diff --git a/.claude/settings.json b/.claude/settings.json
index 1844cfd..4a6d001 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -41,7 +41,12 @@
"Read(//opt/**)",
"Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path, substr\\(encode_plan_json,1,500\\) as plan FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
"Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state FROM jobs ORDER BY id DESC LIMIT 10;\")",
- "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE title LIKE ''%Kill%'' OR id IN \\(69, 212, 213\\) ORDER BY id DESC;\")"
+ "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE title LIKE ''%Kill%'' OR id IN \\(69, 212, 213\\) ORDER BY id DESC;\")",
+ "Bash(find /home/michael/ripster -name *.log -newer /home/michael/ripster/debug/markus.log)",
+ "Bash(pm2 logs:*)",
+ "Bash(journalctl -u ripster --no-pager -n 50)",
+ "Bash(pm2 list:*)",
+ "Bash(journalctl -u ripster-smoketest-http --no-pager -n 40)"
]
}
}
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 6b1ee67..61b9b2a 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
- "version": "0.12.0-15",
+ "version": "0.12.0-16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
- "version": "0.12.0-15",
+ "version": "0.12.0-16",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
diff --git a/backend/package.json b/backend/package.json
index 64bd849..ef74898 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
- "version": "0.12.0-15",
+ "version": "0.12.0-16",
"private": true,
"type": "commonjs",
"scripts": {
diff --git a/backend/src/plugins/ConverterPlugin.js b/backend/src/plugins/ConverterPlugin.js
index 7222575..ba35f90 100644
--- a/backend/src/plugins/ConverterPlugin.js
+++ b/backend/src/plugins/ConverterPlugin.js
@@ -5,7 +5,7 @@ const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const { spawnTrackedProcess } = require('../services/processRunner');
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
-const { ensureDir, sanitizeFileName } = require('../utils/files');
+const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
@@ -74,6 +74,20 @@ function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {
args.push('-codec:a', 'copy');
}
+ // Metadata tags
+ const trackTitle = opts.trackTitle ? String(opts.trackTitle).trim() : null;
+ const trackArtist = (opts.trackArtist || opts.albumArtist)
+ ? String(opts.trackArtist || opts.albumArtist).trim() : null;
+ const albumTitle = opts.albumTitle ? String(opts.albumTitle).trim() : null;
+ const albumYear = opts.albumYear ? String(opts.albumYear).trim() : null;
+ const trackNumber = opts.trackNumber != null ? String(opts.trackNumber) : null;
+
+ if (trackTitle) args.push('-metadata', `title=${trackTitle}`);
+ if (trackArtist) args.push('-metadata', `artist=${trackArtist}`);
+ if (albumTitle) args.push('-metadata', `album=${albumTitle}`);
+ if (albumYear) args.push('-metadata', `date=${albumYear}`);
+ if (trackNumber) args.push('-metadata', `track=${trackNumber}`);
+
args.push(outputFile);
return { cmd: ffmpegCmd, args };
}
@@ -345,12 +359,110 @@ class ConverterPlugin extends SourcePlugin {
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
const formatOptions = encodePlan.audioFormatOptions || {};
const isFolder = Boolean(encodePlan.isFolder);
+ const isSharedAudio = Boolean(encodePlan.isSharedAudio)
+ || (Array.isArray(encodePlan.inputPaths) && encodePlan.inputPaths.length > 0 && !isFolder);
+
+ // Metadata from user config
+ const planMetadata = encodePlan.metadata || {};
+ const planTracks = Array.isArray(encodePlan.tracks) ? encodePlan.tracks : [];
if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) {
throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`);
}
- if (isFolder) {
+ /** Build per-track metadata opts merged with album-level metadata */
+ function trackMetaOpts(position, defaultBaseName) {
+ const trackMeta = planTracks.find((t) => t.position === position) || {};
+ return {
+ ...formatOptions,
+ trackTitle: trackMeta.title || defaultBaseName || null,
+ trackArtist: trackMeta.artist || planMetadata.albumArtist || null,
+ albumTitle: planMetadata.albumTitle || null,
+ albumArtist: planMetadata.albumArtist || null,
+ albumYear: planMetadata.albumYear ? String(planMetadata.albumYear) : null,
+ trackNumber: position
+ };
+ }
+
+ /** Build output filename with optional track number + title */
+ function buildOutputFileName(position, trackMeta, defaultBaseName) {
+ const numStr = String(position).padStart(2, '0');
+ const title = trackMeta?.title ? sanitizeFileName(trackMeta.title) : null;
+ const templateRaw = String(encodePlan.audioTrackTemplate || '').trim();
+ if (templateRaw) {
+ const rendered = renderTemplate(templateRaw, {
+ trackNr: numStr,
+ trackNumber: String(position),
+ title: title || sanitizeFileName(defaultBaseName) || `Track ${numStr}`,
+ artist: sanitizeFileName(trackMeta?.artist || planMetadata.albumArtist || '') || 'unknown',
+ album: sanitizeFileName(planMetadata.albumTitle || '') || 'unknown',
+ year: planMetadata.albumYear || 'unknown'
+ });
+ const sanitized = sanitizeFileName(rendered);
+ if (sanitized) {
+ return `${sanitized}.${outputFormat}`;
+ }
+ }
+ if (title) return `${numStr} - ${title}.${outputFormat}`;
+ return `${sanitizeFileName(defaultBaseName) || numStr}.${outputFormat}`;
+ }
+
+ if (isSharedAudio) {
+ // Freigegebene Audio-Dateien (mehrere Einzeldateien in einem Job)
+ const inputPaths = encodePlan.inputPaths || [];
+ if (inputPaths.length === 0) {
+ throw new Error('ConverterPlugin._encodeAudio(): inputPaths leer für shared-audio-Job');
+ }
+ if (!outputDir) {
+ throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für shared-audio-Job');
+ }
+ ensureDir(outputDir);
+
+ ctx.logger.info('converter:encode:audio:shared:start', {
+ jobId: job?.id, fileCount: inputPaths.length, outputDir, format: outputFormat
+ });
+
+ for (let i = 0; i < inputPaths.length; i++) {
+ if (typeof isCancelled === 'function' && isCancelled()) {
+ const err = new Error('Job wurde vom Benutzer abgebrochen.');
+ err.statusCode = 409;
+ throw err;
+ }
+
+ const inputFile = inputPaths[i];
+ const fileName = path.basename(inputFile);
+ const baseName = path.basename(fileName, path.extname(fileName));
+ const position = i + 1;
+ const trackMeta = planTracks.find((t) => t.position === position) || {};
+ const outputFileName = buildOutputFileName(position, trackMeta, baseName);
+ const outputFile = path.join(outputDir, outputFileName);
+
+ ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
+ ctx.emitProgress(
+ Math.round((i / inputPaths.length) * 100),
+ `Audio: ${position}/${inputPaths.length} — ${fileName}`
+ );
+
+ const encodeConfig = buildAudioFfmpegArgs(
+ ffmpegCmd, inputFile, outputFile, outputFormat,
+ trackMetaOpts(position, baseName)
+ );
+
+ await _spawnAndWait({
+ cmd: encodeConfig.cmd, args: encodeConfig.args,
+ jobId: job?.id,
+ isCancelled,
+ onProcessHandle,
+ context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
+ });
+ }
+
+ ctx.logger.info('converter:encode:audio:shared:done', {
+ jobId: job?.id, fileCount: inputPaths.length, outputDir
+ });
+
+ ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: inputPaths.length };
+ } else if (isFolder) {
// Ordner-Modus: alle Audio-Dateien im Ordner konvertieren
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
@@ -373,15 +485,21 @@ class ConverterPlugin extends SourcePlugin {
const fileName = audioFiles[i];
const inputFile = path.join(inputPath, fileName);
const baseName = path.basename(fileName, path.extname(fileName));
- const outputFile = path.join(outputDir, `${sanitizeFileName(baseName)}.${outputFormat}`);
+ const position = i + 1;
+ const trackMeta = planTracks.find((t) => t.position === position) || {};
+ const outputFileName = buildOutputFileName(position, trackMeta, baseName);
+ const outputFile = path.join(outputDir, outputFileName);
- ctx.logger.info(`converter:encode:audio:track:${i + 1}`, { inputFile, outputFile });
+ ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
ctx.emitProgress(
Math.round((i / audioFiles.length) * 100),
- `Audio: ${i + 1}/${audioFiles.length} — ${fileName}`
+ `Audio: ${position}/${audioFiles.length} — ${fileName}`
);
- const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, outputFormat, formatOptions);
+ const encodeConfig = buildAudioFfmpegArgs(
+ ffmpegCmd, inputFile, outputFile, outputFormat,
+ trackMetaOpts(position, baseName)
+ );
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
@@ -409,7 +527,11 @@ class ConverterPlugin extends SourcePlugin {
});
ctx.emitProgress(0, `Audio: Encoding läuft …`);
- const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputPath, outputPath, outputFormat, formatOptions);
+ const baseName = path.basename(inputPath, path.extname(inputPath));
+ const encodeConfig = buildAudioFfmpegArgs(
+ ffmpegCmd, inputPath, outputPath, outputFormat,
+ trackMetaOpts(1, baseName)
+ );
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
diff --git a/backend/src/routes/converterRoutes.js b/backend/src/routes/converterRoutes.js
index 29f197f..f8f219b 100644
--- a/backend/src/routes/converterRoutes.js
+++ b/backend/src/routes/converterRoutes.js
@@ -146,6 +146,90 @@ router.post(
})
);
+/**
+ * POST /api/converter/jobs/:jobId/assign-files
+ * Body: { relPaths: string[] }
+ * Fügt ausgewählte Dateien einem bestehenden (nicht gestarteten) Converter-Job hinzu.
+ */
+router.post(
+ '/jobs/:jobId/assign-files',
+ asyncHandler(async (req, res) => {
+ const jobId = Number(req.params.jobId);
+ const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
+ if (!Number.isFinite(jobId) || jobId <= 0) {
+ return res.status(400).json({ detail: 'Ungültige jobId.' });
+ }
+ if (relPaths.length === 0) {
+ return res.status(400).json({ detail: 'Keine Dateien übergeben.' });
+ }
+ logger.info('post:jobs:assign-files', { jobId, count: relPaths.length });
+ const result = await pipelineService.assignConverterFilesToJob(jobId, relPaths);
+ res.json(result);
+ })
+);
+
+/**
+ * POST /api/converter/jobs/:jobId/remove-file
+ * Body: { relPath: string }
+ * Entfernt eine Datei aus einem bestehenden Converter-Job.
+ */
+router.post(
+ '/jobs/:jobId/remove-file',
+ asyncHandler(async (req, res) => {
+ const jobId = Number(req.params.jobId);
+ const relPath = String(req.body?.relPath || '').trim();
+ if (!Number.isFinite(jobId) || jobId <= 0) {
+ return res.status(400).json({ detail: 'Ungültige jobId.' });
+ }
+ if (!relPath) {
+ return res.status(400).json({ detail: 'relPath fehlt.' });
+ }
+ logger.info('post:jobs:remove-file', { jobId, relPath });
+ const result = await pipelineService.removeConverterFileFromJob(jobId, relPath);
+ res.json(result);
+ })
+);
+
+/**
+ * POST /api/converter/jobs/:jobId/remove-input
+ * Body: { inputPath: string }
+ * Entfernt eine Datei aus einem Converter-Job anhand des absoluten Input-Pfads.
+ */
+router.post(
+ '/jobs/:jobId/remove-input',
+ asyncHandler(async (req, res) => {
+ const jobId = Number(req.params.jobId);
+ const inputPath = String(req.body?.inputPath || '').trim();
+ if (!Number.isFinite(jobId) || jobId <= 0) {
+ return res.status(400).json({ detail: 'Ungültige jobId.' });
+ }
+ if (!inputPath) {
+ return res.status(400).json({ detail: 'inputPath fehlt.' });
+ }
+ logger.info('post:jobs:remove-input', { jobId, inputPath });
+ const result = await pipelineService.removeConverterInputFromJob(jobId, inputPath);
+ res.json(result);
+ })
+);
+
+/**
+ * POST /api/converter/jobs/:jobId/config
+ * Body: partial config draft (outputFormat, presets, metadata, tracks, MusicBrainz-UI-Stand)
+ * Speichert den Draft für READY_TO_START Jobs persistent im encode_plan_json.
+ */
+router.post(
+ '/jobs/:jobId/config',
+ asyncHandler(async (req, res) => {
+ const jobId = Number(req.params.jobId);
+ if (!Number.isFinite(jobId) || jobId <= 0) {
+ return res.status(400).json({ detail: 'Ungültige jobId.' });
+ }
+ logger.debug('post:jobs:config', { jobId });
+ const result = await pipelineService.updateConverterJobConfig(jobId, req.body || {});
+ res.json(result);
+ })
+);
+
// ── Datei-Operationen (reines FS, keine DB) ───────────────────────────────
/**
@@ -299,7 +383,7 @@ router.post(
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('post:jobs:cancel', { jobId });
- const result = await pipelineService.cancelJob(jobId);
+ const result = await pipelineService.cancel(jobId);
res.json({ result });
})
);
@@ -313,7 +397,8 @@ router.delete(
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('delete:jobs', { jobId });
- await historyService.deleteJob(jobId);
+ await historyService.deleteJob(jobId, 'none', { includeRelated: false });
+ await converterScanService.clearAssignmentsForJob(jobId);
res.json({ ok: true });
})
);
diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js
index 7adda3c..181a706 100644
--- a/backend/src/routes/pipelineRoutes.js
+++ b/backend/src/routes/pipelineRoutes.js
@@ -385,8 +385,15 @@ router.post(
const jobId = Number(req.params.jobId);
const keepBoth = Boolean(req.body?.keepBoth);
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
- logger.info('post:restart-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
- const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders });
+ const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob);
+ logger.info('post:restart-review', {
+ reqId: req.reqId,
+ jobId,
+ keepBoth,
+ reuseCurrentJob,
+ deleteFolderCount: deleteFolders.length
+ });
+ const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders, reuseCurrentJob });
res.json({ result });
})
);
diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js
index 36c5103..90e73bf 100644
--- a/backend/src/services/cdRipService.js
+++ b/backend/src/services/cdRipService.js
@@ -381,6 +381,35 @@ async function runProcessTracked({
}
}
+/**
+ * Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir.
+ * Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files).
+ */
+function getSortedPlainWavFiles(rawWavDir) {
+ try {
+ return fs.readdirSync(rawWavDir)
+ .filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
+ } catch (_) {
+ return [];
+ }
+}
+
+/**
+ * Resolves the WAV file path for a given track position.
+ * Primary: track01.cdda.wav (cdparanoia format)
+ * Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames)
+ */
+function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) {
+ const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`);
+ if (fs.existsSync(cddaPath)) return cddaPath;
+ if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) {
+ const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]);
+ if (fs.existsSync(fallback)) return fallback;
+ }
+ return cddaPath;
+}
+
/**
* Rip and encode a CD.
*
@@ -458,10 +487,12 @@ async function ripAndEncode(options) {
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
+ const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null;
+
if (skipRip) {
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
for (const track of tracksToRip) {
- const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
+ const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
if (!fs.existsSync(wavFile)) {
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
}
@@ -550,7 +581,7 @@ async function ripAndEncode(options) {
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
- const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
+ const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
onProgress && onProgress({
phase: 'encode',
@@ -582,7 +613,7 @@ async function ripAndEncode(options) {
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
- const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
+ const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
if (!fs.existsSync(wavFile)) {
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
diff --git a/backend/src/services/converterScanService.js b/backend/src/services/converterScanService.js
index ec0a6b7..0aff036 100644
--- a/backend/src/services/converterScanService.js
+++ b/backend/src/services/converterScanService.js
@@ -251,14 +251,120 @@ async function getEntryByRelPath(relPath) {
return row || null;
}
-async function markEntryAsJob(relPath, jobId) {
+async function setEntryJobAssignment(relPath, jobId) {
+ const normalizedRelPath = normalizeRelPath(relPath);
+ if (normalizedRelPath === null || normalizedRelPath === '') {
+ throw makeError('Ungültiger relPath für Job-Zuweisung.', 400);
+ }
+ const normalizedJobId = Number(jobId);
+ if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
+ throw makeError('Ungültige jobId für Job-Zuweisung.', 400);
+ }
+
+ const db = await getDb();
+ const existing = await db.get(
+ `SELECT entry_type, file_size, detected_media_type, detected_format
+ FROM converter_scan_entries
+ WHERE rel_path = ?`,
+ [normalizedRelPath]
+ );
+
+ let entryType = String(existing?.entry_type || 'file').trim() || 'file';
+ let fileSize = existing?.file_size ?? null;
+ let detectedMediaType = existing?.detected_media_type ?? null;
+ let detectedFormat = existing?.detected_format ?? null;
+
+ const rawDir = await getRawDir();
+ const absPath = rawDir ? path.join(rawDir, normalizedRelPath) : null;
+ if (absPath && fs.existsSync(absPath)) {
+ try {
+ const stat = fs.statSync(absPath);
+ entryType = stat.isDirectory() ? 'directory' : 'file';
+ fileSize = stat.isFile() ? stat.size : null;
+ if (stat.isFile()) {
+ const fileName = path.basename(normalizedRelPath);
+ detectedMediaType = detectMediaType(fileName);
+ detectedFormat = detectFormat(fileName);
+ }
+ } catch (_err) {
+ // Keep existing metadata values if stat/read fails.
+ }
+ }
+
+ await db.run(
+ `
+ INSERT INTO converter_scan_entries (
+ rel_path,
+ entry_type,
+ file_size,
+ detected_media_type,
+ detected_format,
+ job_id,
+ last_seen_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ ON CONFLICT(rel_path) DO UPDATE SET
+ entry_type = excluded.entry_type,
+ file_size = excluded.file_size,
+ detected_media_type = excluded.detected_media_type,
+ detected_format = excluded.detected_format,
+ job_id = excluded.job_id,
+ last_seen_at = excluded.last_seen_at
+ `,
+ [
+ normalizedRelPath,
+ entryType,
+ fileSize,
+ detectedMediaType,
+ detectedFormat,
+ Math.trunc(normalizedJobId)
+ ]
+ );
+}
+
+async function clearEntryJobAssignment(relPath, expectedJobId = null) {
+ const normalizedRelPath = normalizeRelPath(relPath);
+ if (normalizedRelPath === null || normalizedRelPath === '') {
+ throw makeError('Ungültiger relPath für Job-Entfernung.', 400);
+ }
+ const db = await getDb();
+ const normalizedExpected = Number(expectedJobId);
+ if (Number.isFinite(normalizedExpected) && normalizedExpected > 0) {
+ await db.run(
+ `UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ? AND job_id = ?`,
+ [normalizedRelPath, Math.trunc(normalizedExpected)]
+ );
+ return;
+ }
+ await db.run(
+ `UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ?`,
+ [normalizedRelPath]
+ );
+}
+
+async function clearAssignmentsForJob(jobId) {
+ const normalizedJobId = Number(jobId);
+ if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
+ throw makeError('Ungültige jobId für Job-Entfernung.', 400);
+ }
const db = await getDb();
await db.run(
- `UPDATE converter_scan_entries SET job_id = ? WHERE rel_path = ?`,
- [jobId, relPath]
+ `UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`,
+ [Math.trunc(normalizedJobId)]
);
}
+async function assignEntriesToJob(relPaths, jobId) {
+ const normalizedPaths = Array.isArray(relPaths) ? relPaths : [];
+ for (const relPath of normalizedPaths) {
+ await setEntryJobAssignment(relPath, jobId);
+ }
+}
+
+async function markEntryAsJob(relPath, jobId) {
+ await setEntryJobAssignment(relPath, jobId);
+}
+
async function getRawDir() {
const settings = await settingsService.getSettingsMap();
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
@@ -480,7 +586,7 @@ async function createFolder(parentRelPath, name) {
const TREE_MAX_DEPTH = 8;
-function buildRawTree(rawDir, relPath, depth) {
+function buildRawTree(rawDir, relPath, depth, assignments = new Map()) {
if (depth >= TREE_MAX_DEPTH) return [];
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
let dirents;
@@ -503,19 +609,23 @@ function buildRawTree(rawDir, relPath, depth) {
if (dirent.name.startsWith('.')) continue;
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
if (dirent.isDirectory()) {
- const children = buildRawTree(rawDir, childRel, depth + 1);
+ const children = buildRawTree(rawDir, childRel, depth + 1, assignments);
const size = children.reduce((s, c) => s + (c.size || 0), 0);
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
} else if (dirent.isFile()) {
let size = 0;
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
+ const assignment = assignments.get(childRel) || null;
nodes.push({
name: dirent.name,
type: 'file',
path: childRel,
size,
detectedMediaType: detectMediaType(dirent.name),
- detectedFormat: detectFormat(dirent.name)
+ detectedFormat: detectFormat(dirent.name),
+ jobId: assignment?.jobId || null,
+ jobTitle: assignment?.jobTitle || null,
+ jobStatus: assignment?.jobStatus || null
});
}
}
@@ -527,7 +637,30 @@ async function getTree() {
if (!rawDir || !fs.existsSync(rawDir)) {
return { rawDir: rawDir || null, tree: null };
}
- const children = buildRawTree(rawDir, '', 0);
+ const db = await getDb();
+ const rows = await db.all(`
+ SELECT
+ e.rel_path,
+ e.job_id,
+ j.title AS job_title,
+ j.detected_title AS job_detected_title,
+ j.status AS job_status
+ FROM converter_scan_entries e
+ LEFT JOIN jobs j ON j.id = e.job_id
+ WHERE e.job_id IS NOT NULL
+ `);
+ const assignments = new Map();
+ for (const row of rows) {
+ const rel = String(row?.rel_path || '').trim();
+ const jobId = Number(row?.job_id);
+ if (!rel || !Number.isFinite(jobId) || jobId <= 0) continue;
+ assignments.set(rel, {
+ jobId: Math.trunc(jobId),
+ jobTitle: String(row?.job_title || row?.job_detected_title || '').trim() || null,
+ jobStatus: String(row?.job_status || '').trim() || null
+ });
+ }
+ const children = buildRawTree(rawDir, '', 0, assignments);
const size = children.reduce((s, c) => s + (c.size || 0), 0);
return {
rawDir,
@@ -541,6 +674,10 @@ module.exports = {
getEntryById,
getEntryByRelPath,
markEntryAsJob,
+ setEntryJobAssignment,
+ clearEntryJobAssignment,
+ clearAssignmentsForJob,
+ assignEntriesToJob,
getRawDir,
normalizeRelPath,
getTree,
diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js
index fa90587..9851119 100644
--- a/backend/src/services/historyService.js
+++ b/backend/src/services/historyService.js
@@ -2991,6 +2991,75 @@ class HistoryService {
}));
}
+ async findLatestReplacementJobId(sourceJobId) {
+ const normalizedSourceJobId = normalizeJobIdValue(sourceJobId);
+ if (!normalizedSourceJobId) {
+ return null;
+ }
+
+ const db = await getDb();
+ const rows = await db.all(
+ `
+ SELECT a.job_id
+ FROM job_lineage_artifacts a
+ JOIN jobs j ON j.id = a.job_id
+ WHERE a.source_job_id = ?
+ ORDER BY a.id DESC
+ `,
+ [normalizedSourceJobId]
+ );
+
+ for (const row of (Array.isArray(rows) ? rows : [])) {
+ const candidateJobId = normalizeJobIdValue(row?.job_id);
+ if (!candidateJobId || candidateJobId === normalizedSourceJobId) {
+ continue;
+ }
+ return candidateJobId;
+ }
+
+ return null;
+ }
+
+ async getJobByIdOrReplacement(jobId) {
+ const normalizedJobId = normalizeJobIdValue(jobId);
+ if (!normalizedJobId) {
+ return {
+ requestedJobId: null,
+ resolvedJobId: null,
+ replaced: false,
+ job: null
+ };
+ }
+
+ const directJob = await this.getJobById(normalizedJobId);
+ if (directJob) {
+ return {
+ requestedJobId: normalizedJobId,
+ resolvedJobId: normalizedJobId,
+ replaced: false,
+ job: directJob
+ };
+ }
+
+ const replacementJobId = await this.findLatestReplacementJobId(normalizedJobId);
+ if (!replacementJobId) {
+ return {
+ requestedJobId: normalizedJobId,
+ resolvedJobId: normalizedJobId,
+ replaced: false,
+ job: null
+ };
+ }
+
+ const replacementJob = await this.getJobById(replacementJobId);
+ return {
+ requestedJobId: normalizedJobId,
+ resolvedJobId: replacementJobId,
+ replaced: Boolean(replacementJob),
+ job: replacementJob || null
+ };
+ }
+
async getRunningJobs() {
const db = await getDb();
const [rows, settings] = await Promise.all([
@@ -4357,21 +4426,72 @@ class HistoryService {
const rawRoot = normalizeComparablePath(effectiveRawDir);
const stat = fs.lstatSync(rawPath);
const isFile = stat.isFile();
+ const plan = resolvedPaths.encodePlan || {};
- // Für Converter-Jobs: Datei liegt in einem Unterordner → Ordner löschen
- let deletePath = rawPath;
- if (isFile && resolvedPaths.mediaType === 'converter') {
- const parentDir = normalizeComparablePath(path.dirname(rawPath));
- if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
- deletePath = parentDir;
+ // Für Converter-Jobs: nur die zum Job gehörenden Dateien löschen.
+ // Der übergeordnete Ordner wird nur entfernt, wenn er danach leer ist.
+ // Ausnahme: Ordner-Jobs (isFolder=true) haben einen dedizierten Ordner → komplett löschen.
+ const isConverterFolder = resolvedPaths.mediaType === 'converter' && Boolean(plan.isFolder);
+ const isSharedAudio = resolvedPaths.mediaType === 'converter' && Boolean(plan.isSharedAudio);
+ const isConverterFileJob = resolvedPaths.mediaType === 'converter' && !isConverterFolder;
+
+ if (isConverterFileJob) {
+ // Bestimme die zu löschenden Dateien dieses Jobs
+ let filesToDelete = null;
+ if (isSharedAudio && Array.isArray(plan.inputPaths) && plan.inputPaths.length > 0) {
+ // Shared-Audio-Job: nur die explizit gelisteten Einzeldateien entfernen
+ filesToDelete = plan.inputPaths.map(normalizeComparablePath).filter(Boolean);
+ } else if (isFile) {
+ // Einzeldatei-Job: nur diese eine Datei entfernen
+ filesToDelete = [rawPath];
}
- }
- const keepRoot = deletePath === rawRoot;
- const result = deleteFilesRecursively(deletePath, keepRoot);
- summary.raw.deleted = true;
- summary.raw.filesDeleted = result.filesDeleted;
- summary.raw.dirsRemoved = result.dirsRemoved;
+ if (filesToDelete) {
+ let filesDeleted = 0;
+ const parentDirs = new Set();
+ for (const filePath of filesToDelete) {
+ if (!isPathInside(rawRoot, filePath)) continue;
+ if (!fs.existsSync(filePath)) continue;
+ const fStat = fs.lstatSync(filePath);
+ if (!fStat.isFile()) continue;
+ fs.unlinkSync(filePath);
+ filesDeleted++;
+ const parentDir = normalizeComparablePath(path.dirname(filePath));
+ if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
+ parentDirs.add(parentDir);
+ }
+ }
+ // Übergeordneten Ordner löschen, wenn er nach dem Löschen leer ist
+ let dirsRemoved = 0;
+ for (const parentDir of parentDirs) {
+ try {
+ if (!fs.existsSync(parentDir)) continue;
+ const remaining = fs.readdirSync(parentDir);
+ if (remaining.length === 0) {
+ fs.rmdirSync(parentDir);
+ dirsRemoved++;
+ }
+ } catch (_err) { /* ignore */ }
+ }
+ summary.raw.deleted = true;
+ summary.raw.filesDeleted = filesDeleted;
+ summary.raw.dirsRemoved = dirsRemoved;
+ } else {
+ // Fallback: rawPath ist ein Verzeichnis ohne explizite Dateiliste
+ const keepRoot = rawPath === rawRoot;
+ const result = deleteFilesRecursively(rawPath, keepRoot);
+ summary.raw.deleted = true;
+ summary.raw.filesDeleted = result.filesDeleted;
+ summary.raw.dirsRemoved = result.dirsRemoved;
+ }
+ } else {
+ // Regulärer Job oder dedizierter Converter-Ordner-Job (isFolder=true): gesamten Pfad löschen
+ const keepRoot = rawPath === rawRoot;
+ const result = deleteFilesRecursively(rawPath, keepRoot);
+ summary.raw.deleted = true;
+ summary.raw.filesDeleted = result.filesDeleted;
+ summary.raw.dirsRemoved = result.dirsRemoved;
+ }
}
}
diff --git a/backend/src/services/omdbService.js b/backend/src/services/omdbService.js
index 57d8d6e..025fb58 100644
--- a/backend/src/services/omdbService.js
+++ b/backend/src/services/omdbService.js
@@ -1,7 +1,93 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('OMDB');
+const OMDB_BASE_URL = 'https://www.omdbapi.com/';
+const OMDB_TIMEOUT_MS = 10000;
+const OMDB_MAX_ATTEMPTS = 2;
+const OMDB_RETRY_BASE_DELAY_MS = 300;
+
+function normalizeOmdbTimeoutMs(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ return OMDB_TIMEOUT_MS;
+ }
+ return Math.max(1000, Math.trunc(parsed));
+}
+
+function isRetryableOmdbError(error, aborted = false) {
+ if (aborted) {
+ return true;
+ }
+ const code = String(error?.code || '').trim().toUpperCase();
+ if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') {
+ return true;
+ }
+ const causeCode = String(error?.cause?.code || '').trim().toUpperCase();
+ if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') {
+ return true;
+ }
+ const message = String(error?.message || '').toLowerCase();
+ return message.includes('timed out') || message.includes('timeout');
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
class OmdbService {
+ async requestJson(url, meta = {}, options = {}) {
+ const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs);
+ const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS)));
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetch(url, {
+ headers: {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Ripster/1.0'
+ },
+ signal: controller.signal
+ });
+ if (!response.ok) {
+ logger.warn('request:http-failed', {
+ ...meta,
+ status: response.status,
+ attempt,
+ maxAttempts
+ });
+ return null;
+ }
+ return await response.json();
+ } catch (error) {
+ const aborted = error?.name === 'AbortError';
+ const retryable = isRetryableOmdbError(error, aborted);
+ const willRetry = retryable && attempt < maxAttempts;
+ logger[willRetry ? 'info' : 'warn']('request:failed', {
+ ...meta,
+ timeoutMs,
+ aborted,
+ retryable,
+ attempt,
+ maxAttempts,
+ willRetry,
+ message: error?.message || String(error)
+ });
+ if (willRetry) {
+ await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt);
+ continue;
+ }
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ return null;
+ }
+
async search(query) {
if (!query || query.trim().length === 0) {
return [];
@@ -13,20 +99,18 @@ class OmdbService {
if (!apiKey) {
return [];
}
+ const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const type = settings.omdb_default_type || 'movie';
- const url = new URL('https://www.omdbapi.com/');
+ const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('s', query.trim());
url.searchParams.set('type', type);
- const response = await fetch(url);
- if (!response.ok) {
- logger.error('search:http-failed', { query, status: response.status });
- throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
+ const data = await this.requestJson(url, { query, action: 'search' }, { timeoutMs });
+ if (!data || typeof data !== 'object') {
+ return [];
}
-
- const data = await response.json();
if (data.Response === 'False' || !Array.isArray(data.Search)) {
logger.warn('search:no-results', { query, response: data.Response, error: data.Error });
return [];
@@ -54,19 +138,17 @@ class OmdbService {
if (!apiKey) {
return null;
}
+ const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
- const url = new URL('https://www.omdbapi.com/');
+ const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('i', normalizedId);
url.searchParams.set('plot', 'full');
- const response = await fetch(url);
- if (!response.ok) {
- logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status });
- throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
+ const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
+ if (!data || typeof data !== 'object') {
+ return null;
}
-
- const data = await response.json();
if (data.Response === 'False') {
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
return null;
diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js
index d2761f9..b1a8e75 100644
--- a/backend/src/services/pipelineService.js
+++ b/backend/src/services/pipelineService.js
@@ -75,6 +75,16 @@ const RAW_FOLDER_STATES = Object.freeze({
RIP_COMPLETE: 'rip_complete',
COMPLETE: 'complete'
});
+const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({
+ low: 1,
+ medium: 2,
+ high: 3
+});
+const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35;
+const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35;
+const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220;
+const FORCED_SUBTITLE_MIN_EVENT_GAP = 12;
+const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024;
function nowIso() {
return new Date().toISOString();
@@ -1448,8 +1458,173 @@ function normalizePositiveTrackId(rawValue) {
return Math.trunc(parsed);
}
-function isLikelyForcedSubtitleTrack(track) {
- const text = [
+function normalizeSubtitleConfidence(raw) {
+ const value = String(raw || '').trim().toLowerCase();
+ if (value === 'high' || value === 'medium' || value === 'low') {
+ return value;
+ }
+ return 'low';
+}
+
+function subtitleConfidenceScore(raw) {
+ const normalized = normalizeSubtitleConfidence(raw);
+ return SUBTITLE_CONFIDENCE_SCORES[normalized] || 0;
+}
+
+function parseSubtitleEventCount(track) {
+ const candidates = [
+ track?.eventCount,
+ track?.EventCount,
+ track?.countOfEvents,
+ track?.CountOfEvents,
+ track?.elementCount,
+ track?.ElementCount
+ ];
+ for (const candidate of candidates) {
+ const numeric = Number(candidate);
+ if (Number.isFinite(numeric) && numeric >= 0) {
+ return Math.trunc(numeric);
+ }
+ }
+ return null;
+}
+
+function parseSubtitleStreamSizeBytes(track) {
+ const numericCandidates = [
+ track?.streamSizeBytes,
+ track?.streamSize,
+ track?.StreamSize,
+ track?.sizeBytes,
+ track?.bytes,
+ track?.Bytes
+ ];
+ for (const candidate of numericCandidates) {
+ const numeric = Number(candidate);
+ if (Number.isFinite(numeric) && numeric > 0) {
+ return Math.trunc(numeric);
+ }
+ }
+
+ const textCandidates = [
+ track?.streamSizeString,
+ track?.StreamSize_String,
+ track?.sizeString,
+ track?.Size_String
+ ];
+ for (const candidate of textCandidates) {
+ const parsed = parseSizeToBytes(candidate);
+ if (parsed > 0) {
+ return parsed;
+ }
+ }
+ return null;
+}
+
+function parseSubtitleDefaultFlag(track) {
+ if (typeof track?.defaultFlag === 'boolean') {
+ return track.defaultFlag;
+ }
+ const candidates = [
+ track?.default,
+ track?.Default,
+ track?.isDefault,
+ track?.IsDefault,
+ track?.subtitlePreviewDefaultTrack,
+ track?.defaultTrack
+ ];
+ for (const candidate of candidates) {
+ if (typeof candidate === 'boolean') {
+ return candidate;
+ }
+ const raw = String(candidate || '').trim().toLowerCase();
+ if (!raw) {
+ continue;
+ }
+ if (raw === 'yes' || raw === 'true' || raw === '1') {
+ return true;
+ }
+ if (raw === 'no' || raw === 'false' || raw === '0') {
+ return false;
+ }
+ }
+ return false;
+}
+
+function parseLooseBoolean(value) {
+ if (typeof value === 'boolean') {
+ return value;
+ }
+ if (typeof value === 'number') {
+ if (value === 1) {
+ return true;
+ }
+ if (value === 0) {
+ return false;
+ }
+ }
+ const raw = String(value || '').trim().toLowerCase();
+ if (!raw) {
+ return null;
+ }
+ if (raw === 'yes' || raw === 'true' || raw === '1' || raw === 'y') {
+ return true;
+ }
+ if (raw === 'no' || raw === 'false' || raw === '0' || raw === 'n') {
+ return false;
+ }
+ return null;
+}
+
+function parseSubtitleForcedFlag(track) {
+ if (!track || typeof track !== 'object') {
+ return null;
+ }
+ const candidates = [
+ track?.forcedFlag,
+ track?.forced,
+ track?.Forced,
+ track?.isForced,
+ track?.IsForced,
+ track?.forced_only,
+ track?.forcedOnly,
+ track?.Attributes?.Forced
+ ];
+ for (const candidate of candidates) {
+ const parsed = parseLooseBoolean(candidate);
+ if (parsed === true || parsed === false) {
+ return parsed;
+ }
+ }
+ return null;
+}
+
+function parseSubtitleSdhFlag(track) {
+ if (!track || typeof track !== 'object') {
+ return null;
+ }
+ const candidates = [
+ track?.sdhFlag,
+ track?.hearingImpaired,
+ track?.HearingImpaired,
+ track?.isHearingImpaired,
+ track?.IsHearingImpaired,
+ track?.Hearing_Impaired,
+ track?.closedCaptions,
+ track?.ClosedCaptions,
+ track?.Attributes?.HearingImpaired,
+ track?.Attributes?.ClosedCaptions
+ ];
+ for (const candidate of candidates) {
+ const parsed = parseLooseBoolean(candidate);
+ if (parsed === true || parsed === false) {
+ return parsed;
+ }
+ }
+ return null;
+}
+
+function collectSubtitleText(track) {
+ return [
track?.title,
track?.description,
track?.name,
@@ -1459,10 +1634,56 @@ function isLikelyForcedSubtitleTrack(track) {
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
+}
+
+function isLikelyBitmapSubtitleFormat(track) {
+ const text = [
+ track?.format,
+ track?.codec,
+ track?.codecName,
+ track?.title,
+ track?.description,
+ track?.name,
+ track?.label
+ ]
+ .map((value) => String(value || '').trim().toLowerCase())
+ .filter(Boolean)
+ .join(' ');
if (!text) {
return false;
}
- if (/\bnot forced\b/.test(text)) {
+ return (
+ /\bpgs\b/.test(text)
+ || /\bhdmv\b/.test(text)
+ || /\bsup\b/.test(text)
+ || /\bvobsub\b/.test(text)
+ || /\bdvd[-_\s]?sub/.test(text)
+ || /\bdvb[-_\s]?sub/.test(text)
+ );
+}
+
+function isLikelySdhSubtitleTrack(track) {
+ const text = collectSubtitleText(track);
+ if (!text) {
+ return false;
+ }
+ return (
+ /\bsdh\b/.test(text)
+ || /\bcc\b/.test(text)
+ || /\bhoh\b/.test(text)
+ || /\bcaptions?\b/.test(text)
+ || /hard[-\s]?of[-\s]?hearing/.test(text)
+ || /hearing\s+impaired/.test(text)
+ || /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text)
+ );
+}
+
+function isLikelyForcedSubtitleTrack(track) {
+ const text = collectSubtitleText(track);
+ if (!text) {
+ return false;
+ }
+ if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) {
return false;
}
return (
@@ -1472,6 +1693,122 @@ function isLikelyForcedSubtitleTrack(track) {
);
}
+function compareSubtitleTracksByForcedHeuristic(a, b) {
+ const aSdh = Number(Boolean(a?.sdhLikely));
+ const bSdh = Number(Boolean(b?.sdhLikely));
+ if (aSdh !== bSdh) {
+ return aSdh - bSdh;
+ }
+
+ const aDefault = Number(Boolean(a?.defaultFlag));
+ const bDefault = Number(Boolean(b?.defaultFlag));
+ if (aDefault !== bDefault) {
+ return aDefault - bDefault;
+ }
+
+ const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1;
+ const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1;
+ if (aEventKnown !== bEventKnown) {
+ return aEventKnown - bEventKnown;
+ }
+ if (aEventKnown === 0 && a.eventCount !== b.eventCount) {
+ return a.eventCount - b.eventCount;
+ }
+
+ const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1;
+ const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1;
+ if (aSizeKnown !== bSizeKnown) {
+ return aSizeKnown - bSizeKnown;
+ }
+ if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) {
+ return a.streamSizeBytes - b.streamSizeBytes;
+ }
+
+ const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
+ const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
+ if (aTrackId !== bTrackId) {
+ return aTrackId - bTrackId;
+ }
+ return a.originalIndex - b.originalIndex;
+}
+
+function isHeuristicForcedSubtitleCandidate(entries, candidate) {
+ if (!candidate) {
+ return false;
+ }
+ if (!isLikelyBitmapSubtitleFormat(candidate)) {
+ return false;
+ }
+ const comparable = (Array.isArray(entries) ? entries : [])
+ .filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry));
+ if (comparable.length === 0) {
+ return false;
+ }
+
+ const candidateEventCount = Number(candidate?.eventCount);
+ const comparableEventCounts = comparable
+ .map((entry) => Number(entry?.eventCount))
+ .filter((value) => Number.isFinite(value) && value > 0);
+ const maxComparableEventCount = comparableEventCounts.length > 0
+ ? Math.max(...comparableEventCounts)
+ : null;
+ const hasEventSignal = Number.isFinite(candidateEventCount)
+ && candidateEventCount >= 0
+ && Number.isFinite(maxComparableEventCount)
+ && maxComparableEventCount > 0
+ && candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT
+ && (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD
+ && (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP;
+
+ const candidateStreamSize = Number(candidate?.streamSizeBytes);
+ const comparableSizes = comparable
+ .map((entry) => Number(entry?.streamSizeBytes))
+ .filter((value) => Number.isFinite(value) && value > 0);
+ const maxComparableSize = comparableSizes.length > 0
+ ? Math.max(...comparableSizes)
+ : null;
+ const hasSizeSignal = Number.isFinite(candidateStreamSize)
+ && candidateStreamSize > 0
+ && Number.isFinite(maxComparableSize)
+ && maxComparableSize > 0
+ && (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD
+ && (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES;
+
+ const signalCount = Number(hasEventSignal) + Number(hasSizeSignal);
+ if (signalCount === 0) {
+ return false;
+ }
+ if (candidate.defaultFlag && signalCount < 2) {
+ return false;
+ }
+ return true;
+}
+
+function compareSubtitleTracksForDedup(a, b) {
+ const aSdh = Number(Boolean(a?.sdhLikely));
+ const bSdh = Number(Boolean(b?.sdhLikely));
+ if (aSdh !== bSdh) {
+ return aSdh - bSdh;
+ }
+
+ const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
+ if (defaultDiff !== 0) {
+ return defaultDiff;
+ }
+
+ const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence);
+ if (confidenceDiff !== 0) {
+ return confidenceDiff;
+ }
+
+ const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
+ const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
+ if (aTrackId !== bTrackId) {
+ return aTrackId - bTrackId;
+ }
+ return a.originalIndex - b.originalIndex;
+}
+
function annotateSubtitleForcedAvailability(handBrakeSubtitleTracks, makeMkvSubtitleTracks) {
const hbTracks = Array.isArray(handBrakeSubtitleTracks) ? handBrakeSubtitleTracks : [];
if (hbTracks.length === 0) {
@@ -1479,37 +1816,234 @@ function annotateSubtitleForcedAvailability(handBrakeSubtitleTracks, makeMkvSubt
}
const mkTracks = Array.isArray(makeMkvSubtitleTracks) ? makeMkvSubtitleTracks : [];
- const forcedSourceIdsByLanguage = new Map();
-
- for (const track of mkTracks) {
- if (!isLikelyForcedSubtitleTrack(track)) {
- continue;
- }
+ const normalizedMkTracks = mkTracks.map((track, index) => {
const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
const sourceTrackId = normalizePositiveTrackId(track?.sourceTrackId ?? track?.id);
- if (!sourceTrackId) {
- continue;
+ return {
+ ...track,
+ language,
+ sourceTrackId,
+ originalIndex: index
+ };
+ });
+
+ const mkBySourceTrackId = new Map();
+ const mkByLanguage = new Map();
+ for (const track of normalizedMkTracks) {
+ if (track.sourceTrackId) {
+ const key = String(track.sourceTrackId);
+ if (!mkBySourceTrackId.has(key)) {
+ mkBySourceTrackId.set(key, track);
+ }
}
- if (!forcedSourceIdsByLanguage.has(language)) {
- forcedSourceIdsByLanguage.set(language, []);
+ if (!mkByLanguage.has(track.language)) {
+ mkByLanguage.set(track.language, []);
}
- const list = forcedSourceIdsByLanguage.get(language);
- if (!list.includes(sourceTrackId)) {
- list.push(sourceTrackId);
+ mkByLanguage.get(track.language).push(track);
+ }
+
+ const normalizedHbTracks = hbTracks.map((track, index) => {
+ const id = normalizePositiveTrackId(track?.id) || normalizeScanTrackId(track?.id, index);
+ const forcedFlag = parseSubtitleForcedFlag(track);
+ const sdhFlag = parseSubtitleSdhFlag(track);
+ return {
+ track,
+ originalIndex: index,
+ id,
+ sourceTrackId: normalizePositiveTrackId(track?.sourceTrackId ?? track?.id),
+ language: normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'),
+ defaultFlag: parseSubtitleDefaultFlag(track),
+ forcedFlag,
+ sdhFlag,
+ eventCount: parseSubtitleEventCount(track),
+ streamSizeBytes: parseSubtitleStreamSizeBytes(track),
+ mkTrack: null,
+ sdhLikely: (sdhFlag === true) || isLikelySdhSubtitleTrack(track),
+ forced: false,
+ confidence: null,
+ confidenceSource: 'heuristic',
+ duplicate: false,
+ selected: false
+ };
+ });
+
+ const hbByLanguage = new Map();
+ for (const entry of normalizedHbTracks) {
+ if (!hbByLanguage.has(entry.language)) {
+ hbByLanguage.set(entry.language, []);
+ }
+ hbByLanguage.get(entry.language).push(entry);
+ }
+
+ for (const [language, entries] of hbByLanguage.entries()) {
+ const sortedEntries = [...entries].sort((a, b) => (
+ (a.sourceTrackId || Number.MAX_SAFE_INTEGER) - (b.sourceTrackId || Number.MAX_SAFE_INTEGER)
+ || a.originalIndex - b.originalIndex
+ ));
+ const mkLanguageTracks = [...(mkByLanguage.get(language) || [])].sort((a, b) => (
+ (a.sourceTrackId || Number.MAX_SAFE_INTEGER) - (b.sourceTrackId || Number.MAX_SAFE_INTEGER)
+ || a.originalIndex - b.originalIndex
+ ));
+
+ for (let index = 0; index < sortedEntries.length; index += 1) {
+ const entry = sortedEntries[index];
+ let mkMatch = null;
+ if (entry.sourceTrackId) {
+ mkMatch = mkBySourceTrackId.get(String(entry.sourceTrackId)) || null;
+ if (mkMatch && mkMatch.language !== language) {
+ mkMatch = null;
+ }
+ }
+ if (!mkMatch) {
+ if (mkLanguageTracks.length === sortedEntries.length) {
+ mkMatch = mkLanguageTracks[index] || null;
+ } else if (mkLanguageTracks.length === 1) {
+ mkMatch = mkLanguageTracks[0];
+ }
+ }
+ entry.mkTrack = mkMatch;
+ if (mkMatch && ((parseSubtitleSdhFlag(mkMatch) === true) || isLikelySdhSubtitleTrack(mkMatch))) {
+ entry.sdhLikely = true;
+ }
}
}
- return hbTracks.map((track) => {
- const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
- const forcedSourceTrackIds = normalizeTrackIdList(forcedSourceIdsByLanguage.get(language) || []);
- const forcedTrack = isLikelyForcedSubtitleTrack(track);
- return {
- ...track,
- forcedTrack,
- forcedAvailable: forcedTrack || forcedSourceTrackIds.length > 0,
- forcedSourceTrackIds
- };
- });
+ for (const entry of normalizedHbTracks) {
+ const forcedByExplicitFlag = entry.forcedFlag === true;
+ const forcedBlockedByExplicitFlag = entry.forcedFlag === false;
+ const forcedFromTitle = !entry.sdhLikely
+ && !forcedBlockedByExplicitFlag
+ && (isLikelyForcedSubtitleTrack(entry.track) || isLikelyForcedSubtitleTrack(entry.mkTrack));
+
+ if (forcedByExplicitFlag) {
+ entry.forced = true;
+ entry.confidence = 'high';
+ entry.confidenceSource = 'explicit_flag';
+ } else if (forcedFromTitle) {
+ entry.forced = true;
+ entry.confidence = 'medium';
+ entry.confidenceSource = 'title';
+ } else {
+ entry.forced = false;
+ entry.confidence = null;
+ entry.confidenceSource = 'heuristic';
+ }
+ }
+
+ for (const [language, entries] of hbByLanguage.entries()) {
+ if (!entries.some((entry) => entry.forced)) {
+ const heuristicCandidate = [...entries]
+ .filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false)
+ .sort(compareSubtitleTracksByForcedHeuristic)[0] || null;
+
+ if (!entries.some((entry) => entry.forced) && heuristicCandidate
+ && isHeuristicForcedSubtitleCandidate(entries, heuristicCandidate)) {
+ // Last-resort: infer forced by comparatively tiny event/size profile.
+ // Avoid auto-detection for SDH/CC tracks and weak one-track cases.
+ heuristicCandidate.forced = true;
+ heuristicCandidate.confidence = 'low';
+ heuristicCandidate.confidenceSource = 'heuristic';
+ }
+ }
+
+ for (const entry of entries) {
+ if (!entry.forced) {
+ continue;
+ }
+ if (entry.confidenceSource === 'explicit_flag') {
+ entry.confidence = 'high';
+ } else if (entry.confidenceSource === 'title') {
+ entry.confidence = 'medium';
+ } else {
+ entry.confidence = 'low';
+ }
+ }
+
+ const forcedEntries = entries.filter((entry) => entry.forced);
+ const fullEntries = entries.filter((entry) => !entry.forced && !entry.sdhLikely);
+ const sdhEntries = entries.filter((entry) => !entry.forced && entry.sdhLikely);
+ const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null;
+ const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null;
+ const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null;
+ const forcedSourceTrackIds = normalizeTrackIdList([
+ ...(forcedWinner?.sourceTrackId ? [forcedWinner.sourceTrackId] : [])
+ ]);
+ const forcedAvailable = Boolean(forcedWinner);
+
+ for (const entry of entries) {
+ const typeWinner = entry.forced
+ ? forcedWinner
+ : (entry.sdhLikely ? sdhWinner : fullWinner);
+ entry.duplicate = Boolean(typeWinner && typeWinner !== entry);
+ if (entry.forced) {
+ entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
+ } else if (entry.sdhLikely) {
+ // Keep SDH as an available variant, but auto-select it only when no regular full track exists.
+ entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate);
+ } else {
+ entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
+ }
+ const subtitleType = entry.forced ? 'forced' : 'full';
+ const confidence = entry.forced
+ ? normalizeSubtitleConfidence(entry.confidence || 'low')
+ : null;
+ const burned = Boolean(entry.track?.subtitlePreviewBurnIn || entry.track?.burnIn);
+ const defaultTrack = Boolean(entry.defaultFlag);
+ const forcedOnly = Boolean(
+ entry.track?.isForcedOnly
+ ?? entry.track?.forcedOnly
+ ?? entry.track?.subtitlePreviewForcedOnly
+ ?? (entry.forced && !entry.sdhLikely)
+ );
+ const fullHasForced = !forcedOnly && Boolean(
+ entry.track?.fullHasForced
+ ?? entry.track?.subtitleFullHasForced
+ ?? entry.track?.hasForcedVariant
+ );
+ const flags = [];
+ if (burned) {
+ flags.push('burned');
+ }
+ if (entry.selected && entry.forced) {
+ flags.push('forced');
+ }
+ if (forcedOnly) {
+ flags.push('forced-only');
+ }
+ if (entry.selected && defaultTrack) {
+ flags.push('default');
+ }
+
+ entry.track = {
+ ...entry.track,
+ id: entry.id,
+ sourceTrackId: entry.sourceTrackId || entry.id,
+ language: entry.language,
+ forcedTrack: entry.forced,
+ forcedAvailable,
+ forcedSourceTrackIds,
+ isForcedOnly: forcedOnly,
+ fullHasForced,
+ subtitleType,
+ sourceConfidence: confidence,
+ confidenceSource: entry.confidenceSource,
+ sdhLikely: Boolean(entry.sdhLikely),
+ duplicate: entry.duplicate,
+ selected: entry.selected,
+ selectedByRule: entry.selected,
+ subtitlePreviewSummary: entry.selected ? 'Übernehmen' : (entry.duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'),
+ subtitlePreviewFlags: entry.selected ? flags : [],
+ subtitlePreviewBurnIn: entry.selected ? burned : false,
+ subtitlePreviewForced: entry.selected ? entry.forced : false,
+ subtitlePreviewForcedOnly: entry.selected ? forcedOnly : false,
+ subtitlePreviewDefaultTrack: entry.selected ? defaultTrack : false
+ };
+ }
+ }
+
+ return normalizedHbTracks
+ .sort((a, b) => a.originalIndex - b.originalIndex)
+ .map((entry) => entry.track);
}
function enrichTitleInfoWithForcedSubtitleAvailability(titleInfo, makeMkvSubtitleTracks) {
@@ -1705,7 +2239,10 @@ function buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo) {
Language: track?.language || 'und',
Language_String3: track?.language || 'und',
Title: track?.title || null,
- Format: track?.format || null
+ Format: track?.format || null,
+ Default: track?.defaultFlag ? 'Yes' : 'No',
+ CountOfEvents: Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null,
+ StreamSize: Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null
});
}
@@ -2187,7 +2724,12 @@ function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) {
languageLabel,
title: track?.Name || track?.Description || null,
format: track?.SourceName || track?.Format || track?.Codec || null,
- channels: null
+ channels: null,
+ forcedFlag: parseSubtitleForcedFlag(track),
+ sdhFlag: parseSubtitleSdhFlag(track),
+ defaultFlag: parseSubtitleDefaultFlag(track),
+ eventCount: parseSubtitleEventCount(track),
+ streamSizeBytes: parseSubtitleStreamSizeBytes(track)
};
})
.filter((track) => Number.isFinite(Number(track?.sourceTrackId)) && Number(track.sourceTrackId) > 0);
@@ -2372,6 +2914,11 @@ function buildDiscScanReview({
languageLabel,
title: item?.Name || item?.Description || null,
format: item?.SourceName || item?.Format || null,
+ forcedFlag: parseSubtitleForcedFlag(item),
+ sdhFlag: parseSubtitleSdhFlag(item),
+ defaultFlag: parseSubtitleDefaultFlag(item),
+ eventCount: parseSubtitleEventCount(item),
+ streamSizeBytes: parseSubtitleStreamSizeBytes(item),
selectedByRule: true,
subtitlePreviewSummary: 'Übernehmen',
subtitlePreviewFlags: [],
@@ -2452,15 +2999,17 @@ function buildDiscScanReview({
}),
subtitleTracks: title.subtitleTracks.map((track) => {
const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule);
+ const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : [];
+ const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen';
return {
...track,
selectedForEncode,
- burnIn: false,
- forced: false,
- forcedOnly: false,
- defaultTrack: false,
- flags: [],
- subtitleActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen'
+ burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false,
+ forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false,
+ forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false,
+ defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false,
+ flags: selectedForEncode ? previewFlags : [],
+ subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen'
};
})
};
@@ -2701,6 +3250,30 @@ function isPathInsideDirectory(parentPath, candidatePath) {
return candidate.startsWith(parentWithSep);
}
+function remapPathToRetargetedRawRoot(inputPath, previousRawPath, nextRawPath) {
+ const sourceInputPath = String(inputPath || '').trim();
+ if (!sourceInputPath) {
+ return null;
+ }
+ const previousRaw = normalizeComparablePath(previousRawPath);
+ const nextRaw = normalizeComparablePath(nextRawPath);
+ const input = normalizeComparablePath(sourceInputPath);
+ if (!previousRaw || !nextRaw || !input) {
+ return sourceInputPath;
+ }
+ if (input === previousRaw) {
+ return nextRaw;
+ }
+ if (!isPathInsideDirectory(previousRaw, input)) {
+ return sourceInputPath;
+ }
+ const relative = path.relative(previousRaw, input);
+ if (!relative || relative === '.') {
+ return nextRaw;
+ }
+ return path.join(nextRaw, relative);
+}
+
function isEncodeInputMismatchedWithRaw(rawPath, encodeInputPath) {
const raw = normalizeComparablePath(rawPath);
const input = normalizeComparablePath(encodeInputPath);
@@ -3231,6 +3804,29 @@ function normalizeTrackIdList(rawList) {
return output;
}
+function normalizeTrackIdSequence(rawList, options = {}) {
+ const list = Array.isArray(rawList) ? rawList : [];
+ const dedupe = options?.dedupe !== false;
+ const seen = new Set();
+ const output = [];
+ for (const item of list) {
+ const value = Number(item);
+ if (!Number.isFinite(value) || value <= 0) {
+ continue;
+ }
+ const normalized = Math.trunc(value);
+ const key = String(normalized);
+ if (dedupe && seen.has(key)) {
+ continue;
+ }
+ if (dedupe) {
+ seen.add(key);
+ }
+ output.push(normalized);
+ }
+ return output;
+}
+
function isBurnedSubtitleTrack(track) {
const previewFlags = Array.isArray(track?.subtitlePreviewFlags)
? track.subtitlePreviewFlags
@@ -3245,6 +3841,373 @@ function isBurnedSubtitleTrack(track) {
);
}
+function normalizeSubtitleVariantSelection(rawSelection) {
+ const map = new Map();
+ const order = [];
+ const seenOrder = new Set();
+
+ const append = (rawLanguage, rawValue = null) => {
+ const language = normalizeTrackLanguage(rawLanguage);
+ if (!language) {
+ return;
+ }
+
+ let full = false;
+ let forced = false;
+ if (typeof rawValue === 'string') {
+ const lowered = rawValue.trim().toLowerCase();
+ if (lowered === 'both' || lowered === 'full+forced' || lowered === 'forced+full') {
+ full = true;
+ forced = true;
+ } else {
+ full = lowered.includes('full');
+ forced = lowered.includes('forced');
+ }
+ } else if (Array.isArray(rawValue)) {
+ const normalizedItems = rawValue.map((item) => String(item || '').trim().toLowerCase());
+ full = normalizedItems.includes('full');
+ forced = normalizedItems.includes('forced');
+ } else {
+ full = Boolean(rawValue?.full);
+ forced = Boolean(rawValue?.forced);
+ }
+
+ map.set(language, { full, forced });
+ if (!seenOrder.has(language)) {
+ seenOrder.add(language);
+ order.push(language);
+ }
+ };
+
+ if (Array.isArray(rawSelection)) {
+ for (const entry of rawSelection) {
+ append(entry?.language ?? entry?.lang ?? entry?.code, entry);
+ }
+ } else if (rawSelection && typeof rawSelection === 'object') {
+ for (const [language, value] of Object.entries(rawSelection)) {
+ append(language, value);
+ }
+ }
+
+ return { map, order };
+}
+
+function normalizeSubtitleLanguageOrder(rawOrder = []) {
+ const list = Array.isArray(rawOrder) ? rawOrder : [];
+ const output = [];
+ const seen = new Set();
+ for (const item of list) {
+ const language = normalizeTrackLanguage(item);
+ if (!language || seen.has(language)) {
+ continue;
+ }
+ seen.add(language);
+ output.push(language);
+ }
+ return output;
+}
+
+function normalizeSubtitlePhysicalTrack(track, originalIndex = 0) {
+ const id = normalizeTrackIdList([track?.id ?? track?.sourceTrackId])[0] || null;
+ if (!id) {
+ return null;
+ }
+
+ const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
+ const explicitForcedOnly = Boolean(
+ track?.isForcedOnly
+ ?? track?.subtitlePreviewForcedOnly
+ ?? track?.forcedOnly
+ ?? track?.forcedTrackOnly
+ );
+ const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
+ const isForcedOnly = explicitForcedOnly || subtitleType === 'forced';
+ const fullHasForced = !isForcedOnly && Boolean(
+ track?.fullHasForced
+ ?? track?.subtitleFullHasForced
+ ?? track?.hasForcedVariant
+ ?? track?.fullTrackHasForced
+ );
+ const confidence = normalizeSubtitleConfidence(track?.sourceConfidence || track?.confidence || 'low');
+
+ return {
+ id,
+ language,
+ defaultFlag: parseSubtitleDefaultFlag(track),
+ confidence,
+ isForcedOnly,
+ fullHasForced,
+ selectedForEncode: Boolean(track?.selectedForEncode),
+ originalIndex
+ };
+}
+
+function compareSubtitleForcedCandidatePriority(a, b) {
+ const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
+ if (defaultDiff !== 0) {
+ return defaultDiff;
+ }
+ const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence);
+ if (confidenceDiff !== 0) {
+ return confidenceDiff;
+ }
+ if (a.id !== b.id) {
+ return a.id - b.id;
+ }
+ return a.originalIndex - b.originalIndex;
+}
+
+function compareSubtitleFullCandidatePriority(a, b) {
+ const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
+ if (defaultDiff !== 0) {
+ return defaultDiff;
+ }
+ if (a.id !== b.id) {
+ return a.id - b.id;
+ }
+ return a.originalIndex - b.originalIndex;
+}
+
+function buildSubtitleVariantSelectionFromTrackIds(subtitleTracks, selectedTrackIds = []) {
+ const requestedTrackIds = new Set(normalizeTrackIdList(selectedTrackIds).map(String));
+ const candidates = (Array.isArray(subtitleTracks) ? subtitleTracks : [])
+ .map((track, index) => normalizeSubtitlePhysicalTrack(track, index))
+ .filter(Boolean)
+ .filter((track) => !requestedTrackIds.size || requestedTrackIds.has(String(track.id)));
+
+ const map = new Map();
+ const order = [];
+ const seenOrder = new Set();
+ for (const track of candidates) {
+ const current = map.get(track.language) || { full: false, forced: false };
+ if (track.isForcedOnly) {
+ current.forced = true;
+ } else {
+ current.full = true;
+ }
+ map.set(track.language, current);
+ if (!seenOrder.has(track.language)) {
+ seenOrder.add(track.language);
+ order.push(track.language);
+ }
+ }
+ return { map, order };
+}
+
+function buildEmptySubtitleVariantSelectionForAllLanguages(subtitleTracks) {
+ const map = new Map();
+ const order = [];
+ const seen = new Set();
+ const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
+ for (const track of tracks) {
+ if (isBurnedSubtitleTrack(track) || Boolean(track?.duplicate)) {
+ continue;
+ }
+ const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
+ if (!language || seen.has(language)) {
+ continue;
+ }
+ seen.add(language);
+ order.push(language);
+ map.set(language, { full: false, forced: false });
+ }
+ return { map, order };
+}
+
+function resolveDeterministicSubtitleSelection({
+ subtitleTracks = [],
+ subtitleVariantSelection = null,
+ subtitleLanguageOrder = [],
+ fallbackSelectedSubtitleTrackIds = [],
+ fallbackSelectedSubtitleTrackIdsOrdered = [],
+ hasExplicitVariantSelection = false
+} = {}) {
+ const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : [])
+ .map((track, index) => ({ track, normalized: normalizeSubtitlePhysicalTrack(track, index) }))
+ .filter((entry) => Boolean(entry.normalized))
+ .filter((entry) => !isBurnedSubtitleTrack(entry.track) && !Boolean(entry.track?.duplicate))
+ .map((entry) => entry.normalized);
+
+ const groupedByLanguage = new Map();
+ for (const track of tracks) {
+ if (!groupedByLanguage.has(track.language)) {
+ groupedByLanguage.set(track.language, []);
+ }
+ groupedByLanguage.get(track.language).push(track);
+ }
+
+ const fallbackSelectionSet = new Set(normalizeTrackIdList(fallbackSelectedSubtitleTrackIds).map(String));
+ const explicitSelection = normalizeSubtitleVariantSelection(subtitleVariantSelection);
+ const explicitOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder);
+ const fallbackSelectionFromTrackIds = buildSubtitleVariantSelectionFromTrackIds(
+ subtitleTracks,
+ fallbackSelectedSubtitleTrackIds
+ );
+ const fallbackSelectionFromOrderedTrackIds = buildSubtitleVariantSelectionFromTrackIds(
+ subtitleTracks,
+ normalizeTrackIdSequence(fallbackSelectedSubtitleTrackIdsOrdered, { dedupe: false })
+ );
+
+ const availableLanguagesSorted = Array.from(groupedByLanguage.entries())
+ .map(([language, languageTracks]) => {
+ const minTrackId = languageTracks.reduce((minValue, track) => Math.min(minValue, track.id), Number.MAX_SAFE_INTEGER);
+ return { language, minTrackId };
+ })
+ .sort((a, b) => a.minTrackId - b.minTrackId || a.language.localeCompare(b.language))
+ .map((item) => item.language);
+
+ const languageOrder = [];
+ const seenLanguages = new Set();
+ const pushLanguage = (language) => {
+ if (!groupedByLanguage.has(language) || seenLanguages.has(language)) {
+ return;
+ }
+ seenLanguages.add(language);
+ languageOrder.push(language);
+ };
+
+ for (const language of explicitOrder) {
+ pushLanguage(language);
+ }
+ if (!hasExplicitVariantSelection) {
+ for (const language of explicitSelection.order) {
+ pushLanguage(language);
+ }
+ for (const language of fallbackSelectionFromOrderedTrackIds.order) {
+ pushLanguage(language);
+ }
+ for (const language of fallbackSelectionFromTrackIds.order) {
+ pushLanguage(language);
+ }
+ }
+ for (const language of availableLanguagesSorted) {
+ pushLanguage(language);
+ }
+
+ const subtitleTrackIdsOrdered = [];
+ const subtitleForcedSelectionIndexes = [];
+ const selectedPhysicalTrackIds = [];
+ const selectedPhysicalTrackSet = new Set();
+ const validationErrors = [];
+ const normalizedVariantSelection = {};
+
+ const appendTrackId = (trackId, options = {}) => {
+ subtitleTrackIdsOrdered.push(trackId);
+ const position = subtitleTrackIdsOrdered.length;
+ if (options?.forcedIndex) {
+ subtitleForcedSelectionIndexes.push(position);
+ }
+ const key = String(trackId);
+ if (!selectedPhysicalTrackSet.has(key)) {
+ selectedPhysicalTrackSet.add(key);
+ selectedPhysicalTrackIds.push(trackId);
+ }
+ };
+
+ for (const language of languageOrder) {
+ const languageTracks = groupedByLanguage.get(language) || [];
+ const forcedOnlyCandidates = languageTracks
+ .filter((track) => track.isForcedOnly)
+ .sort(compareSubtitleForcedCandidatePriority);
+ const fullCandidates = languageTracks
+ .filter((track) => !track.isForcedOnly)
+ .sort(compareSubtitleFullCandidatePriority);
+ const fullHasForcedCandidates = fullCandidates
+ .filter((track) => track.fullHasForced)
+ .sort(compareSubtitleFullCandidatePriority);
+
+ const bestForcedOnlyTrack = forcedOnlyCandidates[0] || null;
+ const bestFullTrack = fullCandidates[0] || null;
+ const bestFullHasForcedTrack = fullHasForcedCandidates[0] || null;
+
+ const explicitEntry = explicitSelection.map.get(language) || null;
+ const fallbackEntryFromTrackIds = fallbackSelectionFromTrackIds.map.get(language) || null;
+ const fallbackEntryFromOrderedTrackIds = fallbackSelectionFromOrderedTrackIds.map.get(language) || null;
+ const hasAnyFallbackSelectedTrack = languageTracks.some((track) => fallbackSelectionSet.has(String(track.id)) || track.selectedForEncode);
+
+ let requestedFull = false;
+ let requestedForced = false;
+ if (explicitEntry) {
+ requestedFull = Boolean(explicitEntry.full);
+ requestedForced = Boolean(explicitEntry.forced);
+ } else if (hasExplicitVariantSelection) {
+ requestedFull = false;
+ requestedForced = false;
+ } else if (fallbackEntryFromOrderedTrackIds) {
+ requestedFull = Boolean(fallbackEntryFromOrderedTrackIds.full);
+ requestedForced = Boolean(fallbackEntryFromOrderedTrackIds.forced);
+ } else if (fallbackEntryFromTrackIds) {
+ requestedFull = Boolean(fallbackEntryFromTrackIds.full);
+ requestedForced = Boolean(fallbackEntryFromTrackIds.forced);
+ } else if (hasAnyFallbackSelectedTrack) {
+ requestedFull = Boolean(bestFullTrack);
+ requestedForced = Boolean(bestForcedOnlyTrack);
+ } else {
+ requestedFull = Boolean(bestFullTrack);
+ requestedForced = Boolean(bestForcedOnlyTrack);
+ }
+
+ normalizedVariantSelection[language] = {
+ full: requestedFull,
+ forced: requestedForced
+ };
+
+ const languageActive = requestedFull || requestedForced;
+ if (!languageActive) {
+ continue;
+ }
+
+ if (bestForcedOnlyTrack) {
+ // Case A: forced-only track exists -> forced entry is always included.
+ appendTrackId(bestForcedOnlyTrack.id);
+ if (requestedFull && bestFullTrack) {
+ appendTrackId(bestFullTrack.id);
+ }
+ continue;
+ }
+
+ if (bestFullHasForcedTrack) {
+ // Case B: no forced-only track, but full track contains forced content.
+ if (requestedFull && requestedForced) {
+ appendTrackId(bestFullHasForcedTrack.id);
+ appendTrackId(bestFullHasForcedTrack.id, { forcedIndex: true });
+ } else if (requestedForced) {
+ appendTrackId(bestFullHasForcedTrack.id, { forcedIndex: true });
+ } else if (requestedFull) {
+ appendTrackId(bestFullHasForcedTrack.id);
+ }
+ continue;
+ }
+
+ // Case C: full-only track available, forced request is invalid.
+ if (requestedForced && !requestedFull) {
+ validationErrors.push(`Sprache ${language}: Forced wurde gewählt, aber es gibt keinen forced-only oder fullHasForced Track.`);
+ continue;
+ }
+ if (requestedFull && bestFullTrack) {
+ appendTrackId(bestFullTrack.id);
+ continue;
+ }
+ if (!bestFullTrack) {
+ validationErrors.push(`Sprache ${language}: Kein gültiger Full-Track verfügbar.`);
+ }
+ }
+
+ const invalidForcedIndexes = subtitleForcedSelectionIndexes.filter((index) => index <= 0 || index > subtitleTrackIdsOrdered.length);
+ if (invalidForcedIndexes.length > 0) {
+ validationErrors.push(`Ungültige --subtitle-forced Indizes: ${invalidForcedIndexes.join(',')}`);
+ }
+
+ return {
+ subtitleTrackIdsOrdered,
+ subtitleForcedSelectionIndexes,
+ selectedPhysicalTrackIds,
+ subtitleVariantSelection: normalizedVariantSelection,
+ subtitleLanguageOrder: languageOrder,
+ validationErrors
+ };
+}
+
function normalizeScriptIdList(rawList) {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set();
@@ -3272,7 +4235,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
plan: encodePlan,
selectionApplied: false,
audioTrackIds: [],
- subtitleTrackIds: []
+ subtitleTrackIds: [],
+ subtitleForcedTrackIndexes: [],
+ subtitleSelectionValidationErrors: []
};
}
@@ -3282,7 +4247,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
plan,
selectionApplied: false,
audioTrackIds: [],
- subtitleTrackIds: []
+ subtitleTrackIds: [],
+ subtitleForcedTrackIndexes: [],
+ subtitleSelectionValidationErrors: []
};
}
@@ -3294,7 +4261,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
plan,
selectionApplied: false,
audioTrackIds: [],
- subtitleTrackIds: []
+ subtitleTrackIds: [],
+ subtitleForcedTrackIndexes: [],
+ subtitleSelectionValidationErrors: []
};
}
@@ -3306,7 +4275,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
plan,
selectionApplied: false,
audioTrackIds: [],
- subtitleTrackIds: []
+ subtitleTrackIds: [],
+ subtitleForcedTrackIndexes: [],
+ subtitleSelectionValidationErrors: []
};
}
@@ -3316,7 +4287,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
plan,
selectionApplied: false,
audioTrackIds: [],
- subtitleTrackIds: []
+ subtitleTrackIds: [],
+ subtitleForcedTrackIndexes: [],
+ subtitleSelectionValidationErrors: []
};
}
@@ -3328,7 +4301,7 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
);
const validSubtitleTrackIds = new Set(
(Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : [])
- .filter((track) => !isBurnedSubtitleTrack(track))
+ .filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate))
.map((track) => Number(track?.id))
.filter((id) => Number.isFinite(id))
.map((id) => Math.trunc(id))
@@ -3338,9 +4311,35 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
.filter((id) => validAudioTrackIds.has(id));
const requestedSubtitleTrackIds = normalizeTrackIdList(rawSelection.subtitleTrackIds)
.filter((id) => validSubtitleTrackIds.has(id));
+ const requestedSubtitleTrackIdsOrdered = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false })
+ .filter((id) => validSubtitleTrackIds.has(id));
+
+ const explicitSubtitleVariants = normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection);
+ const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder);
+ let effectiveSubtitleVariantSelection = explicitSubtitleVariants;
+ const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds');
+ const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection');
+ if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && hasRawSubtitleTrackIds) {
+ effectiveSubtitleVariantSelection = requestedSubtitleTrackIds.length > 0
+ ? buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds)
+ : buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks);
+ } else if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && requestedSubtitleTrackIds.length > 0) {
+ effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds);
+ }
+
+ const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({
+ subtitleTracks: encodeTitle.subtitleTracks,
+ subtitleVariantSelection: Object.fromEntries(effectiveSubtitleVariantSelection.map.entries()),
+ subtitleLanguageOrder: explicitSubtitleLanguageOrder.length > 0
+ ? explicitSubtitleLanguageOrder
+ : effectiveSubtitleVariantSelection.order,
+ fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds,
+ fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered,
+ hasExplicitVariantSelection: hasRawSubtitleVariantSelection
+ });
const audioSelectionSet = new Set(requestedAudioTrackIds.map((id) => String(id)));
- const subtitleSelectionSet = new Set(requestedSubtitleTrackIds.map((id) => String(id)));
+ const subtitleSelectionSet = new Set(resolvedSubtitleSelection.selectedPhysicalTrackIds.map((id) => String(id)));
const remappedTitles = plan.titles.map((title) => {
const isEncodeInput = Number(title?.id) === encodeInputTitleId;
@@ -3391,13 +4390,20 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
manualTrackSelection: {
titleId: encodeInputTitleId,
audioTrackIds: requestedAudioTrackIds,
- subtitleTrackIds: requestedSubtitleTrackIds,
+ subtitleTrackIds: resolvedSubtitleSelection.selectedPhysicalTrackIds,
+ subtitleTrackIdsOrdered: resolvedSubtitleSelection.subtitleTrackIdsOrdered,
+ subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes,
+ subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection,
+ subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder,
+ subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors,
updatedAt: nowIso()
}
},
selectionApplied: true,
audioTrackIds: requestedAudioTrackIds,
- subtitleTrackIds: requestedSubtitleTrackIds
+ subtitleTrackIds: resolvedSubtitleSelection.subtitleTrackIdsOrdered,
+ subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes,
+ subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors
};
}
@@ -3439,12 +4445,44 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) {
? normalizeTrackIdList(manualSelection.audioTrackIds)
: normalizeTrackIdList(allAudioTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id));
- const subtitleTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIds))
+ const fallbackSubtitleTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIds))
? normalizeTrackIdList(manualSelection.subtitleTrackIds)
: normalizeTrackIdList(allSubtitleTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id));
+ const fallbackSubtitleTrackIdsOrdered = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIdsOrdered))
+ ? normalizeTrackIdSequence(manualSelection.subtitleTrackIdsOrdered, { dedupe: false })
+ : [];
+ const manualSubtitleVariantSelection = manualMatchesTitle && manualSelection?.subtitleVariantSelection
+ ? manualSelection.subtitleVariantSelection
+ : null;
+ const hasManualSubtitleVariantSelection = Boolean(
+ manualMatchesTitle
+ && manualSelection
+ && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleVariantSelection')
+ );
+ const manualSubtitleLanguageOrder = manualMatchesTitle && Array.isArray(manualSelection?.subtitleLanguageOrder)
+ ? manualSelection.subtitleLanguageOrder
+ : [];
+
+ const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({
+ subtitleTracks: allSubtitleTracks,
+ subtitleVariantSelection: manualSubtitleVariantSelection,
+ subtitleLanguageOrder: manualSubtitleLanguageOrder,
+ fallbackSelectedSubtitleTrackIds: fallbackSubtitleTrackIds,
+ fallbackSelectedSubtitleTrackIdsOrdered: fallbackSubtitleTrackIdsOrdered,
+ hasExplicitVariantSelection: hasManualSubtitleVariantSelection
+ });
+
+ const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIdsOrdered.length > 0
+ ? resolvedSubtitleSelection.subtitleTrackIdsOrdered
+ : (hasManualSubtitleVariantSelection
+ ? []
+ : (fallbackSubtitleTrackIdsOrdered.length > 0
+ ? fallbackSubtitleTrackIdsOrdered
+ : fallbackSubtitleTrackIds));
+ const subtitleForcedTrackIndexes = resolvedSubtitleSelection.subtitleForcedSelectionIndexes;
// Resolve burn/default/forced attributes from the actual track objects, matched by track?.id.
- const subtitleTrackIdSet = new Set(subtitleTrackIds.map(String));
+ const subtitleTrackIdSet = new Set(normalizeTrackIdList(subtitleTrackIds).map(String));
const selectedSubtitleTracks = allSubtitleTracks.filter((t) => {
const tid = normalizeTrackIdList([t?.id])[0];
return tid !== undefined && subtitleTrackIdSet.has(String(tid));
@@ -3456,15 +4494,19 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) {
const subtitleDefaultTrackId = normalizeTrackIdList(
selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.id)
)[0] || null;
- const subtitleForcedTrackId = normalizeTrackIdList(
- selectedSubtitleTracks.filter((track) => Boolean(track?.forced)).map((track) => track?.id)
- )[0] || null;
- const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.forcedOnly));
+ const subtitleForcedTrackId = subtitleForcedTrackIndexes.length === 1
+ ? (subtitleTrackIds[subtitleForcedTrackIndexes[0] - 1] || null)
+ : null;
+ const subtitleForcedOnly = false;
return {
titleId: Number(encodeTitle?.id) || null,
audioTrackIds,
subtitleTrackIds,
+ subtitleForcedTrackIndexes,
+ subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection,
+ subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder,
+ subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors,
subtitleBurnTrackId,
subtitleDefaultTrackId,
subtitleForcedTrackId,
@@ -3631,7 +4673,11 @@ function extractManualSelectionPayloadFromPlan(encodePlan) {
}
return {
audioTrackIds: normalizeTrackIdList(selection.audioTrackIds),
- subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds)
+ subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds),
+ subtitleTrackIdsOrdered: normalizeTrackIdSequence(selection.subtitleTrackIds, { dedupe: false }),
+ subtitleForcedTrackIndexes: normalizeTrackIdList(selection.subtitleForcedTrackIndexes),
+ subtitleVariantSelection: selection.subtitleVariantSelection || null,
+ subtitleLanguageOrder: Array.isArray(selection.subtitleLanguageOrder) ? selection.subtitleLanguageOrder : []
};
}
@@ -3780,7 +4826,7 @@ function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) {
function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, { excludeBurned = false } = {}) {
const tracks = Array.isArray(targetTracks) ? targetTracks : [];
const allowedTracks = excludeBurned
- ? tracks.filter((track) => !isBurnedSubtitleTrack(track))
+ ? tracks.filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate))
: tracks;
const requested = normalizeTrackIdList(sourceTrackIds);
if (requested.length === 0 || allowedTracks.length === 0) {
@@ -3869,7 +4915,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n
);
const fallbackSubtitleTrackIds = normalizeTrackIdList(
(Array.isArray(nextSelectedTitle?.subtitleTracks) ? nextSelectedTitle.subtitleTracks : [])
- .filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
+ .filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate))
.map((track) => track?.id)
);
const effectiveAudioTrackIds = previousAudioSourceIds.length > 0 && mappedAudioTrackIds.length === 0
@@ -4388,6 +5434,66 @@ class PipelineService extends EventEmitter {
return this.resolveCurrentRawPath(rawBaseDir, stored, rawExtraDirs);
}
+ async alignRawFolderJobId(rawPath, targetJobId) {
+ const sourceRawPath = String(rawPath || '').trim();
+ const normalizedTargetJobId = this.normalizeQueueJobId(targetJobId);
+ if (!sourceRawPath || !normalizedTargetJobId) {
+ return sourceRawPath || null;
+ }
+
+ const normalizedSourceRawPath = normalizeComparablePath(sourceRawPath);
+ const folderName = path.basename(normalizedSourceRawPath);
+ const baseName = stripRawStatePrefix(folderName);
+ if (!/\s-\sRAW\s-\sjob-\d+\s*$/i.test(baseName)) {
+ return normalizedSourceRawPath;
+ }
+
+ const metadataBase = baseName.replace(/\s-\sRAW\s-\sjob-\d+\s*$/i, '').trim();
+ if (!metadataBase) {
+ return normalizedSourceRawPath;
+ }
+
+ const rawState = resolveRawFolderStateFromPath(normalizedSourceRawPath);
+ const nextFolderName = buildRawDirName(metadataBase, normalizedTargetJobId, { state: rawState });
+ const normalizedTargetRawPath = normalizeComparablePath(
+ path.join(path.dirname(normalizedSourceRawPath), nextFolderName)
+ );
+ if (!normalizedTargetRawPath || normalizedTargetRawPath === normalizedSourceRawPath) {
+ return normalizedSourceRawPath;
+ }
+
+ try {
+ if (!fs.existsSync(normalizedSourceRawPath) || !fs.statSync(normalizedSourceRawPath).isDirectory()) {
+ return normalizedSourceRawPath;
+ }
+ if (fs.existsSync(normalizedTargetRawPath)) {
+ logger.warn('raw-path:align-job-id:target-exists', {
+ targetJobId: normalizedTargetJobId,
+ sourceRawPath: normalizedSourceRawPath,
+ targetRawPath: normalizedTargetRawPath
+ });
+ return normalizedSourceRawPath;
+ }
+
+ fs.renameSync(normalizedSourceRawPath, normalizedTargetRawPath);
+ await historyService.updateRawPathByOldPath(normalizedSourceRawPath, normalizedTargetRawPath);
+ logger.info('raw-path:align-job-id:renamed', {
+ targetJobId: normalizedTargetJobId,
+ from: normalizedSourceRawPath,
+ to: normalizedTargetRawPath
+ });
+ return normalizedTargetRawPath;
+ } catch (error) {
+ logger.warn('raw-path:align-job-id:failed', {
+ targetJobId: normalizedTargetJobId,
+ sourceRawPath: normalizedSourceRawPath,
+ targetRawPath: normalizedTargetRawPath,
+ error: errorToMeta(error)
+ });
+ return normalizedSourceRawPath;
+ }
+ }
+
async migrateRawFolderNamingOnStartup(db) {
const settings = await settingsService.getSettingsMap();
const rawBaseDir = String(settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '').trim();
@@ -4409,6 +5515,7 @@ class PipelineService extends EventEmitter {
SELECT id, title, year, detected_title, raw_path, status, last_state, rip_successful, makemkv_info_json, handbrake_info_json
FROM jobs
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
+ AND (media_type IS NULL OR media_type <> 'converter')
`);
if (!Array.isArray(rows) || rows.length === 0) {
return;
@@ -5369,6 +6476,25 @@ class PipelineService extends EventEmitter {
return Math.trunc(value);
}
+ async resolveExistingJobForAction(jobId, action = 'job_action') {
+ const resolved = await historyService.getJobByIdOrReplacement(jobId);
+ if (resolved?.job) {
+ if (resolved.replaced && resolved.requestedJobId !== resolved.resolvedJobId) {
+ logger.warn('job:resolved-replacement', {
+ action,
+ requestedJobId: resolved.requestedJobId,
+ resolvedJobId: resolved.resolvedJobId
+ });
+ }
+ return resolved;
+ }
+
+ const missingJobId = Number(resolved?.requestedJobId || jobId);
+ const error = new Error(`Job ${missingJobId} nicht gefunden.`);
+ error.statusCode = 404;
+ throw error;
+ }
+
isJobRunningStatus(status) {
return RUNNING_STATES.has(String(status || '').trim().toUpperCase());
}
@@ -6330,8 +7456,39 @@ class PipelineService extends EventEmitter {
return false;
}
+ shouldSuspendDrivePollingForState(state, context = null) {
+ const normalizedState = String(state || '').trim().toUpperCase();
+ const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']);
+ if (DRIVE_ACTIVE_STATES.has(normalizedState)) {
+ return true;
+ }
+
+ // RAW-based review/restart states should not touch optical drives.
+ if (!['READY_TO_ENCODE', 'WAITING_FOR_USER_DECISION'].includes(normalizedState)) {
+ return false;
+ }
+
+ const resolvedContext = context && typeof context === 'object' ? context : {};
+ const review = resolvedContext.mediaInfoReview && typeof resolvedContext.mediaInfoReview === 'object'
+ ? resolvedContext.mediaInfoReview
+ : null;
+ const mode = String(resolvedContext.mode || review?.mode || '').trim().toLowerCase();
+ const isPreRip = mode === 'pre_rip' || Boolean(review?.preRip);
+ if (isPreRip) {
+ return false;
+ }
+
+ const rawPath = String(resolvedContext.rawPath || '').trim();
+ const inputPath = String(resolvedContext.inputPath || review?.encodeInputPath || '').trim();
+ const hasFilesystemInput = [rawPath, inputPath].some((candidate) =>
+ candidate && !candidate.startsWith('disc-track-scan://')
+ );
+ return hasFilesystemInput;
+ }
+
async setState(state, patch = {}) {
const previous = this.snapshot.state;
+ const previousContext = this.snapshot.context;
const previousActiveJobId = this.snapshot.activeJobId;
const contextPatch = patch.context && typeof patch.context === 'object' && !Array.isArray(patch.context)
? patch.context
@@ -6390,10 +7547,11 @@ class PipelineService extends EventEmitter {
statusText: this.snapshot.statusText
});
- const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']);
- if (DRIVE_ACTIVE_STATES.has(state)) {
+ const shouldSuspendCurrentPolling = this.shouldSuspendDrivePollingForState(state, this.snapshot.context);
+ const shouldSuspendPreviousPolling = this.shouldSuspendDrivePollingForState(previous, previousContext);
+ if (shouldSuspendCurrentPolling) {
diskDetectionService.suspendPolling();
- } else if (DRIVE_ACTIVE_STATES.has(previous)) {
+ } else if (shouldSuspendPreviousPolling) {
diskDetectionService.resumePolling();
}
@@ -8068,6 +9226,72 @@ class PipelineService extends EventEmitter {
const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
+ const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule);
+ const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
+ const subtitleType = String(
+ sourceMeta?.subtitleType
+ || track?.subtitleType
+ || (sourceMeta?.forcedTrack ? 'forced' : (track?.forcedTrack ? 'forced' : 'full'))
+ ).trim().toLowerCase() === 'forced'
+ ? 'forced'
+ : 'full';
+ const rawConfidence = String(sourceMeta?.sourceConfidence || track?.sourceConfidence || '')
+ .trim()
+ .toLowerCase();
+ const confidenceSource = String(sourceMeta?.confidenceSource || track?.confidenceSource || '')
+ .trim()
+ .toLowerCase();
+ let sourceConfidence = null;
+ if (subtitleType === 'forced') {
+ if (rawConfidence === 'high' || rawConfidence === 'medium' || rawConfidence === 'low') {
+ sourceConfidence = rawConfidence;
+ } else if (confidenceSource === 'title') {
+ sourceConfidence = 'medium';
+ } else {
+ sourceConfidence = 'low';
+ }
+ }
+ const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn);
+ const subtitlePreviewForced = Boolean(
+ sourceMeta?.subtitlePreviewForced
+ ?? track?.subtitlePreviewForced
+ ?? (selectedByRule && subtitleType === 'forced')
+ );
+ const subtitlePreviewForcedOnly = Boolean(
+ sourceMeta?.subtitlePreviewForcedOnly
+ ?? track?.subtitlePreviewForcedOnly
+ );
+ const isForcedOnly = Boolean(
+ sourceMeta?.isForcedOnly
+ ?? track?.isForcedOnly
+ ?? sourceMeta?.forcedOnly
+ ?? track?.forcedOnly
+ ?? subtitlePreviewForcedOnly
+ ?? subtitleType === 'forced'
+ );
+ const fullHasForced = !isForcedOnly && Boolean(
+ sourceMeta?.fullHasForced
+ ?? track?.fullHasForced
+ ?? sourceMeta?.subtitleFullHasForced
+ ?? track?.subtitleFullHasForced
+ );
+ const subtitlePreviewDefaultTrack = Boolean(
+ sourceMeta?.subtitlePreviewDefaultTrack
+ ?? track?.subtitlePreviewDefaultTrack
+ ?? sourceMeta?.defaultFlag
+ ?? track?.defaultFlag
+ );
+ const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags)
+ ? sourceMeta.subtitlePreviewFlags
+ : (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []);
+ const subtitlePreviewSummary = String(
+ sourceMeta?.subtitlePreviewSummary
+ || track?.subtitlePreviewSummary
+ || (selectedByRule
+ ? 'Übernehmen'
+ : (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
+ ).trim() || 'Nicht übernommen';
+ const selectedForEncode = selectedByRule;
return {
...track,
id: sourceTrackId || track?.id || null,
@@ -8076,9 +9300,37 @@ class PipelineService extends EventEmitter {
languageLabel: sourceMeta?.languageLabel || track?.languageLabel || track?.language || 'und',
title: sourceMeta?.title ?? track?.title ?? null,
format: sourceMeta?.format || track?.format || null,
- forcedTrack: Boolean(sourceMeta?.forcedTrack),
+ defaultFlag: Boolean(sourceMeta?.defaultFlag ?? track?.defaultFlag),
+ eventCount: Number.isFinite(Number(sourceMeta?.eventCount))
+ ? Math.trunc(Number(sourceMeta.eventCount))
+ : (Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null),
+ streamSizeBytes: Number.isFinite(Number(sourceMeta?.streamSizeBytes))
+ ? Math.trunc(Number(sourceMeta.streamSizeBytes))
+ : (Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null),
+ forcedTrack: Boolean(sourceMeta?.forcedTrack ?? subtitlePreviewForced),
forcedAvailable: Boolean(sourceMeta?.forcedAvailable),
- forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || [])
+ forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || []),
+ isForcedOnly,
+ fullHasForced,
+ subtitleType,
+ sourceConfidence,
+ confidenceSource,
+ duplicate,
+ selected: selectedByRule,
+ selectedByRule,
+ selectedForEncode,
+ subtitlePreviewSummary,
+ subtitlePreviewFlags,
+ subtitlePreviewBurnIn,
+ subtitlePreviewForced,
+ subtitlePreviewForcedOnly,
+ subtitlePreviewDefaultTrack,
+ burnIn: selectedForEncode ? subtitlePreviewBurnIn : false,
+ forced: selectedForEncode ? subtitlePreviewForced : false,
+ forcedOnly: selectedForEncode ? subtitlePreviewForcedOnly : false,
+ defaultTrack: selectedForEncode ? subtitlePreviewDefaultTrack : false,
+ flags: selectedForEncode ? subtitlePreviewFlags : [],
+ subtitleActionSummary: selectedForEncode ? subtitlePreviewSummary : 'Nicht übernommen'
};
});
@@ -8641,14 +9893,14 @@ class PipelineService extends EventEmitter {
}
async startPreparedJob(jobId, options = {}) {
+ const resolvedPreparedJob = await this.resolveExistingJobForAction(jobId, 'start_prepared');
+ jobId = resolvedPreparedJob.resolvedJobId;
+ const optionPreloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(jobId)
+ ? options.preloadedJob
+ : null;
const immediate = Boolean(options?.immediate);
if (!immediate) {
- const preloadedJob = await historyService.getJobById(jobId);
- if (!preloadedJob) {
- const error = new Error(`Job ${jobId} nicht gefunden.`);
- error.statusCode = 404;
- throw error;
- }
+ const preloadedJob = optionPreloadedJob || resolvedPreparedJob.job || await historyService.getJobById(jobId);
if (!preloadedJob.title && !preloadedJob.detected_title) {
const error = new Error('Start nicht möglich: keine Metadaten vorhanden.');
error.statusCode = 400;
@@ -8708,12 +9960,7 @@ class PipelineService extends EventEmitter {
return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob });
}
- const job = options?.preloadedJob || await historyService.getJobById(jobId);
- if (!job) {
- const error = new Error(`Job ${jobId} nicht gefunden.`);
- error.statusCode = 404;
- throw error;
- }
+ const job = optionPreloadedJob || resolvedPreparedJob.job || await historyService.getJobById(jobId);
const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json);
const jobEncodePlan = this.safeParseJson(job.encode_plan_json);
@@ -8973,6 +10220,13 @@ class PipelineService extends EventEmitter {
planForConfirm,
options?.selectedTrackSelection || null
);
+ if (Array.isArray(trackSelectionResult.subtitleSelectionValidationErrors) && trackSelectionResult.subtitleSelectionValidationErrors.length > 0) {
+ const error = new Error(
+ `Subtitle-Auswahl ungültig: ${trackSelectionResult.subtitleSelectionValidationErrors.join(' | ')}`
+ );
+ error.statusCode = 400;
+ throw error;
+ }
planForConfirm = trackSelectionResult.plan;
const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined;
const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection
@@ -9097,6 +10351,7 @@ class PipelineService extends EventEmitter {
`Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}`
+ ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.`
+ ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.`
+ + ` Subtitle-Forced-Indizes: ${Array.isArray(trackSelectionResult.subtitleForcedTrackIndexes) && trackSelectionResult.subtitleForcedTrackIndexes.length > 0 ? trackSelectionResult.subtitleForcedTrackIndexes.join(',') : 'none'}.`
+ ` Pre-Encode-Scripte: ${selectedPreEncodeScripts.length > 0 ? selectedPreEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
+ ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.`
+ ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
@@ -9260,7 +10515,7 @@ class PipelineService extends EventEmitter {
// WAV-Dateien im RAW-Verzeichnis suchen
const wavFiles = fs.existsSync(resolvedCdRawPath)
- ? fs.readdirSync(resolvedCdRawPath).filter((f) => f.endsWith('.cdda.wav'))
+ ? fs.readdirSync(resolvedCdRawPath).filter((f) => /\.wav$/i.test(f))
: [];
if (wavFiles.length === 0) {
const error = new Error('CD-Encode nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.');
@@ -9525,16 +10780,13 @@ class PipelineService extends EventEmitter {
}
async restartCdReviewFromRaw(jobId, options = {}) {
+ const resolvedCdReviewJob = await this.resolveExistingJobForAction(jobId, 'restart_cd_review');
+ jobId = resolvedCdReviewJob.resolvedJobId;
this.ensureNotBusy('restartCdReviewFromRaw', jobId);
logger.info('restartCdReviewFromRaw:requested', { jobId, options });
this.cancelRequestedByJob.delete(Number(jobId));
- const sourceJob = await historyService.getJobById(jobId);
- if (!sourceJob) {
- const error = new Error(`Job ${jobId} nicht gefunden.`);
- error.statusCode = 404;
- throw error;
- }
+ const sourceJob = resolvedCdReviewJob.job || await historyService.getJobById(jobId);
const currentStatus = String(sourceJob.status || '').trim().toUpperCase();
if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(currentStatus)) {
@@ -9566,7 +10818,7 @@ class PipelineService extends EventEmitter {
}
const wavFiles = fs.existsSync(resolvedRawPath)
- ? fs.readdirSync(resolvedRawPath).filter((f) => f.endsWith('.cdda.wav'))
+ ? fs.readdirSync(resolvedRawPath).filter((f) => /\.wav$/i.test(f))
: [];
if (wavFiles.length === 0) {
const error = new Error('CD-Vorprüfung nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.');
@@ -9597,8 +10849,15 @@ class PipelineService extends EventEmitter {
const tocTracks = Array.isArray(mkInfo.tracks) && mkInfo.tracks.length > 0
? mkInfo.tracks
- : (Array.isArray(encodePlan.tracks) ? encodePlan.tracks : []);
- const resetTracks = tocTracks.map((track, index) => {
+ : (Array.isArray(encodePlan.tracks) && encodePlan.tracks.length > 0 ? encodePlan.tracks : []);
+ // If no track info available (e.g. orphan import with plain WAV files), derive tracks from sorted WAV files
+ const effectiveTocTracks = tocTracks.length > 0
+ ? tocTracks
+ : wavFiles
+ .filter((f) => /\.wav$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
+ .map((f, i) => ({ position: i + 1, title: `Track ${i + 1}`, artist: null, selected: true }));
+ const resetTracks = effectiveTocTracks.map((track, index) => {
const position = Number(track?.position);
const normalizedPosition = Number.isFinite(position) && position > 0 ? Math.trunc(position) : (index + 1);
return {
@@ -11039,6 +12298,13 @@ class PipelineService extends EventEmitter {
try {
const trackSelection = extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath);
+ if (Array.isArray(trackSelection?.subtitleSelectionValidationErrors) && trackSelection.subtitleSelectionValidationErrors.length > 0) {
+ const error = new Error(
+ `Subtitle-Auswahl ungültig: ${trackSelection.subtitleSelectionValidationErrors.join(' | ')}`
+ );
+ error.statusCode = 400;
+ throw error;
+ }
let handBrakeTitleId = null;
let directoryInput = false;
try {
@@ -11198,7 +12464,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
jobId,
'SYSTEM',
- `HandBrake Track-Override: audio=${trackSelection.audioTrackIds.length > 0 ? trackSelection.audioTrackIds.join(',') : 'none'}, subtitles=${trackSelection.subtitleTrackIds.length > 0 ? trackSelection.subtitleTrackIds.join(',') : 'none'}, subtitle-burned=${trackSelection.subtitleBurnTrackId ?? 'none'}, subtitle-default=${trackSelection.subtitleDefaultTrackId ?? 'none'}, subtitle-forced=${trackSelection.subtitleForcedTrackId ?? (trackSelection.subtitleForcedOnly ? 'forced-only' : 'none')}`
+ `HandBrake Track-Override: audio=${trackSelection.audioTrackIds.length > 0 ? trackSelection.audioTrackIds.join(',') : 'none'}, subtitles=${trackSelection.subtitleTrackIds.length > 0 ? trackSelection.subtitleTrackIds.join(',') : 'none'}, subtitle-burned=${trackSelection.subtitleBurnTrackId ?? 'none'}, subtitle-default=${trackSelection.subtitleDefaultTrackId ?? 'none'}, subtitle-forced-index=${Array.isArray(trackSelection.subtitleForcedTrackIndexes) && trackSelection.subtitleForcedTrackIndexes.length > 0 ? trackSelection.subtitleForcedTrackIndexes.join(',') : 'none'}`
);
}
if (handBrakeTitleId) {
@@ -11772,16 +13038,13 @@ class PipelineService extends EventEmitter {
return this.retry(jobId, { ...options, immediate: true });
}
+ const resolvedRetryJob = await this.resolveExistingJobForAction(jobId, 'retry');
+ jobId = resolvedRetryJob.resolvedJobId;
this.ensureNotBusy('retry', jobId);
logger.info('retry:start', { jobId });
this.cancelRequestedByJob.delete(Number(jobId));
- let sourceJob = await historyService.getJobById(jobId);
- if (!sourceJob) {
- const error = new Error(`Job ${jobId} nicht gefunden.`);
- error.statusCode = 404;
- throw error;
- }
+ let sourceJob = resolvedRetryJob.job || await historyService.getJobById(jobId);
if (!sourceJob.title && !sourceJob.detected_title) {
const error = new Error('Retry nicht möglich: keine Metadaten vorhanden.');
@@ -11982,6 +13245,31 @@ class PipelineService extends EventEmitter {
throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
}
+ let retryRawPath = isAudiobookRetry ? (sourceJob.raw_path || null) : null;
+ let retryEncodeInputPath = isAudiobookRetry
+ ? (
+ sourceJob.encode_input_path
+ || sourceEncodePlan?.encodeInputPath
+ || sourceMakemkvInfo?.rawFilePath
+ || null
+ )
+ : null;
+ if (retryRawPath) {
+ const previousRetryRawPath = retryRawPath;
+ retryRawPath = await this.alignRawFolderJobId(retryRawPath, retryJobId);
+ if (
+ previousRetryRawPath
+ && retryRawPath
+ && normalizeComparablePath(previousRetryRawPath) !== normalizeComparablePath(retryRawPath)
+ ) {
+ retryEncodeInputPath = remapPathToRetargetedRawRoot(
+ retryEncodeInputPath,
+ previousRetryRawPath,
+ retryRawPath
+ );
+ }
+ }
+
const retryUpdatePayload = {
parent_job_id: Number(jobId),
title: sourceJob.title || null,
@@ -11999,17 +13287,10 @@ class PipelineService extends EventEmitter {
encode_plan_json: (isCdRetry || isAudiobookRetry)
? (sourceJob.encode_plan_json || null)
: null,
- encode_input_path: isAudiobookRetry
- ? (
- sourceJob.encode_input_path
- || sourceEncodePlan?.encodeInputPath
- || sourceMakemkvInfo?.rawFilePath
- || null
- )
- : null,
+ encode_input_path: retryEncodeInputPath,
encode_review_confirmed: isAudiobookRetry ? 1 : 0,
output_path: null,
- raw_path: isAudiobookRetry ? (sourceJob.raw_path || null) : null,
+ raw_path: retryRawPath,
status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'),
last_state: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING')
};
@@ -12066,15 +13347,12 @@ class PipelineService extends EventEmitter {
}
async resumeReadyToEncodeJob(jobId) {
+ const resolvedReadyJob = await this.resolveExistingJobForAction(jobId, 'resume_ready_to_encode');
+ jobId = resolvedReadyJob.resolvedJobId;
this.ensureNotBusy('resumeReadyToEncodeJob', jobId);
logger.info('resumeReadyToEncodeJob:requested', { jobId });
- const job = await historyService.getJobById(jobId);
- if (!job) {
- const error = new Error(`Job ${jobId} nicht gefunden.`);
- error.statusCode = 404;
- throw error;
- }
+ const job = resolvedReadyJob.job || await historyService.getJobById(jobId);
const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE';
if (!isReadyToEncode) {
@@ -12153,6 +13431,7 @@ class PipelineService extends EventEmitter {
context: {
...(this.snapshot.context || {}),
jobId,
+ rawPath: activeResumeRawPath,
inputPath,
hasEncodableTitle,
reviewConfirmed,
@@ -12195,17 +13474,14 @@ class PipelineService extends EventEmitter {
return this.restartEncodeWithLastSettings(jobId, { ...options, immediate: true });
}
+ const resolvedRestartEncodeJob = await this.resolveExistingJobForAction(jobId, 'restart_encode');
+ jobId = resolvedRestartEncodeJob.resolvedJobId;
this.ensureNotBusy('restartEncodeWithLastSettings', jobId);
logger.info('restartEncodeWithLastSettings:requested', { jobId });
this.cancelRequestedByJob.delete(Number(jobId));
const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase();
- const job = await historyService.getJobById(jobId);
- if (!job) {
- const error = new Error(`Job ${jobId} nicht gefunden.`);
- error.statusCode = 404;
- throw error;
- }
+ const job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId);
const currentStatus = String(job.status || '').trim().toUpperCase();
if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK'].includes(currentStatus)) {
@@ -12313,7 +13589,7 @@ class PipelineService extends EventEmitter {
readyMediaProfile,
job.raw_path
);
- const activeRestartRawPath = resolvedRestartRawPath || String(job.raw_path || '').trim() || null;
+ let activeRestartRawPath = resolvedRestartRawPath || String(job.raw_path || '').trim() || null;
let inputPath = isPreRipMode
? null
@@ -12354,6 +13630,17 @@ class PipelineService extends EventEmitter {
throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
}
+ const previousRestartRawPath = activeRestartRawPath;
+ activeRestartRawPath = await this.alignRawFolderJobId(activeRestartRawPath, replacementJobId);
+ if (
+ previousRestartRawPath
+ && activeRestartRawPath
+ && normalizeComparablePath(previousRestartRawPath) !== normalizeComparablePath(activeRestartRawPath)
+ ) {
+ inputPath = remapPathToRetargetedRawRoot(inputPath, previousRestartRawPath, activeRestartRawPath);
+ restartPlan.encodeInputPath = inputPath;
+ }
+
await historyService.updateJob(replacementJobId, {
parent_job_id: Number(jobId),
title: job.title || null,
@@ -12421,6 +13708,7 @@ class PipelineService extends EventEmitter {
context: {
...(this.snapshot.context || {}),
jobId: replacementJobId,
+ rawPath: activeRestartRawPath,
inputPath,
hasEncodableTitle,
reviewConfirmed: false,
@@ -12444,16 +13732,13 @@ class PipelineService extends EventEmitter {
}
async restartReviewFromRaw(jobId, options = {}) {
+ const resolvedRestartReviewJob = await this.resolveExistingJobForAction(jobId, 'restart_review');
+ jobId = resolvedRestartReviewJob.resolvedJobId;
this.ensureNotBusy('restartReviewFromRaw', jobId);
logger.info('restartReviewFromRaw:requested', { jobId, options });
this.cancelRequestedByJob.delete(Number(jobId));
- const sourceJob = await historyService.getJobById(jobId);
- if (!sourceJob) {
- const error = new Error(`Job ${jobId} nicht gefunden.`);
- error.statusCode = 404;
- throw error;
- }
+ const sourceJob = resolvedRestartReviewJob.job || await historyService.getJobById(jobId);
if (!sourceJob.raw_path) {
const error = new Error('Review-Neustart nicht möglich: raw_path fehlt.');
@@ -12469,15 +13754,62 @@ class PipelineService extends EventEmitter {
rawPath: sourceJob.raw_path
});
const reviewSettings = await settingsService.getSettingsMap();
- const resolvedReviewRawPath = this.resolveCurrentRawPathForSettings(
+ let resolvedReviewRawPath = this.resolveCurrentRawPathForSettings(
reviewSettings,
reviewMediaProfile,
sourceJob.raw_path
);
if (!resolvedReviewRawPath) {
- const error = new Error(`Review-Neustart nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
- error.statusCode = 400;
- throw error;
+ const storedRawPath = String(sourceJob.raw_path || '').trim();
+ const storedFolderName = path.basename(storedRawPath);
+ const rawState = resolveRawFolderStateFromPath(storedRawPath);
+ const strippedFolderName = stripRawStatePrefix(storedFolderName);
+ const metadataBase = strippedFolderName.replace(/\s-\sRAW\s-\sjob-\d+\s*$/i, '').trim();
+ const recoveryRoots = Array.from(new Set([
+ path.dirname(storedRawPath),
+ String(reviewMakemkvInfo?.rawPath || '').trim(),
+ String(reviewMakemkvInfo?.importContext?.requestedRawPath || '').trim()
+ ].filter(Boolean)));
+
+ let recoveredRawPath = null;
+ for (const rootCandidate of recoveryRoots) {
+ const rootDir = fs.existsSync(rootCandidate) && fs.statSync(rootCandidate).isDirectory()
+ ? rootCandidate
+ : path.dirname(rootCandidate);
+ if (!rootDir || !fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
+ continue;
+ }
+ if (metadataBase) {
+ const byMetadata = findExistingRawDirectory(rootDir, metadataBase);
+ if (byMetadata) {
+ recoveredRawPath = byMetadata;
+ break;
+ }
+ const normalizedJobId = this.normalizeQueueJobId(jobId);
+ if (normalizedJobId) {
+ const targetedFolder = buildRawDirName(metadataBase, normalizedJobId, { state: rawState });
+ const targetedPath = path.join(rootDir, targetedFolder);
+ if (fs.existsSync(targetedPath) && fs.statSync(targetedPath).isDirectory()) {
+ recoveredRawPath = targetedPath;
+ break;
+ }
+ }
+ }
+ }
+
+ if (recoveredRawPath) {
+ resolvedReviewRawPath = recoveredRawPath;
+ await historyService.updateJob(jobId, { raw_path: recoveredRawPath }).catch(() => {});
+ await historyService.appendLog(
+ jobId,
+ 'SYSTEM',
+ `Review-Neustart: RAW-Pfad automatisch korrigiert: ${sourceJob.raw_path || '-'} -> ${recoveredRawPath}`
+ ).catch(() => {});
+ } else {
+ const error = new Error(`Review-Neustart nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
+ error.statusCode = 400;
+ throw error;
+ }
}
const resolvedReviewInput = hasBluRayBackupStructure(resolvedReviewRawPath)
@@ -12556,6 +13888,7 @@ class PipelineService extends EventEmitter {
const nextMakemkvInfoJson = mkInfo && typeof mkInfo === 'object'
? JSON.stringify({
...mkInfo,
+ rawPath: resolvedReviewRawPath,
analyzeContext: {
...(mkInfo?.analyzeContext || {}),
playlistAnalysis: null,
@@ -12584,90 +13917,136 @@ class PipelineService extends EventEmitter {
raw_path: resolvedReviewRawPath
};
- const replacementJob = await historyService.createJob({
- discDevice: sourceJob.disc_device || null,
- status: 'MEDIAINFO_CHECK',
- detectedTitle: sourceJob.detected_title || sourceJob.title || null
- });
- const replacementJobId = Number(replacementJob?.id || 0);
- if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
- throw new Error('Review-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
- }
+ const reuseCurrentJob = Boolean(options?.reuseCurrentJob);
+ const inheritedSourceJobId = this.normalizeQueueJobId(previousEncodePlan?.sourceJobId)
+ || this.normalizeQueueJobId(sourceJob.parent_job_id)
+ || null;
+ const reviewSourceJobId = reuseCurrentJob
+ ? inheritedSourceJobId
+ : Number(jobId);
+ let targetJobId = Number(jobId);
+ let replacedSourceJob = false;
+ let activeReviewRawPath = resolvedReviewRawPath;
+ let activeReviewInputPath = normalizedReviewInputPath || null;
- await historyService.updateJob(replacementJobId, {
- parent_job_id: Number(jobId),
- title: sourceJob.title || null,
- year: sourceJob.year ?? null,
- imdb_id: sourceJob.imdb_id || null,
- poster_url: sourceJob.poster_url || null,
- omdb_json: sourceJob.omdb_json || null,
- selected_from_omdb: Number(sourceJob.selected_from_omdb || 0),
- disc_device: sourceJob.disc_device || null,
- rip_successful: Number(sourceJob.rip_successful || 0),
- output_path: null,
- handbrake_info_json: null,
- mediainfo_info_json: null,
- encode_plan_json: null,
- encode_input_path: null,
- encode_review_confirmed: 0,
- ...jobUpdatePayload
- });
-
- // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist
- if (thumbnailService.isLocalUrl(sourceJob.poster_url)) {
- const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId);
- if (copiedUrl) {
- await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {});
+ if (reuseCurrentJob) {
+ await historyService.resetProcessLog(targetJobId);
+ await historyService.updateJob(targetJobId, {
+ ...jobUpdatePayload,
+ output_path: null,
+ handbrake_info_json: null,
+ mediainfo_info_json: null,
+ encode_plan_json: null,
+ encode_input_path: activeReviewInputPath,
+ encode_review_confirmed: 0
+ });
+ } else {
+ const replacementJob = await historyService.createJob({
+ discDevice: sourceJob.disc_device || null,
+ status: 'MEDIAINFO_CHECK',
+ detectedTitle: sourceJob.detected_title || sourceJob.title || null
+ });
+ const replacementJobId = Number(replacementJob?.id || 0);
+ if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
+ throw new Error('Review-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
}
+
+ const previousReviewRawPath = activeReviewRawPath;
+ activeReviewRawPath = await this.alignRawFolderJobId(activeReviewRawPath, replacementJobId);
+ if (
+ previousReviewRawPath
+ && activeReviewRawPath
+ && normalizeComparablePath(previousReviewRawPath) !== normalizeComparablePath(activeReviewRawPath)
+ ) {
+ activeReviewInputPath = remapPathToRetargetedRawRoot(
+ activeReviewInputPath,
+ previousReviewRawPath,
+ activeReviewRawPath
+ );
+ }
+
+ await historyService.updateJob(replacementJobId, {
+ parent_job_id: Number(jobId),
+ title: sourceJob.title || null,
+ year: sourceJob.year ?? null,
+ imdb_id: sourceJob.imdb_id || null,
+ poster_url: sourceJob.poster_url || null,
+ omdb_json: sourceJob.omdb_json || null,
+ selected_from_omdb: Number(sourceJob.selected_from_omdb || 0),
+ disc_device: sourceJob.disc_device || null,
+ rip_successful: Number(sourceJob.rip_successful || 0),
+ output_path: null,
+ handbrake_info_json: null,
+ mediainfo_info_json: null,
+ encode_plan_json: null,
+ encode_input_path: activeReviewInputPath,
+ encode_review_confirmed: 0,
+ ...jobUpdatePayload,
+ raw_path: activeReviewRawPath,
+ encode_input_path: activeReviewInputPath
+ });
+
+ // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist
+ if (thumbnailService.isLocalUrl(sourceJob.poster_url)) {
+ const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId);
+ if (copiedUrl) {
+ await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {});
+ }
+ }
+
+ targetJobId = replacementJobId;
+ replacedSourceJob = true;
}
if (removedQueueActionLabel) {
await historyService.appendLog(
- replacementJobId,
+ targetJobId,
'USER_ACTION',
`Queue-Eintrag entfernt (Review-Neustart): ${removedQueueActionLabel}`
);
}
if (shouldRealignEncodeInput) {
await historyService.appendLog(
- replacementJobId,
+ targetJobId,
'SYSTEM',
- `Review-Neustart: Encode-Input auf aktuellen RAW-Pfad abgeglichen: ${existingEncodeInputPath || '-'} -> ${normalizedReviewInputPath}`
+ `Review-Neustart: Encode-Input auf aktuellen RAW-Pfad abgeglichen: ${existingEncodeInputPath || '-'} -> ${activeReviewInputPath || '-'}`
);
}
await historyService.appendLog(
- replacementJobId,
+ targetJobId,
'USER_ACTION',
- `Review-Neustart aus RAW angefordert.${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''} MakeMKV Full-Analyse wird vollständig neu ausgeführt.`
+ `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.`
);
- await historyService.retireJobInFavorOf(jobId, replacementJobId, {
- reason: 'restart_review'
- });
+ if (!reuseCurrentJob) {
+ await historyService.retireJobInFavorOf(jobId, targetJobId, {
+ reason: 'restart_review'
+ });
+ }
await this.setState('MEDIAINFO_CHECK', {
- activeJobId: replacementJobId,
+ activeJobId: targetJobId,
progress: 0,
eta: null,
statusText: 'Titel-/Spurprüfung wird neu gestartet...',
context: {
...(this.snapshot.context || {}),
- jobId: replacementJobId,
+ jobId: targetJobId,
reviewConfirmed: false,
mediaInfoReview: null
}
});
- this.runReviewForRawJob(replacementJobId, resolvedReviewRawPath, {
+ this.runReviewForRawJob(targetJobId, activeReviewRawPath, {
mode: options?.mode || 'reencode',
- sourceJobId: Number(jobId),
+ sourceJobId: reviewSourceJobId,
forcePlaylistReselection,
forceFreshAnalyze: true,
previousEncodePlan
}).catch((error) => {
- logger.error('restartReviewFromRaw:background-failed', { jobId: replacementJobId, sourceJobId: jobId, error: errorToMeta(error) });
- this.failJob(replacementJobId, 'MEDIAINFO_CHECK', error).catch((failError) => {
+ logger.error('restartReviewFromRaw:background-failed', { jobId: targetJobId, sourceJobId: jobId, error: errorToMeta(error) });
+ this.failJob(targetJobId, 'MEDIAINFO_CHECK', error).catch((failError) => {
logger.error('restartReviewFromRaw:background-failJob-failed', {
- jobId: replacementJobId,
+ jobId: targetJobId,
error: errorToMeta(failError)
});
});
@@ -12678,8 +14057,8 @@ class PipelineService extends EventEmitter {
started: true,
stage: 'MEDIAINFO_CHECK',
sourceJobId: Number(jobId),
- jobId: replacementJobId,
- replacedSourceJob: true
+ jobId: targetJobId,
+ replacedSourceJob
};
}
@@ -14452,6 +15831,13 @@ class PipelineService extends EventEmitter {
error_message: null
});
+ // Datei-Zuweisungen im Explorer freigeben
+ try {
+ await require('./converterScanService').clearAssignmentsForJob(jobId);
+ } catch (_clearErr) {
+ // nicht kritisch
+ }
+
await historyService.appendLog(
jobId,
'SYSTEM',
@@ -14495,6 +15881,12 @@ class PipelineService extends EventEmitter {
} catch (error) {
this.activeProcesses.delete(Number(jobId));
logger.error('converter:encode:failed', { jobId, error: errorToMeta(error) });
+ // Datei-Zuweisungen im Explorer freigeben (auch bei Abbruch/Fehler)
+ try {
+ await require('./converterScanService').clearAssignmentsForJob(jobId);
+ } catch (_clearErr) {
+ // nicht kritisch
+ }
await this.failJob(jobId, 'ENCODING', error);
}
})();
@@ -14949,15 +16341,12 @@ class PipelineService extends EventEmitter {
}
async startCdRip(jobId, ripConfig) {
+ const resolvedCdRipJob = await this.resolveExistingJobForAction(jobId, 'start_cd_rip');
+ jobId = resolvedCdRipJob.resolvedJobId;
this.ensureNotBusy('startCdRip', jobId);
this.cancelRequestedByJob.delete(Number(jobId));
- const sourceJob = await historyService.getJobById(jobId);
- if (!sourceJob) {
- const error = new Error(`Job ${jobId} nicht gefunden.`);
- error.statusCode = 404;
- throw error;
- }
+ const sourceJob = resolvedCdRipJob.job || await historyService.getJobById(jobId);
let activeJobId = Number(jobId);
let activeJob = sourceJob;
@@ -14982,6 +16371,11 @@ class PipelineService extends EventEmitter {
throw new Error('CD-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
}
+ let replacementRawPath = sourceHasSuccessfulRawRip ? sourceResolvedRawPath : null;
+ if (replacementRawPath) {
+ replacementRawPath = await this.alignRawFolderJobId(replacementRawPath, replacementJobId);
+ }
+
await historyService.updateJob(replacementJobId, {
parent_job_id: Number(jobId),
title: sourceJob.title || null,
@@ -14996,7 +16390,7 @@ class PipelineService extends EventEmitter {
end_time: null,
output_path: null,
disc_device: sourceJob.disc_device || null,
- raw_path: sourceHasSuccessfulRawRip ? sourceResolvedRawPath : null,
+ raw_path: replacementRawPath,
rip_successful: sourceHasSuccessfulRawRip ? 1 : 0,
makemkv_info_json: sourceJob.makemkv_info_json || null,
handbrake_info_json: null,
@@ -15986,9 +17380,29 @@ class PipelineService extends EventEmitter {
const baseName = path.basename(relPath, path.extname(relPath));
const detectedTitle = baseName || 'Converter Job';
- const converterMediaType = options.converterMediaType
+ let converterMediaType = options.converterMediaType
|| converterScanService.detectMediaType(path.basename(relPath));
+ // Für Ordner ohne erkannte Dateiendung: Medientyp anhand der enthaltenen Dateien bestimmen
+ if (isDirectory && !converterMediaType) {
+ try {
+ const dirFiles = fs.readdirSync(fullPath, { withFileTypes: true });
+ let audioCount = 0;
+ let videoCount = 0;
+ for (const f of dirFiles) {
+ if (!f.isFile()) continue;
+ const mt = converterScanService.detectMediaType(f.name);
+ if (mt === 'audio') audioCount++;
+ else if (mt === 'video' || mt === 'iso') videoCount++;
+ }
+ // Mehrheit gewinnt; bei Gleichstand gilt audio (häufigster Fall: Musikalbum)
+ if (audioCount > 0 && audioCount >= videoCount) converterMediaType = 'audio';
+ else if (videoCount > 0) converterMediaType = 'video';
+ } catch (_err) {
+ // Ordner nicht lesbar — bleibt null
+ }
+ }
+
const job = await historyService.createJob({
discDevice: null,
status: 'READY_TO_START',
@@ -16059,8 +17473,8 @@ class PipelineService extends EventEmitter {
if (folderName) {
// ── Ordner-Upload (wie Klangkiste target_path): alle Dateien in EINEN Ordner ──
const safeFolderName = sanitizeFileName(folderName) || `upload_${Date.now()}`;
- const uniqueFolderName = `${safeFolderName}_${Date.now()}`;
- const targetDir = path.join(rawDir, uniqueFolderName);
+ const targetDir = ensureUniqueOutputPath(path.join(rawDir, safeFolderName));
+ const uniqueFolderName = path.basename(targetDir);
ensureDir(targetDir);
for (const file of normalizedFiles) {
@@ -16102,8 +17516,8 @@ class PipelineService extends EventEmitter {
const baseNameClean = sanitizeFileName(
path.basename(originalName, path.extname(originalName))
) || `upload_${Date.now()}`;
- const subfolder = `${baseNameClean}_${Date.now()}`;
- const targetDir = path.join(rawDir, subfolder);
+ const targetDir = ensureUniqueOutputPath(path.join(rawDir, baseNameClean));
+ const subfolder = path.basename(targetDir);
ensureDir(targetDir);
const safeFileName = sanitizeFileNameWithExtension(originalName) || originalName;
@@ -16120,7 +17534,8 @@ class PipelineService extends EventEmitter {
/**
* Erstellt Converter-Jobs aus ausgewählten Dateipfaden (relativ zu converter_raw_dir).
- * Audio-Dateien können als einzelne Jobs oder als ein gemeinsamer Job angelegt werden.
+ * Video-Dateien bekommen je einen eigenen Job, Audio-Dateien je nach audioMode.
+ * Ordner werden vom Frontend bereits zu Einzel-Dateien expandiert.
*
* @param {string[]} relPaths - Dateipfade relativ zum rawDir
* @param {'individual'|'shared'} audioMode - Modus für Audio-Dateien
@@ -16138,7 +17553,7 @@ class PipelineService extends EventEmitter {
}
const audioRelPaths = [];
- const nonAudioRelPaths = [];
+ const nonAudioRelPaths = []; // Videos, ISOs, Ordner, sonstige
for (const relPath of relPaths) {
const ext = path.extname(String(relPath || '')).toLowerCase();
@@ -16151,12 +17566,13 @@ class PipelineService extends EventEmitter {
const createdJobs = [];
- // Nicht-Audio (Video, ISO, Ordner): immer ein Job pro Eintrag
+ // Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag
for (const relPath of nonAudioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, {});
createdJobs.push(job);
}
+ // Audio-Dateien
if (audioMode === 'shared' && audioRelPaths.length > 0) {
// Ein gemeinsamer Job für alle Audio-Dateien
const fullPaths = audioRelPaths.map((p) => path.join(rawDir, p));
@@ -16183,6 +17599,8 @@ class PipelineService extends EventEmitter {
last_state: 'READY_TO_START'
});
+ await converterScanService.assignEntriesToJob(audioRelPaths, job.id);
+
await historyService.appendLog(
job.id, 'SYSTEM',
`Converter-Job (gemeinsam) erstellt: ${audioRelPaths.length} Audio-Datei(en)`
@@ -16204,6 +17622,619 @@ class PipelineService extends EventEmitter {
return createdJobs;
}
+ /**
+ * Fügt Dateien einem existierenden (noch nicht gestarteten) Converter-Job hinzu.
+ * Aktuell werden Mehrfach-Dateien nur für Audio-Jobs unterstützt.
+ *
+ * @param {number} jobId
+ * @param {string[]} relPaths
+ * @returns {Promise<{job: object, addedRelPaths: string[]}>}
+ */
+ async assignConverterFilesToJob(jobId, relPaths = []) {
+ const normalizedJobId = Number(jobId);
+ if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
+ const error = new Error('Ungültige Job-ID.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const converterScanService = require('./converterScanService');
+ const rawDir = await converterScanService.getRawDir();
+ if (!rawDir) {
+ const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const requestedRelPaths = Array.isArray(relPaths)
+ ? relPaths
+ .map((relPath) => converterScanService.normalizeRelPath(relPath))
+ .filter((relPath) => relPath !== null && relPath !== '')
+ : [];
+ if (requestedRelPaths.length === 0) {
+ const error = new Error('Keine Dateien für Zuweisung übergeben.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const job = await historyService.getJobById(normalizedJobId);
+ if (!job) {
+ const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
+ error.statusCode = 404;
+ throw error;
+ }
+ if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
+ const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
+ error.statusCode = 409;
+ throw error;
+ }
+ if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') {
+ const error = new Error('Dateien können nur nicht gestarteten Converter-Jobs zugewiesen werden.');
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
+ const converterMediaType = String(existingPlan.converterMediaType || '').trim().toLowerCase();
+ if (converterMediaType !== 'audio') {
+ const error = new Error('Weitere Dateien können derzeit nur Audio-Converter-Jobs zugewiesen werden.');
+ error.statusCode = 409;
+ throw error;
+ }
+ if (Boolean(existingPlan.isFolder)) {
+ const error = new Error('Ordnerbasierte Audio-Jobs können nicht erweitert werden. Bitte neuen gemeinsamen Audio-Job nutzen.');
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const existingInputPaths = [];
+ if (Array.isArray(existingPlan.inputPaths) && existingPlan.inputPaths.length > 0) {
+ for (const item of existingPlan.inputPaths) {
+ const normalized = String(item || '').trim();
+ if (normalized) existingInputPaths.push(normalized);
+ }
+ } else {
+ const singleInput = String(existingPlan.inputPath || job.raw_path || '').trim();
+ if (singleInput && fs.existsSync(singleInput)) {
+ try {
+ const stat = fs.statSync(singleInput);
+ if (stat.isFile()) {
+ existingInputPaths.push(singleInput);
+ }
+ } catch (_err) {
+ // Ignored: validation below catches empty/invalid setups.
+ }
+ }
+ }
+
+ if (existingInputPaths.length === 0) {
+ const error = new Error(`Job ${normalizedJobId} enthält keine gültige Eingabedatei.`);
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const nextInputPaths = [...existingInputPaths];
+ const knownPaths = new Set(
+ existingInputPaths
+ .map((filePath) => normalizeComparablePath(filePath))
+ .filter(Boolean)
+ );
+ const addedRelPaths = [];
+
+ for (const relPath of requestedRelPaths) {
+ const absolutePath = path.join(rawDir, relPath);
+ if (!fs.existsSync(absolutePath)) {
+ const error = new Error(`Datei nicht gefunden: ${relPath}`);
+ error.statusCode = 404;
+ throw error;
+ }
+ let stat;
+ try {
+ stat = fs.statSync(absolutePath);
+ } catch (_err) {
+ const error = new Error(`Datei nicht lesbar: ${relPath}`);
+ error.statusCode = 409;
+ throw error;
+ }
+ if (!stat.isFile()) {
+ const error = new Error(`Nur Dateien können zugewiesen werden: ${relPath}`);
+ error.statusCode = 409;
+ throw error;
+ }
+ const mediaType = converterScanService.detectMediaType(path.basename(relPath));
+ if (mediaType !== 'audio') {
+ const error = new Error(`Nur Audio-Dateien können diesem Job zugewiesen werden: ${relPath}`);
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const entry = await converterScanService.getEntryByRelPath(relPath);
+ const assignedJobId = Number(entry?.job_id);
+ if (Number.isFinite(assignedJobId) && assignedJobId > 0 && assignedJobId !== normalizedJobId) {
+ const error = new Error(`Datei ist bereits Job #${assignedJobId} zugewiesen: ${relPath}`);
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const comparablePath = normalizeComparablePath(absolutePath);
+ if (comparablePath && knownPaths.has(comparablePath)) {
+ continue;
+ }
+ nextInputPaths.push(absolutePath);
+ if (comparablePath) knownPaths.add(comparablePath);
+ addedRelPaths.push(relPath);
+ }
+
+ if (addedRelPaths.length === 0) {
+ return {
+ job: await historyService.getJobById(normalizedJobId),
+ addedRelPaths: []
+ };
+ }
+
+ const isSharedAudio = nextInputPaths.length > 1;
+ const primaryInputPath = isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0];
+ const nextPlan = {
+ ...existingPlan,
+ mediaProfile: 'converter',
+ converterMediaType: 'audio',
+ isFolder: false,
+ isSharedAudio,
+ inputPath: primaryInputPath,
+ inputPaths: isSharedAudio ? nextInputPaths : null,
+ audioFiles: null,
+ reviewConfirmed: false
+ };
+
+ await historyService.updateJob(normalizedJobId, {
+ status: 'READY_TO_START',
+ last_state: 'READY_TO_START',
+ media_type: 'converter',
+ raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
+ encode_plan_json: JSON.stringify(nextPlan),
+ encode_review_confirmed: 0,
+ error_message: null,
+ end_time: null
+ });
+
+ await converterScanService.assignEntriesToJob(addedRelPaths, normalizedJobId);
+
+ await historyService.appendLog(
+ normalizedJobId,
+ 'USER_ACTION',
+ `Dateien dem Converter-Job zugewiesen: ${addedRelPaths.join(', ')}`
+ );
+
+ return {
+ job: await historyService.getJobById(normalizedJobId),
+ addedRelPaths
+ };
+ }
+
+ /**
+ * Entfernt eine Datei aus einem Converter-Job.
+ *
+ * @param {number} jobId
+ * @param {string} relPath
+ * @returns {Promise<{job: object, removedRelPath: string}>}
+ */
+ async removeConverterFileFromJob(jobId, relPath) {
+ const normalizedJobId = Number(jobId);
+ if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
+ const error = new Error('Ungültige Job-ID.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const converterScanService = require('./converterScanService');
+ const normalizedRelPath = converterScanService.normalizeRelPath(relPath);
+ if (normalizedRelPath === null || normalizedRelPath === '') {
+ const error = new Error('Ungültiger relPath.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const rawDir = await converterScanService.getRawDir();
+ if (!rawDir) {
+ const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const job = await historyService.getJobById(normalizedJobId);
+ if (!job) {
+ const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
+ error.statusCode = 404;
+ throw error;
+ }
+ if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
+ const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
+ error.statusCode = 409;
+ throw error;
+ }
+ if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') {
+ const error = new Error('Dateien können nur aus nicht gestarteten Converter-Jobs entfernt werden.');
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
+ const converterMediaType = String(existingPlan.converterMediaType || '').trim().toLowerCase();
+ if (converterMediaType !== 'audio' || Boolean(existingPlan.isFolder)) {
+ const error = new Error('Dateien können derzeit nur aus gemeinsamem Audio-Converter-Job entfernt werden.');
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const allInputPaths = [];
+ if (Array.isArray(existingPlan.inputPaths) && existingPlan.inputPaths.length > 0) {
+ for (const item of existingPlan.inputPaths) {
+ const normalized = String(item || '').trim();
+ if (normalized) allInputPaths.push(normalized);
+ }
+ } else {
+ const singleInput = String(existingPlan.inputPath || job.raw_path || '').trim();
+ if (singleInput && fs.existsSync(singleInput)) {
+ try {
+ const stat = fs.statSync(singleInput);
+ if (stat.isFile()) {
+ allInputPaths.push(singleInput);
+ }
+ } catch (_err) {
+ // Keep empty fallback and validate below.
+ }
+ }
+ }
+
+ if (allInputPaths.length === 0) {
+ const error = new Error(`Job ${normalizedJobId} enthält keine gültigen Eingabedateien.`);
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const targetPath = path.join(rawDir, normalizedRelPath);
+ const targetComparable = normalizeComparablePath(targetPath);
+ const hasTarget = allInputPaths.some(
+ (filePath) => normalizeComparablePath(filePath) === targetComparable
+ );
+ if (!hasTarget) {
+ const error = new Error(`Datei ist diesem Job nicht zugewiesen: ${normalizedRelPath}`);
+ error.statusCode = 409;
+ throw error;
+ }
+
+ if (allInputPaths.length <= 1) {
+ const error = new Error('Die letzte Datei kann nicht entfernt werden. Bitte Job löschen.');
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const nextInputPaths = allInputPaths.filter(
+ (filePath) => normalizeComparablePath(filePath) !== targetComparable
+ );
+ const isSharedAudio = nextInputPaths.length > 1;
+ const primaryInputPath = isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0];
+ const nextPlan = {
+ ...existingPlan,
+ mediaProfile: 'converter',
+ converterMediaType: 'audio',
+ isFolder: false,
+ isSharedAudio,
+ inputPath: primaryInputPath,
+ inputPaths: isSharedAudio ? nextInputPaths : null,
+ audioFiles: null,
+ reviewConfirmed: false
+ };
+
+ await historyService.updateJob(normalizedJobId, {
+ status: 'READY_TO_START',
+ last_state: 'READY_TO_START',
+ media_type: 'converter',
+ raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
+ encode_plan_json: JSON.stringify(nextPlan),
+ encode_review_confirmed: 0,
+ error_message: null,
+ end_time: null
+ });
+
+ await converterScanService.clearEntryJobAssignment(normalizedRelPath, normalizedJobId);
+
+ await historyService.appendLog(
+ normalizedJobId,
+ 'USER_ACTION',
+ `Datei aus Converter-Job entfernt: ${normalizedRelPath}`
+ );
+
+ return {
+ job: await historyService.getJobById(normalizedJobId),
+ removedRelPath: normalizedRelPath
+ };
+ }
+
+ /**
+ * Entfernt eine Datei aus einem Converter-Job via absolutem Input-Pfad.
+ * Nützlich für Track-Tabellen, die mit inputPaths (absolut) arbeiten.
+ *
+ * @param {number} jobId
+ * @param {string} inputPath
+ * @returns {Promise<{job: object, removedRelPath: string|null}>}
+ */
+ async removeConverterInputFromJob(jobId, inputPath) {
+ const normalizedJobId = Number(jobId);
+ const normalizedInputPath = String(inputPath || '').trim();
+ if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
+ const error = new Error('Ungültige Job-ID.');
+ error.statusCode = 400;
+ throw error;
+ }
+ if (!normalizedInputPath) {
+ const error = new Error('inputPath fehlt.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const converterScanService = require('./converterScanService');
+ const rawDir = await converterScanService.getRawDir();
+ if (rawDir) {
+ const absoluteRawDir = normalizeComparablePath(rawDir);
+ const absoluteInputPath = normalizeComparablePath(normalizedInputPath);
+ if (absoluteRawDir && absoluteInputPath && absoluteInputPath.startsWith(`${absoluteRawDir}${path.sep}`)) {
+ const relCandidate = path.relative(rawDir, normalizedInputPath);
+ const normalizedRelPath = converterScanService.normalizeRelPath(relCandidate);
+ if (normalizedRelPath && normalizedRelPath !== '.') {
+ return this.removeConverterFileFromJob(normalizedJobId, normalizedRelPath);
+ }
+ }
+ }
+
+ // Fallback: direkte Plan-Manipulation (wenn kein valider relPath ableitbar ist)
+ const job = await historyService.getJobById(normalizedJobId);
+ if (!job) {
+ const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
+ error.statusCode = 404;
+ throw error;
+ }
+ const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
+ const allInputPaths = Array.isArray(existingPlan.inputPaths)
+ ? existingPlan.inputPaths.map((value) => String(value || '').trim()).filter(Boolean)
+ : [String(existingPlan.inputPath || job.raw_path || '').trim()].filter(Boolean);
+ if (allInputPaths.length <= 1) {
+ const error = new Error('Die letzte Datei kann nicht entfernt werden. Bitte Job löschen.');
+ error.statusCode = 409;
+ throw error;
+ }
+ const targetComparable = normalizeComparablePath(normalizedInputPath);
+ const nextInputPaths = allInputPaths.filter((value) => normalizeComparablePath(value) !== targetComparable);
+ if (nextInputPaths.length === allInputPaths.length) {
+ const error = new Error('Datei ist diesem Job nicht zugewiesen.');
+ error.statusCode = 409;
+ throw error;
+ }
+ const isSharedAudio = nextInputPaths.length > 1;
+ const nextPlan = {
+ ...existingPlan,
+ mediaProfile: 'converter',
+ converterMediaType: 'audio',
+ isFolder: false,
+ isSharedAudio,
+ inputPath: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
+ inputPaths: isSharedAudio ? nextInputPaths : null,
+ audioFiles: null,
+ reviewConfirmed: false
+ };
+
+ await historyService.updateJob(normalizedJobId, {
+ status: 'READY_TO_START',
+ last_state: 'READY_TO_START',
+ media_type: 'converter',
+ raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
+ encode_plan_json: JSON.stringify(nextPlan),
+ encode_review_confirmed: 0,
+ error_message: null,
+ end_time: null
+ });
+
+ await historyService.appendLog(
+ normalizedJobId,
+ 'USER_ACTION',
+ `Datei aus Converter-Job entfernt (inputPath): ${normalizedInputPath}`
+ );
+
+ return {
+ job: await historyService.getJobById(normalizedJobId),
+ removedRelPath: null
+ };
+ }
+
+ /**
+ * Speichert Converter-Draft-Konfiguration persistent für READY_TO_START Jobs.
+ * Damit bleiben UI-Eingaben (Metadaten, Tracktitel, Presets etc.) bei Reload erhalten.
+ *
+ * @param {number} jobId
+ * @param {object} config
+ * @returns {Promise<{job: object}>}
+ */
+ async updateConverterJobConfig(jobId, config = {}) {
+ const normalizedJobId = Number(jobId);
+ if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
+ const error = new Error('Ungültige Job-ID.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const job = await historyService.getJobById(normalizedJobId);
+ if (!job) {
+ const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
+ error.statusCode = 404;
+ throw error;
+ }
+ if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
+ const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
+ 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.');
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
+ const normalizeText = (value, maxLen = 300) => {
+ const normalized = String(value || '').trim();
+ if (!normalized) return null;
+ return normalized.slice(0, maxLen);
+ };
+ const normalizeNullableNumber = (value) => {
+ const n = Number(value);
+ return Number.isFinite(n) ? Math.trunc(n) : null;
+ };
+
+ const requestedMediaType = String(
+ config.converterMediaType || existingPlan.converterMediaType || 'video'
+ ).trim().toLowerCase();
+ const converterMediaType = ['video', 'audio', 'iso'].includes(requestedMediaType)
+ ? requestedMediaType
+ : 'video';
+
+ const defaultFormat = converterMediaType === 'audio' ? 'flac' : 'mkv';
+ const outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20)
+ ?.toLowerCase() || defaultFormat;
+
+ let userPreset = existingPlan.userPreset || null;
+ if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) {
+ if (config.userPreset && typeof config.userPreset === 'object') {
+ const presetId = Number(config.userPreset.id);
+ userPreset = {
+ ...(Number.isFinite(presetId) && presetId > 0 ? { id: Math.trunc(presetId) } : {}),
+ ...(normalizeText(config.userPreset.handbrakePreset, 200)
+ ? { handbrakePreset: normalizeText(config.userPreset.handbrakePreset, 200) }
+ : {}),
+ ...(normalizeText(config.userPreset.extraArgs, 1000)
+ ? { extraArgs: normalizeText(config.userPreset.extraArgs, 1000) }
+ : {})
+ };
+ if (Object.keys(userPreset).length === 0) {
+ userPreset = null;
+ }
+ } else {
+ userPreset = null;
+ }
+ }
+
+ const nextAudioFormatOptions = (
+ Object.prototype.hasOwnProperty.call(config, 'audioFormatOptions')
+ && config.audioFormatOptions
+ && typeof config.audioFormatOptions === 'object'
+ )
+ ? { ...config.audioFormatOptions }
+ : (existingPlan.audioFormatOptions || {});
+
+ const nextMetadata = (
+ Object.prototype.hasOwnProperty.call(config, 'metadata')
+ && config.metadata
+ && typeof config.metadata === 'object'
+ )
+ ? {
+ albumTitle: normalizeText(config.metadata.albumTitle, 300),
+ albumArtist: normalizeText(config.metadata.albumArtist, 300),
+ albumYear: normalizeNullableNumber(config.metadata.albumYear),
+ mbId: normalizeText(config.metadata.mbId, 120),
+ coverUrl: normalizeText(config.metadata.coverUrl, 500) || null
+ }
+ : (existingPlan.metadata || null);
+
+ const normalizeTrack = (track) => {
+ const position = Number(track?.position);
+ if (!Number.isFinite(position) || position <= 0) return null;
+ return {
+ position: Math.trunc(position),
+ title: normalizeText(track?.title, 300) || null,
+ artist: normalizeText(track?.artist, 300) || null
+ };
+ };
+ const nextTracks = Array.isArray(config.tracks)
+ ? config.tracks.map(normalizeTrack).filter(Boolean)
+ : (Array.isArray(existingPlan.tracks) ? existingPlan.tracks : null);
+
+ const sanitizeMbEntry = (entry) => {
+ if (!entry || typeof entry !== 'object') return null;
+ return {
+ mbId: normalizeText(entry.mbId, 120),
+ title: normalizeText(entry.title, 300),
+ artist: normalizeText(entry.artist, 300),
+ year: normalizeNullableNumber(entry.year),
+ country: normalizeText(entry.country, 16)
+ };
+ };
+ const nextMusicBrainz = (
+ Object.prototype.hasOwnProperty.call(config, 'musicBrainz')
+ && config.musicBrainz
+ && typeof config.musicBrainz === 'object'
+ )
+ ? {
+ query: normalizeText(config.musicBrainz.query, 400),
+ selected: sanitizeMbEntry(config.musicBrainz.selected),
+ results: Array.isArray(config.musicBrainz.results)
+ ? config.musicBrainz.results.map(sanitizeMbEntry).filter(Boolean).slice(0, 50)
+ : []
+ }
+ : (existingPlan.musicBrainz || null);
+
+ const normalizeEncodeItem = (item) => {
+ if (!item || typeof item !== 'object') return null;
+ const type = String(item.type || '').trim().toLowerCase();
+ if (type !== 'script' && type !== 'chain') return null;
+ const id = Number(item.id);
+ if (!Number.isFinite(id) || id <= 0) return null;
+ return { type, id: Math.trunc(id) };
+ };
+ const nextPreEncodeItems = Array.isArray(config.preEncodeItems)
+ ? config.preEncodeItems.map(normalizeEncodeItem).filter(Boolean)
+ : (Array.isArray(existingPlan.preEncodeItems) ? existingPlan.preEncodeItems : []);
+ const nextPostEncodeItems = Array.isArray(config.postEncodeItems)
+ ? config.postEncodeItems.map(normalizeEncodeItem).filter(Boolean)
+ : (Array.isArray(existingPlan.postEncodeItems) ? existingPlan.postEncodeItems : []);
+
+ const nextPlan = {
+ ...existingPlan,
+ mediaProfile: 'converter',
+ converterMediaType,
+ outputFormat,
+ userPreset,
+ audioFormatOptions: nextAudioFormatOptions,
+ metadata: nextMetadata,
+ tracks: nextTracks,
+ musicBrainz: nextMusicBrainz,
+ preEncodeItems: nextPreEncodeItems,
+ postEncodeItems: nextPostEncodeItems,
+ reviewConfirmed: false
+ };
+
+ const newCoverUrl = nextMetadata?.coverUrl || null;
+ const jobPatch = {
+ encode_plan_json: JSON.stringify(nextPlan),
+ encode_review_confirmed: 0,
+ error_message: null
+ };
+ if (newCoverUrl && !thumbnailService.isLocalUrl(newCoverUrl)) {
+ jobPatch.poster_url = newCoverUrl;
+ }
+
+ await historyService.updateJob(normalizedJobId, jobPatch);
+
+ if (newCoverUrl && !thumbnailService.isLocalUrl(newCoverUrl)) {
+ historyService.queuePosterCache(normalizedJobId, newCoverUrl, {
+ source: 'Coverart',
+ logFailures: true
+ });
+ }
+
+ return {
+ job: await historyService.getJobById(normalizedJobId)
+ };
+ }
+
/**
* Startet einen Converter-Job mit der finalen Konfiguration (Preset, Format etc.).
*
@@ -16237,6 +18268,9 @@ class PipelineService extends EventEmitter {
|| existingPlan.converterMediaType
|| 'video';
+ const metadata = config.metadata || existingPlan.metadata || null;
+ const tracks = config.tracks || existingPlan.tracks || null;
+
const converterScanService = require('./converterScanService');
const settings = await settingsService.getSettingsMap();
const rawDir = String(
@@ -16259,6 +18293,7 @@ class PipelineService extends EventEmitter {
// Ausgabepfad bestimmen
let outputPath = null;
let outputDir = null;
+ let audioTrackTemplate = null;
const outputFormat = String(config.outputFormat || 'mkv').trim().toLowerCase();
const baseName = sanitizeFileName(
path.basename(inputPath, path.extname(inputPath))
@@ -16266,23 +18301,107 @@ class PipelineService extends EventEmitter {
const title = job.title || job.detected_title || baseName;
const safeTitle = sanitizeFileName(title) || baseName;
+ // Metadaten-basierte Namen (Album-Titel und Interpret)
+ const albumTitle = metadata?.albumTitle ? String(metadata.albumTitle).trim() : null;
+ const albumArtist = metadata?.albumArtist ? String(metadata.albumArtist).trim() : null;
+ const albumYear = Number.isFinite(Number(metadata?.albumYear))
+ ? Math.trunc(Number(metadata.albumYear))
+ : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null);
+ const safeAlbumTitle = albumTitle ? (sanitizeFileName(albumTitle) || safeTitle) : safeTitle;
+ const safeAlbumArtist = albumArtist ? (sanitizeFileName(albumArtist) || null) : null;
+
+ const parseTemplateSegments = (template) => String(template || '')
+ .replace(/\\/g, '/')
+ .replace(/\/+/g, '/')
+ .replace(/^\/+|\/+$/g, '')
+ .split('/')
+ .map((segment) => String(segment || '').trim())
+ .filter(Boolean);
+
+ const normalizeAudioTemplate = (template) => {
+ let value = String(template || '').trim();
+ if (!value) {
+ return '{artist} - {title}';
+ }
+
+ // Legacy-Recovery: alte fehlerhafte Werte konnten Placeholder-Klammern verlieren.
+ // Auch gemischte Templates (teilweise mit {}, teilweise ohne) werden repariert.
+ value = value.replace(
+ /(^|[^A-Za-z0-9_$\{])(trackNr|trackNumber|artist|album|title|year)(?=$|[^A-Za-z0-9_\}])/g,
+ (_full, prefix, key) => `${prefix}{${key}}`
+ );
+
+ // Falls ein kombiniertes Album+Track-Template ohne "/" gespeichert wurde,
+ // trennen wir vor der Tracknummer automatisch.
+ if (
+ !value.includes('/')
+ && /\{(?:trackNr|trackNumber)\}/i.test(value)
+ && /\{(?:album|year)\}/i.test(value)
+ ) {
+ value = value.replace(/\)\s*(\{(?:trackNr|trackNumber)\})/i, ')/$1');
+ }
+
+ return value;
+ };
+
+ const renderTemplateSegment = (segment, values, fallback = 'unknown') => {
+ const rendered = renderTemplate(segment, values);
+ return sanitizeFileName(rendered) || fallback;
+ };
+
+ const albumTemplateValues = {
+ artist: safeAlbumArtist || safeAlbumTitle,
+ album: safeAlbumTitle,
+ title: safeAlbumTitle,
+ year: albumYear ?? new Date().getFullYear(),
+ trackNr: '01',
+ trackNumber: '1'
+ };
+
if (converterMediaType === 'video' || converterMediaType === 'iso') {
- const videoTemplate = String(
+ const videoTemplateRaw = String(
settings?.converter_output_template_video || '{title}'
- ).replace('{title}', safeTitle);
- outputPath = path.join(movieDir, `${videoTemplate}.${outputFormat}`);
+ ).trim() || '{title}';
+ const videoSegmentsRaw = parseTemplateSegments(videoTemplateRaw);
+ const videoSegments = (videoSegmentsRaw.length > 0 ? videoSegmentsRaw : ['{title}'])
+ .map((segment) => renderTemplateSegment(segment, {
+ title: safeTitle,
+ year: Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : new Date().getFullYear()
+ }, safeTitle));
+ const videoBaseName = videoSegments[videoSegments.length - 1] || safeTitle;
+ const videoFolders = videoSegments.slice(0, -1);
+ outputPath = path.join(movieDir, ...(videoFolders.length > 0 ? videoFolders : []), `${videoBaseName}.${outputFormat}`);
} else if (converterMediaType === 'audio') {
const isFolder = Boolean(existingPlan.isFolder || config.isFolder);
- if (isFolder) {
- const audioTemplate = String(
- settings?.converter_output_template_audio || '{artist} - {title}'
- ).replace('{title}', safeTitle).replace('{artist}', safeTitle);
- outputDir = path.join(audioDir, sanitizeFileName(audioTemplate) || safeTitle);
+ const isSharedAudio = Boolean(existingPlan.isSharedAudio);
+ const audioTemplateRaw = normalizeAudioTemplate(String(
+ settings?.converter_output_template_audio || '{artist} - {title}'
+ ).trim() || '{artist} - {title}');
+ const audioSegmentsRaw = parseTemplateSegments(audioTemplateRaw);
+ if (isFolder || isSharedAudio) {
+ const folderSegmentRaw = audioSegmentsRaw.length > 1
+ ? audioSegmentsRaw.slice(0, -1)
+ : audioSegmentsRaw;
+ const folderSegments = (folderSegmentRaw.length > 0 ? folderSegmentRaw : ['{artist} - {album}'])
+ .map((segment) => renderTemplateSegment(segment, albumTemplateValues, safeAlbumTitle));
+ outputDir = path.join(audioDir, ...folderSegments);
+ audioTrackTemplate = audioSegmentsRaw.length > 1
+ ? audioSegmentsRaw[audioSegmentsRaw.length - 1]
+ : (existingPlan.audioTrackTemplate || '{trackNr} - {title}');
} else {
- const audioTemplate = String(
- settings?.converter_output_template_audio || '{artist} - {title}'
- ).replace('{title}', safeTitle).replace('{artist}', safeTitle);
- outputPath = path.join(audioDir, `${sanitizeFileName(audioTemplate) || safeTitle}.${outputFormat}`);
+ const firstTrack = Array.isArray(tracks) && tracks.length > 0 ? tracks[0] : null;
+ const singleValues = {
+ ...albumTemplateValues,
+ title: sanitizeFileName(String(firstTrack?.title || safeAlbumTitle).trim()) || safeAlbumTitle,
+ artist: sanitizeFileName(String(firstTrack?.artist || safeAlbumArtist || safeAlbumTitle).trim()) || (safeAlbumArtist || safeAlbumTitle),
+ trackNr: '01',
+ trackNumber: '1'
+ };
+ const renderedSegments = (audioSegmentsRaw.length > 0 ? audioSegmentsRaw : ['{artist} - {title}'])
+ .map((segment) => renderTemplateSegment(segment, singleValues, safeAlbumTitle));
+ const singleBaseName = renderedSegments[renderedSegments.length - 1] || safeAlbumTitle;
+ const singleFolders = renderedSegments.slice(0, -1);
+ outputPath = path.join(audioDir, ...(singleFolders.length > 0 ? singleFolders : []), `${singleBaseName}.${outputFormat}`);
}
}
@@ -16300,11 +18419,31 @@ class PipelineService extends EventEmitter {
}
}
+ const normalizeStartEncodeItem = (item) => {
+ if (!item || typeof item !== 'object') return null;
+ const type = String(item.type || '').trim().toLowerCase();
+ if (type !== 'script' && type !== 'chain') return null;
+ const id = Number(item.id);
+ if (!Number.isFinite(id) || id <= 0) return null;
+ return { type, id: Math.trunc(id) };
+ };
+ const preEncodeItems = Array.isArray(config.preEncodeItems)
+ ? config.preEncodeItems.map(normalizeStartEncodeItem).filter(Boolean)
+ : (Array.isArray(existingPlan.preEncodeItems) ? existingPlan.preEncodeItems : []);
+ const postEncodeItems = Array.isArray(config.postEncodeItems)
+ ? config.postEncodeItems.map(normalizeStartEncodeItem).filter(Boolean)
+ : (Array.isArray(existingPlan.postEncodeItems) ? existingPlan.postEncodeItems : []);
+ const preEncodeScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
+ const postEncodeScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
+ const preEncodeChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
+ const postEncodeChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
+
const nextEncodePlan = {
...existingPlan,
mediaProfile: 'converter',
converterMediaType,
inputPath,
+ inputPaths: existingPlan.inputPaths || null,
outputPath: outputPath || null,
outputDir: outputDir || null,
outputFormat,
@@ -16312,7 +18451,16 @@ class PipelineService extends EventEmitter {
trackSelection: config.trackSelection || existingPlan.trackSelection || null,
handBrakeTitleId: config.handBrakeTitleId || existingPlan.handBrakeTitleId || null,
audioFormatOptions: config.audioFormatOptions || existingPlan.audioFormatOptions || {},
+ audioTrackTemplate: audioTrackTemplate || existingPlan.audioTrackTemplate || null,
audioFiles: audioFiles || existingPlan.audioFiles || null,
+ metadata: metadata || null,
+ tracks: tracks || null,
+ preEncodeItems,
+ postEncodeItems,
+ preEncodeScriptIds,
+ postEncodeScriptIds,
+ preEncodeChainIds,
+ postEncodeChainIds,
reviewConfirmed: true
};
diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js
index 4ebdccc..6e065e1 100644
--- a/backend/src/services/settingsService.js
+++ b/backend/src/services/settingsService.js
@@ -156,6 +156,40 @@ function normalizeTrackIds(rawList) {
return output;
}
+function normalizeTrackIdSequence(rawList, options = {}) {
+ const list = Array.isArray(rawList) ? rawList : [];
+ const dedupe = options?.dedupe !== false;
+ const seen = new Set();
+ const output = [];
+ for (const item of list) {
+ const value = Number(item);
+ if (!Number.isFinite(value) || value <= 0) {
+ continue;
+ }
+ const normalized = String(Math.trunc(value));
+ if (dedupe && seen.has(normalized)) {
+ continue;
+ }
+ if (dedupe) {
+ seen.add(normalized);
+ }
+ output.push(normalized);
+ }
+ return output;
+}
+
+function normalizePositiveIndexes(rawList, maxValue = null) {
+ const values = normalizeTrackIds(rawList)
+ .map((item) => Number(item))
+ .filter((value) => Number.isFinite(value) && value > 0)
+ .map((value) => Math.trunc(value));
+ if (!Number.isFinite(maxValue) || maxValue <= 0) {
+ return values;
+ }
+ const limit = Math.trunc(maxValue);
+ return values.filter((value) => value <= limit);
+}
+
function normalizeNonNegativeInteger(rawValue) {
if (rawValue === null || rawValue === undefined) {
return null;
@@ -1209,10 +1243,14 @@ class SettingsService {
}
const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds);
- const subtitleTrackIds = normalizeTrackIds(rawSelection.subtitleTrackIds);
+ const subtitleTrackIds = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false });
const subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null;
const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null;
const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null;
+ const subtitleForcedTrackIndexes = normalizePositiveIndexes(
+ rawSelection.subtitleForcedTrackIndexes,
+ subtitleTrackIds.length
+ );
const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly);
const filteredExtra = removeSelectionArgs(extra);
const overrideArgs = [
@@ -1227,7 +1265,9 @@ class SettingsService {
if (subtitleDefaultTrackId !== null) {
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
}
- if (subtitleForcedTrackId !== null) {
+ if (subtitleForcedTrackIndexes.length > 0) {
+ overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackIndexes.join(',')}`);
+ } else if (subtitleForcedTrackId !== null) {
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
} else if (subtitleForcedOnly) {
overrideArgs.push('--subtitle-forced');
@@ -1245,6 +1285,7 @@ class SettingsService {
subtitleTrackIds,
subtitleBurnTrackId,
subtitleDefaultTrackId,
+ subtitleForcedTrackIndexes,
subtitleForcedTrackId,
subtitleForcedOnly
}
@@ -1258,6 +1299,7 @@ class SettingsService {
subtitleTrackIds,
subtitleBurnTrackId,
subtitleDefaultTrackId,
+ subtitleForcedTrackIndexes,
subtitleForcedTrackId,
subtitleForcedOnly
}
diff --git a/backend/src/utils/encodePlan.js b/backend/src/utils/encodePlan.js
index 0e1061f..9ea8943 100644
--- a/backend/src/utils/encodePlan.js
+++ b/backend/src/utils/encodePlan.js
@@ -3,6 +3,16 @@ const { splitArgs } = require('./commandLine');
const DEFAULT_AUDIO_COPY_MASK = ['aac', 'ac3', 'eac3', 'truehd', 'dts', 'dtshd', 'mp3', 'flac'];
const DEFAULT_AUDIO_FALLBACK = 'av_aac';
+const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({
+ low: 1,
+ medium: 2,
+ high: 3
+});
+const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35;
+const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35;
+const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220;
+const FORCED_SUBTITLE_MIN_EVENT_GAP = 12;
+const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024;
const ISO2_TO_3_LANGUAGE = {
de: 'deu',
en: 'eng',
@@ -271,7 +281,7 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
channels: item?.Channels || item?.Channel_s_ || null
}));
- const subtitleTracks = tracks
+ const subtitleTracksRaw = tracks
.filter((item) => {
const type = String(item?.['@type'] || '').toLowerCase();
return type === 'text' || type === 'subtitle';
@@ -282,8 +292,14 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'),
languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und',
title: item?.Title || null,
- format: item?.Format || null
+ format: item?.Format || null,
+ defaultFlag: parseBooleanFlag(item?.Default ?? item?.Default_String ?? item?.IsDefault ?? item?.isDefault),
+ forcedFlag: parseBooleanFlagNullable(item?.Forced ?? item?.Forced_String ?? item?.IsForced ?? item?.isForced),
+ sdhFlag: parseSubtitleSdhFlag(item),
+ eventCount: parseSubtitleEventCount(item),
+ streamSizeBytes: parseSubtitleStreamSizeBytes(item)
}));
+ const subtitleTracks = annotateSubtitleTracks(subtitleTracksRaw);
const videoTracks = tracks
.filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video')
@@ -350,6 +366,467 @@ function parseTrackIdList(raw) {
.filter((item) => Number.isFinite(item));
}
+function parseBooleanFlag(value) {
+ return parseBooleanFlagNullable(value) === true;
+}
+
+function parseBooleanFlagNullable(value) {
+ if (typeof value === 'boolean') {
+ return value;
+ }
+ if (typeof value === 'number') {
+ return value === 1;
+ }
+ const raw = String(value || '').trim().toLowerCase();
+ if (!raw) {
+ return null;
+ }
+ if (raw === 'yes' || raw === 'true' || raw === '1') {
+ return true;
+ }
+ if (raw === 'no' || raw === 'false' || raw === '0') {
+ return false;
+ }
+ return null;
+}
+
+function parseSubtitleForcedFlag(track) {
+ if (!track || typeof track !== 'object') {
+ return null;
+ }
+ const candidates = [
+ track?.forcedFlag,
+ track?.forced,
+ track?.Forced,
+ track?.isForced,
+ track?.IsForced,
+ track?.forced_only,
+ track?.forcedOnly,
+ track?.Attributes?.Forced
+ ];
+ for (const candidate of candidates) {
+ const parsed = parseBooleanFlagNullable(candidate);
+ if (parsed === true || parsed === false) {
+ return parsed;
+ }
+ }
+ return null;
+}
+
+function parseSubtitleSdhFlag(track) {
+ if (!track || typeof track !== 'object') {
+ return null;
+ }
+ const candidates = [
+ track?.sdhFlag,
+ track?.hearingImpaired,
+ track?.HearingImpaired,
+ track?.isHearingImpaired,
+ track?.IsHearingImpaired,
+ track?.Hearing_Impaired,
+ track?.closedCaptions,
+ track?.ClosedCaptions,
+ track?.Attributes?.HearingImpaired,
+ track?.Attributes?.ClosedCaptions
+ ];
+ for (const candidate of candidates) {
+ const parsed = parseBooleanFlagNullable(candidate);
+ if (parsed === true || parsed === false) {
+ return parsed;
+ }
+ }
+
+ const serviceKind = String(track?.serviceKind || track?.ServiceKind || '').trim().toLowerCase();
+ if (!serviceKind) {
+ return null;
+ }
+ if (serviceKind.includes('sdh') || serviceKind.includes('cc') || serviceKind.includes('hearing')) {
+ return true;
+ }
+ return null;
+}
+
+function parseSubtitleEventCount(track) {
+ const candidates = [
+ track?.CountOfEvents,
+ track?.countOfEvents,
+ track?.EventCount,
+ track?.eventCount,
+ track?.ElementCount,
+ track?.elementCount
+ ];
+ for (const candidate of candidates) {
+ const numeric = Number(candidate);
+ if (Number.isFinite(numeric) && numeric >= 0) {
+ return Math.trunc(numeric);
+ }
+ }
+ return null;
+}
+
+function parseSubtitleStreamSizeBytes(track) {
+ const numericCandidates = [
+ track?.StreamSize,
+ track?.streamSize,
+ track?.StreamSize_Original,
+ track?.streamSizeOriginal,
+ track?.Bytes,
+ track?.bytes
+ ];
+ for (const candidate of numericCandidates) {
+ const numeric = Number(candidate);
+ if (Number.isFinite(numeric) && numeric > 0) {
+ return Math.trunc(numeric);
+ }
+ }
+
+ const textCandidates = [
+ track?.StreamSize_String,
+ track?.streamSizeString,
+ track?.StreamSize_Original_String,
+ track?.streamSizeOriginalString,
+ track?.Size_String,
+ track?.sizeString
+ ];
+ for (const candidate of textCandidates) {
+ const text = String(candidate || '').trim();
+ if (!text) {
+ continue;
+ }
+ const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i);
+ if (!match) {
+ continue;
+ }
+ const value = Number(String(match[1]).replace(',', '.'));
+ if (!Number.isFinite(value) || value <= 0) {
+ continue;
+ }
+ const unit = String(match[2] || 'b').toLowerCase();
+ const factorByUnit = {
+ b: 1,
+ kb: 1024,
+ mb: 1024 ** 2,
+ gb: 1024 ** 3,
+ tb: 1024 ** 4
+ };
+ const factor = factorByUnit[unit] || 1;
+ return Math.max(0, Math.trunc(value * factor));
+ }
+ return null;
+}
+
+function normalizeSubtitleConfidence(raw) {
+ const value = String(raw || '').trim().toLowerCase();
+ if (value === 'high' || value === 'medium' || value === 'low') {
+ return value;
+ }
+ return 'low';
+}
+
+function subtitleConfidenceScore(raw) {
+ return SUBTITLE_CONFIDENCE_SCORES[normalizeSubtitleConfidence(raw)] || 0;
+}
+
+function collectSubtitleText(track) {
+ return [
+ track?.title,
+ track?.description,
+ track?.name,
+ track?.format,
+ track?.label
+ ]
+ .map((value) => String(value || '').trim().toLowerCase())
+ .filter(Boolean)
+ .join(' ');
+}
+
+function isLikelyBitmapSubtitleFormat(track) {
+ const text = [
+ track?.format,
+ track?.codec,
+ track?.codecName,
+ track?.title,
+ track?.description,
+ track?.name,
+ track?.label
+ ]
+ .map((value) => String(value || '').trim().toLowerCase())
+ .filter(Boolean)
+ .join(' ');
+ if (!text) {
+ return false;
+ }
+ return (
+ /\bpgs\b/.test(text)
+ || /\bhdmv\b/.test(text)
+ || /\bsup\b/.test(text)
+ || /\bvobsub\b/.test(text)
+ || /\bdvd[-_\s]?sub/.test(text)
+ || /\bdvb[-_\s]?sub/.test(text)
+ );
+}
+
+function isLikelySdhSubtitleTrack(track) {
+ const text = collectSubtitleText(track);
+ if (!text) {
+ return false;
+ }
+ return (
+ /\bsdh\b/.test(text)
+ || /\bcc\b/.test(text)
+ || /\bhoh\b/.test(text)
+ || /\bcaptions?\b/.test(text)
+ || /hard[-\s]?of[-\s]?hearing/.test(text)
+ || /hearing\s+impaired/.test(text)
+ || /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text)
+ );
+}
+
+function isLikelyForcedSubtitleTrack(track) {
+ const text = collectSubtitleText(track);
+ if (!text) {
+ return false;
+ }
+ if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) {
+ return false;
+ }
+ return (
+ /\bforced(?:\s+only)?\b/.test(text)
+ || /nur\s+erzwungen/.test(text)
+ || /\berzwungen\b/.test(text)
+ );
+}
+
+function compareSubtitleTracksByForcedHeuristic(a, b) {
+ const aSdh = Number(Boolean(a?.sdhLikely));
+ const bSdh = Number(Boolean(b?.sdhLikely));
+ if (aSdh !== bSdh) {
+ return aSdh - bSdh;
+ }
+
+ const aDefault = Number(Boolean(a?.defaultFlag));
+ const bDefault = Number(Boolean(b?.defaultFlag));
+ if (aDefault !== bDefault) {
+ return aDefault - bDefault;
+ }
+
+ const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1;
+ const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1;
+ if (aEventKnown !== bEventKnown) {
+ return aEventKnown - bEventKnown;
+ }
+ if (aEventKnown === 0 && a.eventCount !== b.eventCount) {
+ return a.eventCount - b.eventCount;
+ }
+
+ const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1;
+ const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1;
+ if (aSizeKnown !== bSizeKnown) {
+ return aSizeKnown - bSizeKnown;
+ }
+ if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) {
+ return a.streamSizeBytes - b.streamSizeBytes;
+ }
+
+ const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
+ const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
+ if (aTrackId !== bTrackId) {
+ return aTrackId - bTrackId;
+ }
+ return a.originalIndex - b.originalIndex;
+}
+
+function compareSubtitleTracksForDedup(a, b) {
+ const aSdh = Number(Boolean(a?.sdhLikely));
+ const bSdh = Number(Boolean(b?.sdhLikely));
+ if (aSdh !== bSdh) {
+ return aSdh - bSdh;
+ }
+
+ const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
+ if (defaultDiff !== 0) {
+ return defaultDiff;
+ }
+ const confidenceDiff = subtitleConfidenceScore(b?.sourceConfidence) - subtitleConfidenceScore(a?.sourceConfidence);
+ if (confidenceDiff !== 0) {
+ return confidenceDiff;
+ }
+ const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
+ const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
+ if (aTrackId !== bTrackId) {
+ return aTrackId - bTrackId;
+ }
+ return a.originalIndex - b.originalIndex;
+}
+
+function isHeuristicForcedSubtitleCandidate(languageEntries, candidate) {
+ if (!candidate) {
+ return false;
+ }
+ if (!isLikelyBitmapSubtitleFormat(candidate)) {
+ return false;
+ }
+ const comparable = (Array.isArray(languageEntries) ? languageEntries : [])
+ .filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry));
+ if (comparable.length === 0) {
+ return false;
+ }
+
+ const candidateEventCount = Number(candidate?.eventCount);
+ const comparableEventCounts = comparable
+ .map((entry) => Number(entry?.eventCount))
+ .filter((value) => Number.isFinite(value) && value > 0);
+ const maxComparableEventCount = comparableEventCounts.length > 0
+ ? Math.max(...comparableEventCounts)
+ : null;
+ const hasEventSignal = Number.isFinite(candidateEventCount)
+ && candidateEventCount >= 0
+ && Number.isFinite(maxComparableEventCount)
+ && maxComparableEventCount > 0
+ && candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT
+ && (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD
+ && (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP;
+
+ const candidateStreamSize = Number(candidate?.streamSizeBytes);
+ const comparableSizes = comparable
+ .map((entry) => Number(entry?.streamSizeBytes))
+ .filter((value) => Number.isFinite(value) && value > 0);
+ const maxComparableSize = comparableSizes.length > 0
+ ? Math.max(...comparableSizes)
+ : null;
+ const hasSizeSignal = Number.isFinite(candidateStreamSize)
+ && candidateStreamSize > 0
+ && Number.isFinite(maxComparableSize)
+ && maxComparableSize > 0
+ && (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD
+ && (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES;
+
+ const signalCount = Number(hasEventSignal) + Number(hasSizeSignal);
+ if (signalCount === 0) {
+ return false;
+ }
+
+ if (candidate.defaultFlag && signalCount < 2) {
+ return false;
+ }
+ return true;
+}
+
+function annotateSubtitleTracks(subtitleTracks) {
+ const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
+ if (tracks.length === 0) {
+ return [];
+ }
+
+ const entries = tracks.map((track, index) => ({
+ ...track,
+ language: normalizeLanguage(track?.language || track?.languageLabel || 'und'),
+ defaultFlag: Boolean(track?.defaultFlag),
+ forcedFlag: parseSubtitleForcedFlag(track),
+ forcedTrack: false,
+ sourceConfidence: null,
+ confidenceSource: 'heuristic',
+ duplicate: false,
+ selected: false,
+ subtitleType: 'full',
+ forcedAvailable: false,
+ forcedSourceTrackIds: [],
+ sdhLikely: (parseSubtitleSdhFlag(track) === true) || Boolean(track?.sdhLikely) || isLikelySdhSubtitleTrack(track),
+ originalIndex: index
+ }));
+
+ const byLanguage = new Map();
+ for (const entry of entries) {
+ if (!byLanguage.has(entry.language)) {
+ byLanguage.set(entry.language, []);
+ }
+ byLanguage.get(entry.language).push(entry);
+ }
+
+ for (const languageEntries of byLanguage.values()) {
+ for (const entry of languageEntries) {
+ const forcedByFlag = entry.forcedFlag === true;
+ const forcedBlockedByFlag = entry.forcedFlag === false;
+ const forcedByTitle = !entry.sdhLikely && !forcedBlockedByFlag && isLikelyForcedSubtitleTrack(entry);
+
+ if (forcedByFlag) {
+ entry.forcedTrack = true;
+ entry.sourceConfidence = 'high';
+ entry.confidenceSource = 'explicit_flag';
+ } else if (forcedByTitle) {
+ entry.forcedTrack = true;
+ entry.sourceConfidence = 'medium';
+ entry.confidenceSource = 'title';
+ }
+ }
+
+ if (!languageEntries.some((entry) => entry.forcedTrack)) {
+ const candidates = languageEntries
+ .filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false)
+ .sort(compareSubtitleTracksByForcedHeuristic);
+ const forcedCandidate = candidates[0] || null;
+ if (forcedCandidate && isHeuristicForcedSubtitleCandidate(languageEntries, forcedCandidate)) {
+ forcedCandidate.forcedTrack = true;
+ forcedCandidate.sourceConfidence = 'low';
+ forcedCandidate.confidenceSource = 'heuristic';
+ }
+ }
+
+ for (const entry of languageEntries) {
+ if (!entry.forcedTrack) {
+ continue;
+ }
+ entry.sourceConfidence = normalizeSubtitleConfidence(entry.sourceConfidence || 'low');
+ }
+
+ const forcedEntries = languageEntries.filter((entry) => entry.forcedTrack);
+ const fullEntries = languageEntries.filter((entry) => !entry.forcedTrack && !entry.sdhLikely);
+ const sdhEntries = languageEntries.filter((entry) => !entry.forcedTrack && entry.sdhLikely);
+ const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null;
+ const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null;
+ const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null;
+ const forcedSourceTrackIds = forcedEntries
+ .map((entry) => Number(entry?.sourceTrackId ?? entry?.id))
+ .filter((value) => Number.isFinite(value) && value > 0)
+ .map((value) => Math.trunc(value));
+ const forcedAvailable = Boolean(forcedWinner);
+
+ for (const entry of languageEntries) {
+ const typeWinner = entry.forcedTrack
+ ? forcedWinner
+ : (entry.sdhLikely ? sdhWinner : fullWinner);
+ entry.duplicate = Boolean(typeWinner && typeWinner !== entry);
+ if (entry.forcedTrack) {
+ entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
+ } else if (entry.sdhLikely) {
+ entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate);
+ } else {
+ entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
+ }
+ entry.subtitleType = entry.forcedTrack ? 'forced' : 'full';
+ entry.forcedAvailable = forcedAvailable;
+ entry.forcedSourceTrackIds = forcedSourceTrackIds;
+ const fullHasForced = !entry.forcedTrack && Boolean(
+ entry.fullHasForced
+ ?? entry.subtitleFullHasForced
+ ?? entry.hasForcedVariant
+ );
+ const explicitForcedOnly = entry.isForcedOnly ?? entry.forcedOnly ?? entry.subtitlePreviewForcedOnly;
+ entry.isForcedOnly = typeof explicitForcedOnly === 'boolean'
+ ? explicitForcedOnly
+ : (entry.forcedTrack && !fullHasForced);
+ entry.fullHasForced = fullHasForced;
+ }
+ }
+
+ return entries
+ .sort((a, b) => a.originalIndex - b.originalIndex)
+ .map((entry) => {
+ const { originalIndex, ...rest } = entry;
+ return rest;
+ });
+}
+
function parseEncoderList(raw) {
return String(raw || '')
.split(',')
@@ -638,7 +1115,10 @@ function buildTrackSelectors(settings, presetProfile) {
function selectTrackIds(tracks, selector, trackType) {
const available = Array.isArray(tracks) ? tracks : [];
- if (available.length === 0) {
+ const selectable = trackType === 'subtitle'
+ ? available.filter((track) => !Boolean(track?.duplicate))
+ : available;
+ if (selectable.length === 0) {
return [];
}
@@ -648,13 +1128,13 @@ function selectTrackIds(tracks, selector, trackType) {
if (selector.mode === 'all') {
if (selector.firstOnly) {
- return [available[0].id];
+ return [selectable[0].id];
}
- return available.map((track) => track.id);
+ return selectable.map((track) => track.id);
}
if (selector.mode === 'explicit') {
- const explicit = available
+ const explicit = selectable
.filter((track) => selector.explicitIds.includes(track.id))
.map((track) => track.id);
if (selector.firstOnly) {
@@ -664,7 +1144,7 @@ function selectTrackIds(tracks, selector, trackType) {
}
if (selector.mode === 'language') {
- const matches = available.filter((track) => selector.languages.includes(track.language));
+ const matches = selectable.filter((track) => selector.languages.includes(track.language));
if (selector.firstOnly) {
return matches.length > 0 ? [matches[0].id] : [];
}
@@ -672,11 +1152,11 @@ function selectTrackIds(tracks, selector, trackType) {
}
if (selector.mode === 'first') {
- return [available[0].id];
+ return [selectable[0].id];
}
if (trackType === 'audio') {
- return [available[0].id];
+ return [selectable[0].id];
}
return [];
@@ -916,21 +1396,45 @@ function buildMediainfoReview({
const normalizedSubtitle = title.subtitleTracks.map((track) => {
const selectedByRule = selectedSubtitleIds.includes(track.id);
const subtitleFlags = computeSubtitleFlags(track.id, selectedSubtitleIds, trackSelectors.subtitle);
+ const inferredForced = Boolean(
+ track?.forcedTrack
+ || String(track?.subtitleType || '').trim().toLowerCase() === 'forced'
+ );
+ const inferredForcedOnly = Boolean(
+ track?.isForcedOnly
+ ?? track?.forcedOnly
+ ?? track?.subtitlePreviewForcedOnly
+ ?? inferredForced
+ );
+ const inferredDefault = Boolean(track?.defaultFlag);
+ const subtitlePreviewFlags = [];
+ if (subtitleFlags.burned) {
+ subtitlePreviewFlags.push('burned');
+ }
+ if (subtitleFlags.forced || inferredForced) {
+ subtitlePreviewFlags.push('forced');
+ }
+ if (subtitleFlags.forcedOnly || inferredForcedOnly) {
+ subtitlePreviewFlags.push('forced-only');
+ }
+ if (subtitleFlags.default || inferredDefault) {
+ subtitlePreviewFlags.push('default');
+ }
const subtitlePreviewSummary = !selectedByRule
? 'Nicht übernommen'
- : (subtitleFlags.flags.length > 0
- ? `Übernehmen (${subtitleFlags.flags.join(', ')})`
+ : (subtitlePreviewFlags.length > 0
+ ? `Übernehmen (${subtitlePreviewFlags.join(', ')})`
: 'Übernehmen');
return {
...track,
selectedByRule,
subtitlePreviewSummary,
- subtitlePreviewFlags: subtitleFlags.flags,
+ subtitlePreviewFlags: selectedByRule ? subtitlePreviewFlags : [],
subtitlePreviewBurnIn: subtitleFlags.burned,
- subtitlePreviewForced: subtitleFlags.forced,
- subtitlePreviewForcedOnly: subtitleFlags.forcedOnly,
- subtitlePreviewDefaultTrack: subtitleFlags.default
+ subtitlePreviewForced: selectedByRule ? (subtitleFlags.forced || inferredForced) : false,
+ subtitlePreviewForcedOnly: selectedByRule ? (subtitleFlags.forcedOnly || inferredForcedOnly) : false,
+ subtitlePreviewDefaultTrack: selectedByRule ? (subtitleFlags.default || inferredDefault) : false
};
});
diff --git a/backend/src/utils/files.js b/backend/src/utils/files.js
index 98fb6ea..e16145a 100644
--- a/backend/src/utils/files.js
+++ b/backend/src/utils/files.js
@@ -44,13 +44,26 @@ function sanitizeFileNameWithExtension(input) {
}
function renderTemplate(template, values) {
- return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
- const val = values[key.trim()];
+ const rawSource = String(template || '${title} (${year})');
+ const resolveToken = (rawKey) => {
+ const key = String(rawKey || '').trim();
+ const val = values[key];
if (val === undefined || val === null || val === '') {
return 'unknown';
}
return String(val);
- });
+ };
+
+ const source = (
+ /[{}]/.test(rawSource)
+ ? rawSource
+ : rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}')
+ );
+
+ // Support both ${key} and legacy {key} placeholders (+ recovered bare tokens).
+ return source
+ .replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key))
+ .replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key));
}
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d632aad..08289e8 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
- "version": "0.12.0-15",
+ "version": "0.12.0-16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
- "version": "0.12.0-15",
+ "version": "0.12.0-16",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
diff --git a/frontend/package.json b/frontend/package.json
index 3afde2d..251d84a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
- "version": "0.12.0-15",
+ "version": "0.12.0-16",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 9d37d1e..bc59dec 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -617,6 +617,7 @@ export const api = {
const body = {};
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
+ if (options.reuseCurrentJob) body.reuseCurrentJob = true;
const result = await request(`/pipeline/restart-review/${jobId}`, {
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
@@ -920,6 +921,33 @@ export const api = {
body: JSON.stringify({ relPaths, audioMode })
});
},
+ converterAssignFilesToJob(jobId, relPaths = []) {
+ afterMutationInvalidate(['/converter/jobs']);
+ return request(`/converter/jobs/${encodeURIComponent(jobId)}/assign-files`, {
+ method: 'POST',
+ body: JSON.stringify({ relPaths })
+ });
+ },
+ converterRemoveFileFromJob(jobId, relPath) {
+ afterMutationInvalidate(['/converter/jobs']);
+ return request(`/converter/jobs/${encodeURIComponent(jobId)}/remove-file`, {
+ method: 'POST',
+ body: JSON.stringify({ relPath })
+ });
+ },
+ converterRemoveInputFromJob(jobId, inputPath) {
+ afterMutationInvalidate(['/converter/jobs']);
+ return request(`/converter/jobs/${encodeURIComponent(jobId)}/remove-input`, {
+ method: 'POST',
+ body: JSON.stringify({ inputPath })
+ });
+ },
+ converterUpdateJobConfig(jobId, config = {}) {
+ return request(`/converter/jobs/${encodeURIComponent(jobId)}/config`, {
+ method: 'POST',
+ body: JSON.stringify(config)
+ });
+ },
converterUploadFiles(formData) {
return request('/converter/upload', { method: 'POST', body: formData });
},
diff --git a/frontend/src/components/CdMetadataDialog.jsx b/frontend/src/components/CdMetadataDialog.jsx
index e944a11..67d93a9 100644
--- a/frontend/src/components/CdMetadataDialog.jsx
+++ b/frontend/src/components/CdMetadataDialog.jsx
@@ -86,6 +86,8 @@ export default function CdMetadataDialog({
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
+ const contextJobId = Number(context?.jobId || 0);
+
useEffect(() => {
if (!visible) {
return;
@@ -103,7 +105,7 @@ export default function CdMetadataDialog({
titles[t.position] = t.title || `Track ${t.position}`;
}
setTrackTitles(titles);
- }, [visible, context]);
+ }, [visible, contextJobId]);
useEffect(() => {
if (!selected) {
diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx
index b96acde..bc3461a 100644
--- a/frontend/src/components/CdRipConfigPanel.jsx
+++ b/frontend/src/components/CdRipConfigPanel.jsx
@@ -64,7 +64,12 @@ function normalizePosition(value) {
}
function normalizeTrackText(value) {
- return String(value || '').replace(/\s+/g, ' ').trim();
+ return String(value || '')
+ .normalize('NFC')
+ .replace(/[♥❤♡❥❣❦❧]/gu, ' ')
+ .replace(/\p{C}+/gu, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
}
function normalizeYear(value) {
diff --git a/frontend/src/components/ConverterFileExplorer.jsx b/frontend/src/components/ConverterFileExplorer.jsx
index 092c8dc..1f77d25 100644
--- a/frontend/src/components/ConverterFileExplorer.jsx
+++ b/frontend/src/components/ConverterFileExplorer.jsx
@@ -26,7 +26,7 @@ function mediaTypeBadge(type) {
return