0.12.0-16 misc fixes

This commit is contained in:
2026-03-30 14:37:23 +00:00
parent 76b020d5c0
commit 5284dd2a43
30 changed files with 6955 additions and 896 deletions
+6 -1
View File
@@ -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)"
]
}
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.12.0-15",
"version": "0.12.0-16",
"private": true,
"type": "commonjs",
"scripts": {
+129 -7
View File
@@ -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,
+87 -2
View File
@@ -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 });
})
);
+9 -2
View File
@@ -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 });
})
);
+34 -3
View File
@@ -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}`);
+144 -7
View File
@@ -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,
+128 -8
View File
@@ -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,22 +4426,73 @@ 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));
// 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];
}
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)) {
deletePath = parentDir;
parentDirs.add(parentDir);
}
}
const keepRoot = deletePath === rawRoot;
const result = deleteFilesRecursively(deletePath, keepRoot);
// Ü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;
}
}
}
if (target === 'movie' || target === 'both') {
+96 -14
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+44 -2
View File
@@ -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
}
+519 -15
View File
@@ -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
};
});
+16 -3
View File
@@ -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']) {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.12.0-15",
"version": "0.12.0-16",
"private": true,
"type": "module",
"scripts": {
+28
View File
@@ -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 });
},
+3 -1
View File
@@ -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) {
+6 -1
View File
@@ -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) {
+299 -50
View File
@@ -26,7 +26,7 @@ function mediaTypeBadge(type) {
return <Tag value={m.label} severity={m.severity} />;
}
/** Navigiert den Baum per Pfad-String */
/** Navigiert den Baum per Pfad-String (Ordner UND Dateien) */
function getNodeByPath(root, targetPath) {
if (!root) return null;
if ((root.path || '') === (targetPath || '')) return root;
@@ -34,6 +34,8 @@ function getNodeByPath(root, targetPath) {
if (child.type === 'folder') {
const found = getNodeByPath(child, targetPath);
if (found) return found;
} else if ((child.path || '') === (targetPath || '')) {
return child;
}
}
return null;
@@ -78,6 +80,52 @@ function collectDescendantFilePaths(node) {
return result;
}
/** Zustände, in denen ein Job aktiv läuft → Dateien sind gesperrt */
const LOCKED_JOB_STATES = new Set([
'ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK',
'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'
]);
/** Datei ist gesperrt wenn ihr Job gerade aktiv läuft */
function isNodeLocked(node) {
if (!node || node.type !== 'file') return false;
const jobId = Number(node.jobId);
if (!Number.isFinite(jobId) || jobId <= 0) return false;
return LOCKED_JOB_STATES.has(String(node.jobStatus || '').trim().toUpperCase());
}
/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Dateipfade sammeln */
function collectDescendantSelectableFilePaths(node) {
const result = [];
for (const child of (node?.children || [])) {
if (child.type === 'file') {
const assignedJobId = Number(child.jobId);
if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) {
result.push(child.path || '');
}
} else if (child.type === 'folder') {
result.push(...collectDescendantSelectableFilePaths(child));
}
}
return result;
}
/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Datei-Knoten sammeln */
function collectDescendantSelectableFileNodes(node) {
const result = [];
for (const child of (node?.children || [])) {
if (child.type === 'file') {
const assignedJobId = Number(child.jobId);
if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) {
result.push(child);
}
} else if (child.type === 'folder') {
result.push(...collectDescendantSelectableFileNodes(child));
}
}
return result;
}
/** Nur "Wurzel"-Selektionen: Pfade, die keinen ausgewählten Elternpfad haben */
function getRootSelections(paths) {
const set = new Set(paths);
@@ -98,7 +146,12 @@ function collectFolderPaths(node, result = []) {
// Hauptkomponente
export default function ConverterFileExplorer({ onSelectionChange, refreshToken, navigateToPath }) {
export default function ConverterFileExplorer({
onSelectionChange,
refreshToken,
navigateToPath,
onAssignmentChanged
}) {
const toastRef = useRef(null);
const explorerRef = useRef(null);
@@ -111,8 +164,11 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
const [currentPath, setCurrentPath] = useState('');
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
// Auswahl
// Auswahl (Checkbox Job-Zuweisung)
const [selectedPaths, setSelectedPaths] = useState([]);
// Aktive Zeilen-Auswahl (Klick Rename/Delete/Move)
const [activePaths, setActivePaths] = useState([]);
const [activeAnchor, setActiveAnchor] = useState(null);
// Seitenleiste
const [sidebarQuery, setSidebarQuery] = useState('');
@@ -123,10 +179,13 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
const [renameName, setRenameName] = useState('');
const [moveTarget, setMoveTarget] = useState('');
const [busy, setBusy] = useState(false);
const [jobUnassignTarget, setJobUnassignTarget] = useState(null);
// Stabile Ref für onSelectionChange
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
const onAssignmentChangedRef = useRef(onAssignmentChanged);
useEffect(() => { onAssignmentChangedRef.current = onAssignmentChanged; }, [onAssignmentChanged]);
// Baum laden
@@ -155,35 +214,42 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigateToPath]);
// Auswahl an Eltern melden: nur Wurzel-Selektionen (keine redundanten Kind-Pfade)
// Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert
useEffect(() => {
if (!tree) return;
const rootPaths = getRootSelections(selectedPaths);
const report = rootPaths.map((p) => {
const report = [];
for (const p of rootPaths) {
const node = getNodeByPath(tree, p);
if (!node) return null;
return {
if (!node) continue;
if (node.type === 'folder') {
const fileNodes = collectDescendantSelectableFileNodes(node);
if (fileNodes.length > 0) {
for (const fn of fileNodes) {
report.push({
relPath: fn.path,
entryType: 'file',
detectedMediaType: fn.detectedMediaType || null,
detectedFormat: fn.detectedFormat || null
});
}
} else {
// Leerer Ordner: als Ordner-Eintrag weitergeben
report.push({ relPath: node.path, entryType: 'directory', detectedMediaType: null, detectedFormat: null });
}
} else {
report.push({
relPath: node.path,
entryType: node.type === 'folder' ? 'directory' : 'file',
entryType: 'file',
detectedMediaType: node.detectedMediaType || null,
detectedFormat: node.detectedFormat || null
};
}).filter(Boolean);
});
}
}
onSelectionChangeRef.current?.(report);
}, [selectedPaths, tree]);
// Outside-Click Auswahl aufheben
useEffect(() => {
function handleOutside(event) {
if (activeModal) return;
if (!explorerRef.current) return;
if (!explorerRef.current.contains(event.target)) {
setSelectedPaths([]);
}
}
window.addEventListener('mousedown', handleOutside);
return () => window.removeEventListener('mousedown', handleOutside);
}, [activeModal]);
// Auswahl bleibt erhalten kein Outside-Click-Handler
// Navigation
@@ -194,7 +260,8 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
function navigateTo(pathStr) {
setCurrentPath(pathStr || '');
setSelectedPaths([]);
setActivePaths([]);
setActiveAnchor(null);
setExpandedFolders((prev) => new Set(prev).add(pathStr || ''));
}
@@ -236,10 +303,31 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
function handleCheckboxChange(item, checked) {
const p = item.path || '';
if (item.type === 'file') {
// Gesperrte Dateien (Job läuft) keine Aktion möglich
if (isNodeLocked(item)) return;
const assignedJobId = Number(item.jobId);
const isAssigned = Number.isFinite(assignedJobId) && assignedJobId > 0;
if (isAssigned && !checked) {
setJobUnassignTarget({
relPath: p,
jobId: Math.trunc(assignedJobId),
jobTitle: String(item.jobTitle || '').trim() || null
});
setActiveModal('job-unassign');
return;
}
if (isAssigned) {
return;
}
}
if (item.type === 'folder') {
const folderNode = getNodeByPath(tree, p);
const descendantFiles = collectDescendantFilePaths(folderNode);
const descendantFiles = collectDescendantSelectableFilePaths(folderNode);
if (checked) {
if (descendantFiles.length === 0) return;
setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles])));
} else {
const toRemove = new Set([p, ...descendantFiles]);
@@ -271,6 +359,35 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
}
}
async function handleRemoveFromJob() {
if (!jobUnassignTarget?.jobId || !jobUnassignTarget?.relPath) return;
setBusy(true);
try {
const result = await api.converterRemoveFileFromJob(jobUnassignTarget.jobId, jobUnassignTarget.relPath);
const removedRelPath = String(result?.removedRelPath || jobUnassignTarget.relPath || '').trim();
toastRef.current?.show({
severity: 'success',
summary: 'Aus Job entfernt',
detail: removedRelPath || 'Datei wurde aus dem Job entfernt.',
life: 2800
});
setSelectedPaths((prev) => prev.filter((entryPath) => entryPath !== removedRelPath));
setJobUnassignTarget(null);
setActiveModal('');
await loadTree();
onAssignmentChangedRef.current?.();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Datei konnte nicht aus dem Job entfernt werden.',
life: 4200
});
} finally {
setBusy(false);
}
}
function handleOpen(item) {
if (item.type !== 'folder') return;
navigateTo(item.path || '');
@@ -294,16 +411,16 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
}
async function handleRename() {
if (!selectedPaths.length) return;
if (!activePaths.length) return;
const name = renameName.trim();
if (!name) return;
setBusy(true);
try {
await api.converterRenameFile(rootSelected[0], name);
await api.converterRenameFile(activePaths[0], name);
toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `${name}`, life: 2500 });
setRenameName('');
setActiveModal('');
setSelectedPaths([]);
setActivePaths([]);
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
@@ -311,14 +428,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
}
async function handleDeleteSelected() {
if (!rootSelected.length) return;
if (!activePaths.length) return;
setBusy(true);
try {
for (const p of rootSelected) {
for (const p of activePaths) {
await api.converterDeleteFile(p);
}
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${rootSelected.length} Eintrag/Einträge`, life: 2500 });
setSelectedPaths([]);
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${activePaths.length} Eintrag/Einträge`, life: 2500 });
setActivePaths([]);
setSelectedPaths((prev) => prev.filter((x) => !activePaths.includes(x)));
setActiveModal('');
await loadTree();
} catch (err) {
@@ -327,14 +445,14 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
}
async function handleMoveSelected() {
if (!selectedPaths.length) return;
if (!activePaths.length) return;
setBusy(true);
try {
const isRoot = moveTarget === '' || moveTarget === '__root__';
await api.converterMoveFile(rootSelected[0], isRoot ? '' : moveTarget);
await api.converterMoveFile(activePaths[0], isRoot ? '' : moveTarget);
toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 });
setMoveTarget('');
setSelectedPaths([]);
setActivePaths([]);
setActiveModal('');
await loadTree();
} catch (err) {
@@ -342,6 +460,49 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
} finally { setBusy(false); }
}
function handleSelectActive() {
const newPaths = [];
for (const p of activePaths) {
const node = getNodeByPath(tree, p);
if (!node || isNodeLocked(node)) continue;
if (node.type === 'folder') {
newPaths.push(p, ...collectDescendantSelectableFilePaths(node));
} else {
newPaths.push(p);
}
}
setSelectedPaths((prev) => Array.from(new Set([...prev, ...newPaths])));
}
function handleRowClick(e, item) {
if (isNodeLocked(item)) return;
const p = item.path || '';
const items = currentItems;
if (e.shiftKey && activeAnchor) {
const anchorIdx = items.findIndex((i) => (i.path || '') === activeAnchor);
const clickIdx = items.findIndex((i) => (i.path || '') === p);
if (anchorIdx !== -1 && clickIdx !== -1) {
const from = Math.min(anchorIdx, clickIdx);
const to = Math.max(anchorIdx, clickIdx);
const rangePaths = items.slice(from, to + 1).map((i) => i.path || '');
if (e.ctrlKey || e.metaKey) {
setActivePaths((prev) => Array.from(new Set([...prev, ...rangePaths])));
} else {
setActivePaths(rangePaths);
}
}
} else if (e.ctrlKey || e.metaKey) {
setActivePaths((prev) =>
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
);
setActiveAnchor(p);
} else {
setActivePaths([p]);
setActiveAnchor(p);
}
}
// Ordnerbaum (Seitenleiste)
function renderFolderTree(node, depth) {
@@ -352,6 +513,9 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
const childFolders = (node.children || []).filter((c) => c.type === 'folder');
const hasChildren = childFolders.length > 0;
const nodeSel = isSelected(key);
const nodeIndet = isIndeterminate(node);
return (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<div
@@ -374,6 +538,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={nodeSel}
ref={(el) => { if (el) el.indeterminate = nodeIndet; }}
onChange={(e) => handleCheckboxChange(node, e.target.checked)}
aria-label={`${node.name || 'raw'} auswählen`}
/>
</span>
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
@@ -389,9 +562,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
const allFolders = tree ? collectFolderPaths(tree) : [{ label: '/ (Root)', value: '' }];
const breadcrumb = buildBreadcrumb(currentPath);
const rootSelected = getRootSelections(selectedPaths);
const canRename = rootSelected.length === 1;
const canDelete = selectedPaths.length > 0;
const canMove = rootSelected.length === 1;
// Aktionen basieren auf activePaths (Zeilen-Klick-Auswahl)
const hasLockedActive = activePaths.some((p) => {
const node = tree ? getNodeByPath(tree, p) : null;
return node ? isNodeLocked(node) : false;
});
const canRename = activePaths.length === 1 && !hasLockedActive;
const canDelete = activePaths.length > 0 && !hasLockedActive;
const canMove = activePaths.length === 1 && !hasLockedActive;
const canSelectActive = activePaths.length > 1;
// Render
@@ -500,11 +679,22 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
>
<i className="pi pi-folder-plus" />
</button>
{canSelectActive && (
<button
type="button"
className="icon-button"
onClick={handleSelectActive}
title="Auswahl als Checkbox setzen"
aria-label="Auswahl als Checkbox setzen"
>
<i className="pi pi-check-square" />
</button>
)}
<button
type="button"
className="icon-button"
onClick={() => {
const node = getNodeByPath(tree, rootSelected[0]);
const node = getNodeByPath(tree, activePaths[0]);
setRenameName(node?.name || '');
setActiveModal('rename');
}}
@@ -545,6 +735,7 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
<span>Name</span>
<span>Typ</span>
<span>Größe</span>
<span>Job</span>
</div>
{/* Zeilen */}
@@ -553,31 +744,36 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
Leer.
</div>
) : currentItems.map((item) => {
const sel = isSelected(item.path);
const assignedJobId = Number(item.jobId);
const assigned = item.type === 'file' && Number.isFinite(assignedJobId) && assignedJobId > 0;
const locked = isNodeLocked(item);
const sel = (assigned || isSelected(item.path)) && !locked;
const indet = isIndeterminate(item);
const active = activePaths.includes(item.path || '');
const jobTitle = String(item.jobTitle || '').trim();
return (
<div
key={item.path}
className={`explorer-row${sel ? ' selected' : ''}`}
onClick={() => {
if (item.type === 'folder') {
handleOpen(item);
} else {
handleCheckboxChange(item, !sel);
}
}}
className={`explorer-row${sel ? ' selected' : ''}${active ? ' row-active' : ''}${locked ? ' row-locked' : ''}`}
onClick={(e) => handleRowClick(e, item)}
onDoubleClick={() => item.type === 'folder' && handleOpen(item)}
role="row"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
tabIndex={locked ? -1 : 0}
onKeyDown={(e) => { if (locked) return; if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
>
<span className="row-checkbox" onClick={(e) => e.stopPropagation()}>
<span className="row-checkbox">
{locked ? (
<i className="pi pi-lock" style={{ fontSize: '0.8rem', color: 'var(--rip-muted)' }} title="Datei wird gerade verarbeitet" />
) : (
<input
type="checkbox"
checked={sel}
ref={(el) => { if (el) el.indeterminate = indet; }}
onChange={(e) => handleCheckboxChange(item, e.target.checked)}
onClick={(e) => e.stopPropagation()}
aria-label={`${item.name} auswählen`}
/>
)}
</span>
<span className="row-name">
<span className="row-icon">
@@ -593,6 +789,24 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
<span style={{ textAlign: 'right', color: 'var(--rip-muted)', fontSize: '0.78rem' }}>
{formatBytes(item.size)}
</span>
<span className="row-job">
{locked ? (
<small className="row-job-assignment" style={{ color: 'var(--orange-600, #e65100)' }}
title={`Job #${item.jobId} läuft (${item.jobStatus})`}>
<i className="pi pi-spin pi-spinner" style={{ fontSize: '0.65rem', marginRight: 3 }} />
#{item.jobId} läuft
</small>
) : assigned ? (
<small
className="row-job-assignment"
title={jobTitle ? `#${item.jobId} | ${jobTitle}` : `#${item.jobId}`}
>
#{item.jobId}{jobTitle ? ` | ${jobTitle}` : ''}
</small>
) : (
<small className="row-job-empty">-</small>
)}
</span>
</div>
);
})}
@@ -603,6 +817,7 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
<span />
<span />
<span />
<span />
<span className="footer-count">{getRootSelections(selectedPaths).length} ausgewählt</span>
</div>
)}
@@ -618,6 +833,40 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
{/* ── Dialoge ─────────────────────────────────────────────────────────── */}
{/* Aus Job entfernen */}
<Dialog
header="Aus Job entfernen?"
visible={activeModal === 'job-unassign'}
onHide={() => { setActiveModal(''); setJobUnassignTarget(null); }}
style={{ width: '420px' }}
footer={(
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button
label="Abbrechen"
outlined
onClick={() => { setActiveModal(''); setJobUnassignTarget(null); }}
disabled={busy}
/>
<Button
label="Entfernen"
severity="danger"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-times'}
disabled={busy}
onClick={handleRemoveFromJob}
/>
</div>
)}
modal
>
<p style={{ margin: 0, lineHeight: 1.5 }}>
Soll die Datei aus dem Job&nbsp;
<strong>
{jobUnassignTarget?.jobTitle || (jobUnassignTarget?.jobId ? `#${jobUnassignTarget.jobId}` : '-')}
</strong>
&nbsp;entfernt werden?
</p>
</Dialog>
{/* Neuer Ordner */}
<Dialog
header="Neuen Ordner erstellen"
File diff suppressed because it is too large Load Diff
+278 -19
View File
@@ -300,6 +300,18 @@ function resolveMediaType(job) {
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
}
if (String(encodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter') {
return 'converter';
}
if (String(job?.media_type || '').trim().toLowerCase() === 'converter') {
return 'converter';
}
if (resolveConverterMediaType(job)) {
return 'converter';
}
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
@@ -325,6 +337,26 @@ function resolveMediaType(job) {
return 'other';
}
function resolveConverterMediaType(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const candidates = [
encodePlan?.converterMediaType,
job?.converterMediaType,
job?.makemkvInfo?.converterMediaType,
job?.mediainfoInfo?.converterMediaType
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim().toLowerCase();
if (!raw) {
continue;
}
if (raw === 'audio' || raw === 'video' || raw === 'iso') {
return raw;
}
}
return null;
}
function resolveCdDetails(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
@@ -595,10 +627,12 @@ export default function JobDetailDialog({
onDownloadArchive,
onDownloadOutputFolder,
onRemoveFromQueue,
onCancel,
isQueued = false,
omdbAssignBusy = false,
cdMetadataAssignBusy = false,
actionBusy = false,
cancelBusy = false,
reencodeBusy = false,
deleteEntryBusy = false,
downloadBusyTarget = null,
@@ -609,11 +643,19 @@ export default function JobDetailDialog({
const isUnencodedOrphanImport = Boolean(
job?.makemkvInfo?.source === 'orphan_raw_import' && !job?.handbrakeInfo
);
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
const statusUpper = String(job?.status || '').trim().toUpperCase();
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
const softCancelable = ['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP'].includes(statusUpper);
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
const showFinalLog = !running;
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const isConverter = mediaType === 'converter';
const converterMediaType = isConverter ? (resolveConverterMediaType(job) || 'video') : null;
const converterMediaTypeLabel = converterMediaType === 'audio'
? 'Audio'
: (converterMediaType === 'iso' ? 'ISO (Video)' : 'Video');
const cdRawPathCandidates = [
job?.raw_path,
job?.makemkvInfo?.rawPath,
@@ -704,6 +746,62 @@ export default function JobDetailDialog({
&& mediaType !== 'audiobook'
&& typeof onRestartReview === 'function'
);
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
? job.encodePlan
: {};
const converterMetadata = isConverter && converterPlan?.metadata && typeof converterPlan.metadata === 'object'
? converterPlan.metadata
: {};
const converterInputPaths = isConverter
? (
Array.isArray(converterPlan?.inputPaths) && converterPlan.inputPaths.length > 0
? converterPlan.inputPaths
: [converterPlan?.inputPath || job?.raw_path]
)
.map((value) => String(value || '').trim())
.filter(Boolean)
: [];
const converterTrackList = isConverter && Array.isArray(converterPlan?.tracks)
? converterPlan.tracks
: [];
const converterOutputFormat = String(
converterPlan?.outputFormat
|| job?.handbrakeInfo?.format
|| converterPlan?.format
|| ''
).trim().toLowerCase();
const converterPresetLabel = String(
converterPlan?.userPreset?.name
|| converterPlan?.userPreset?.handbrakePreset
|| ''
).trim() || null;
const converterAudioQualityLabel = isConverter && converterMediaType === 'audio'
? (() => {
const opts = converterPlan?.audioFormatOptions && typeof converterPlan.audioFormatOptions === 'object'
? converterPlan.audioFormatOptions
: {};
if (converterOutputFormat === 'flac') {
return `Kompression ${Number(opts?.flacCompression ?? 5)}`;
}
if (converterOutputFormat === 'mp3') {
const mode = String(opts?.mp3Mode || 'cbr').trim().toLowerCase();
if (mode === 'vbr') {
return `VBR V${Number(opts?.mp3Quality ?? 4)}`;
}
return `CBR ${Number(opts?.mp3Bitrate ?? 192)} kbps`;
}
if (converterOutputFormat === 'aac') {
return `${Number(opts?.aacBitrate ?? 256)} kbps`;
}
if (converterOutputFormat === 'opus') {
return `${Number(opts?.opusBitrate ?? 160)} kbps`;
}
if (converterOutputFormat === 'ogg') {
return `Qualität ${Number(opts?.oggQuality ?? 6)}`;
}
return converterOutputFormat ? converterOutputFormat.toUpperCase() : '-';
})()
: null;
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
const queueLocked = Boolean(isQueued && job?.id);
const logCount = Number(job?.log_count || 0);
@@ -719,7 +817,7 @@ export default function JobDetailDialog({
? 'DVD'
: isCd
? 'Audio CD'
: (isAudiobook ? 'Audiobook' : 'Sonstiges Medium');
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : 'Sonstiges Medium'));
const mediaTypeIcon = mediaType === 'bluray'
? blurayIndicatorIcon
: mediaType === 'dvd'
@@ -742,6 +840,11 @@ export default function JobDetailDialog({
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
const canShowGeneralEncodeActions = canResumeReady
|| typeof onRestartEncode === 'function'
|| typeof onRestartReview === 'function'
|| typeof onReencode === 'function';
const useAudioPosterLayout = isCd || isAudiobook || (isConverter && converterMediaType === 'audio');
return (
<Dialog
@@ -759,9 +862,9 @@ export default function JobDetailDialog({
<div className="job-head-row">
{job.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={job.title || 'Poster'} className={isCd || isAudiobook ? 'poster-large-audio' : 'poster-large'} />
<img src={job.poster_url} alt={job.title || 'Poster'} className={useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} />
) : (
<div className={`${isCd || isAudiobook ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{isCd || isAudiobook ? 'Kein Cover' : 'Kein Poster'}</div>
<div className={`${useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{useAudioPosterLayout ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="job-film-info-grid">
@@ -812,6 +915,70 @@ export default function JobDetailDialog({
</div>
</div>
</section>
) : isConverter ? (
<section className="job-meta-block job-meta-block-film">
<h4>{converterMediaType === 'audio' ? 'Converter Audio' : 'Converter Video'}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
<span>{job.title || converterMetadata?.albumTitle || (job?.id ? `Job #${job.id}` : '-')}</span>
</div>
<div className="job-meta-item">
<strong>Typ:</strong>
<span>{converterMediaTypeLabel}</span>
</div>
<div className="job-meta-item">
<strong>Eingaben:</strong>
<span>{converterInputPaths.length > 0 ? `${converterInputPaths.length} Datei${converterInputPaths.length !== 1 ? 'en' : ''}` : '-'}</span>
</div>
<div className="job-meta-item">
<strong>Format:</strong>
<span>{converterOutputFormat ? converterOutputFormat.toUpperCase() : '-'}</span>
</div>
{converterMediaType === 'audio' ? (
<>
<div className="job-meta-item">
<strong>Album:</strong>
<span>{converterMetadata?.albumTitle || job?.title || (job?.id ? `Job #${job.id}` : '-')}</span>
</div>
<div className="job-meta-item">
<strong>Interpret:</strong>
<span>{converterMetadata?.albumArtist || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Jahr:</strong>
<span>{converterMetadata?.albumYear || job?.year || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Tracks:</strong>
<span>{converterTrackList.length > 0 ? converterTrackList.length : converterInputPaths.length || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Qualität:</strong>
<span>{converterAudioQualityLabel || '-'}</span>
</div>
</>
) : (
<>
<div className="job-meta-item">
<strong>Preset:</strong>
<span>{converterPresetLabel || '-'}</span>
</div>
<div className="job-meta-item">
<strong>HandBrake Titel:</strong>
<span>{converterPlan?.handBrakeTitleId || '-'}</span>
</div>
</>
)}
<div className="job-meta-item">
<strong>Medium:</strong>
<span className="job-step-cell">
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
<span>{mediaTypeLabel}</span>
</span>
</div>
</div>
</section>
) : (
<>
<section className="job-meta-block job-meta-block-film">
@@ -937,7 +1104,7 @@ export default function JobDetailDialog({
</>
) : (
<>
<span><strong>{isAudiobook ? 'Import:' : 'Backup:'}</strong> <BoolState value={job?.backupSuccess} /></span>
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> <BoolState value={job?.backupSuccess} /></span>
<span className="job-infos-sep">|</span>
<span><strong>Encode:</strong> <BoolState value={job?.encodeSuccess} /></span>
</>
@@ -950,7 +1117,7 @@ export default function JobDetailDialog({
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
{/* Zeile 3+4: Pfade */}
<PathField
label={isCd ? 'WAV:' : 'RAW:'}
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
value={isCd ? (job.raw_path || job.output_path) : job.raw_path}
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
downloadDisabled={!canDownloadRaw}
@@ -990,7 +1157,72 @@ export default function JobDetailDialog({
</div>
</section>
{!isCd && !isAudiobook && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
{isConverter ? (
<section className="job-meta-block job-meta-block-full">
<h4>Encode-Konfiguration</h4>
<div className="job-meta-grid job-meta-grid-compact">
<div>
<strong>Typ:</strong> {converterMediaTypeLabel}
</div>
<div>
<strong>Format:</strong> {converterOutputFormat ? converterOutputFormat.toUpperCase() : '-'}
</div>
<div>
<strong>Eingabe-Modus:</strong> {converterPlan?.isFolder ? 'Ordner' : (converterPlan?.isSharedAudio ? 'Gemeinsam' : 'Einzeln')}
</div>
<div>
<strong>Eingabe-Dateien:</strong> {converterInputPaths.length > 0 ? converterInputPaths.length : '-'}
</div>
<div>
<strong>Preset:</strong> {converterPresetLabel || '-'}
</div>
<div>
<strong>HandBrake Titel:</strong> {converterPlan?.handBrakeTitleId || '-'}
</div>
</div>
{converterMediaType === 'audio' ? (
<div className="job-meta-grid job-meta-grid-compact">
<div>
<strong>Album:</strong> {converterMetadata?.albumTitle || job?.title || (job?.id ? `Job #${job.id}` : '-')}
</div>
<div>
<strong>Interpret:</strong> {converterMetadata?.albumArtist || '-'}
</div>
<div>
<strong>Jahr:</strong> {converterMetadata?.albumYear || job?.year || '-'}
</div>
<div>
<strong>Qualität:</strong> {converterAudioQualityLabel || '-'}
</div>
</div>
) : null}
{converterInputPaths.length > 0 ? (
<div className="track-group">
{converterInputPaths.map((inputPath, index) => (
<div key={`${inputPath}-${index}`} className="track-item">
<span>#{index + 1} | {inputPath}</span>
</div>
))}
</div>
) : null}
{converterMediaType === 'audio' && converterTrackList.length > 0 ? (
<div className="track-group">
{converterTrackList.map((track, index) => {
const position = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : (index + 1);
const title = String(track?.title || '').trim() || `Track ${position}`;
const artist = String(track?.artist || '').trim();
return (
<div key={`${position}-${title}`} className="track-item">
<span>#{position} | {artist ? `${artist} - ` : ''}{title}</span>
</div>
);
})}
</div>
) : null}
</section>
) : null}
{!isCd && !isAudiobook && !isConverter && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Encode-Konfiguration</h4>
{/* Zeile 1: Preset + Mindestlaufzeit */}
@@ -1193,13 +1425,13 @@ export default function JobDetailDialog({
<section className="job-meta-block job-meta-block-full">
<h4>Logs</h4>
<div className="job-json-grid">
{!isCd && !isAudiobook ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
{!isCd && !isAudiobook && !isConverter ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : 'MakeMKV Info')} value={job.makemkvInfo} />
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : 'HandBrake Info')} value={job.handbrakeInfo} />
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
</section>
@@ -1257,6 +1489,27 @@ export default function JobDetailDialog({
</div>
) : (
<div className="actions-section">
{showCancelAction ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-ban" /> {running ? 'Laufender Job' : 'Wartender Job'}</div>
<div className="action-item">
<Button
label="Job abbrechen"
icon="pi pi-times"
severity="warning"
size="small"
onClick={() => onCancel?.(job)}
loading={cancelBusy}
/>
<span className="action-desc">
{running
? 'Bricht den aktuell laufenden Job sofort ab.'
: `Bricht den wartenden Job im Status ${statusUpper || '-'} ab.`}
</span>
</div>
</div>
) : null}
{isCd ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audio CD</div>
@@ -1304,6 +1557,7 @@ export default function JobDetailDialog({
</div>
</div>
) : (
canShowGeneralEncodeActions ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
{canResumeReady ? (
@@ -1352,6 +1606,7 @@ export default function JobDetailDialog({
}</span>
</div>
) : null}
{typeof onReencode === 'function' ? (
<div className="action-item">
<Button
label="Neustart"
@@ -1360,17 +1615,19 @@ export default function JobDetailDialog({
size="small"
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
disabled={isUnencodedOrphanImport ? !canRestartReview : (!canReencode || typeof onReencode !== 'function')}
disabled={isUnencodedOrphanImport ? !canRestartReview : !canReencode}
/>
<span className="action-desc">{isAudiobook
? 'Liest die AAX-Datei neu ein und öffnet danach die Kapitel-Auswahl. Sinnvoll wenn sich die Quelldatei geändert hat.'
: 'Analysiert die Quelldatei neu (MediaInfo, Playlist-Check) und öffnet dann die Spur-Auswahl. Unterschied zu „Spur-Auswahl öffnen": der Analyse-Schritt wird komplett wiederholt.'
}</span>
</div>
) : null}
</div>
) : null
)}
{!isCd && !isAudiobook && typeof onAssignOmdb === 'function' ? (
{!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
@@ -1389,6 +1646,7 @@ export default function JobDetailDialog({
</div>
) : null}
{typeof onDeleteFiles === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
<div className="action-item">
@@ -1400,22 +1658,22 @@ export default function JobDetailDialog({
size="small"
onClick={() => onDeleteFiles?.(job, 'raw')}
loading={actionBusy}
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
disabled={!job.rawStatus?.exists}
/>
<span className="action-desc">Löscht die Quelldateien nach dem Rip ({isCd ? 'Audio-Rohdaten' : isAudiobook ? 'AAX/MP3-Quelldatei' : 'MKV-Datei von MakeMKV'}).</span>
<span className="action-desc">Löscht die Quelldateien nach dem Rip ({isCd ? 'Audio-Rohdaten' : isAudiobook ? 'AAX/MP3-Quelldatei' : isConverter ? 'Quelldatei aus dem Converter-Import' : 'MKV-Datei von MakeMKV'}).</span>
</div>
<div className="action-item">
<Button
label={isCd ? 'Audio löschen' : (isAudiobook ? 'Ausgabe löschen' : 'Movie löschen')}
label={isCd ? 'Audio löschen' : (isAudiobook ? 'Ausgabe löschen' : (isConverter ? 'Output löschen' : 'Movie löschen'))}
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!hasAnyOutputFolder || typeof onDeleteFiles !== 'function'}
disabled={!hasAnyOutputFolder}
/>
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : isConverter ? 'die fertig konvertierte Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
</div>
<div className="action-item">
<Button
@@ -1425,11 +1683,12 @@ export default function JobDetailDialog({
size="small"
onClick={() => onDeleteFiles?.(job, 'both')}
loading={actionBusy}
disabled={(!job.rawStatus?.exists && !job.outputStatus?.exists) || typeof onDeleteFiles !== 'function'}
disabled={!job.rawStatus?.exists && !job.outputStatus?.exists}
/>
<span className="action-desc">Löscht RAW-Quelldateien und Ausgabe gemeinsam.</span>
</div>
</div>
) : null}
</div>
)}
+491 -53
View File
@@ -65,21 +65,246 @@ function isBurnedSubtitleTrack(track) {
);
}
function isForcedOnlySubtitleTrack(track) {
const summary = `${track?.title || ''} ${track?.description || ''} ${track?.languageLabel || ''}`.toLowerCase();
return Boolean(
track?.forcedTrack
|| /forced only/.test(summary)
|| /nur erzwungen/.test(summary)
|| /\berzwungen\b/.test(summary)
);
function isDuplicateSubtitleTrack(track) {
return Boolean(track?.duplicate);
}
function hasForcedSubtitleAvailable(track) {
const sourceTrackIds = normalizeTrackIdList(
Array.isArray(track?.forcedSourceTrackIds) ? track.forcedSourceTrackIds : []
function normalizeTrackIdSequence(values, options = {}) {
const list = Array.isArray(values) ? values : [];
const dedupe = options?.dedupe !== false;
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeTrackId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (dedupe && seen.has(key)) {
continue;
}
if (dedupe) {
seen.add(key);
}
output.push(normalized);
}
return output;
}
function normalizeSubtitleLanguage(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return 'und';
}
if (raw.length >= 3) {
return raw.slice(0, 3);
}
return raw;
}
function normalizeSubtitleLanguageOrder(rawOrder = []) {
const list = Array.isArray(rawOrder) ? rawOrder : [];
const seen = new Set();
const output = [];
for (const item of list) {
const language = normalizeSubtitleLanguage(item);
if (!language || seen.has(language)) {
continue;
}
seen.add(language);
output.push(language);
}
return output;
}
function normalizeSubtitleVariantSelectionMap(rawSelection) {
const map = {};
if (!rawSelection || typeof rawSelection !== 'object') {
return map;
}
for (const [language, value] of Object.entries(rawSelection)) {
const normalizedLanguage = normalizeSubtitleLanguage(language);
map[normalizedLanguage] = {
full: Boolean(value?.full),
forced: Boolean(value?.forced)
};
}
return map;
}
function normalizeSubtitleConfidenceValue(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'high' || raw === 'medium' || raw === 'low') {
return raw;
}
return 'low';
}
function subtitleConfidenceScore(value) {
const normalized = normalizeSubtitleConfidenceValue(value);
if (normalized === 'high') {
return 3;
}
if (normalized === 'medium') {
return 2;
}
return 1;
}
function resolveSubtitleTrackVariantFlags(track) {
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
const isForcedOnly = Boolean(
track?.isForcedOnly
?? track?.subtitlePreviewForcedOnly
?? track?.forcedOnly
?? subtitleType === 'forced'
);
return Boolean(track?.forcedAvailable || sourceTrackIds.length > 0);
const fullHasForced = !isForcedOnly && Boolean(
track?.fullHasForced
?? track?.subtitleFullHasForced
?? track?.hasForcedVariant
);
return { isForcedOnly, fullHasForced };
}
function compareForcedSubtitleCandidate(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 compareFullSubtitleCandidate(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 resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks,
subtitleVariantSelection,
subtitleLanguageOrder
}) {
const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : [])
.map((track, index) => {
const id = normalizeTrackId(track?.id ?? track?.sourceTrackId);
if (id === null) {
return null;
}
if (isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track)) {
return null;
}
const flags = resolveSubtitleTrackVariantFlags(track);
return {
id,
language: normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'),
isForcedOnly: flags.isForcedOnly,
fullHasForced: flags.fullHasForced,
defaultFlag: Boolean(track?.defaultFlag ?? track?.subtitlePreviewDefaultTrack ?? track?.defaultTrack),
confidence: normalizeSubtitleConfidenceValue(track?.sourceConfidence || track?.confidence),
selectedForEncode: Boolean(track?.selectedForEncode),
originalIndex: index
};
})
.filter(Boolean);
const grouped = new Map();
for (const track of tracks) {
if (!grouped.has(track.language)) {
grouped.set(track.language, []);
}
grouped.get(track.language).push(track);
}
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(subtitleVariantSelection || {});
const hasExplicitVariantSelection = Object.keys(normalizedVariantSelection).length > 0;
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder || []);
const sortedLanguages = Array.from(grouped.entries())
.map(([language, languageTracks]) => ({
language,
minId: languageTracks.reduce((minValue, track) => Math.min(minValue, track.id), Number.MAX_SAFE_INTEGER)
}))
.sort((a, b) => a.minId - b.minId || a.language.localeCompare(b.language))
.map((item) => item.language);
const order = [];
const seen = new Set();
const pushLanguage = (language) => {
if (!grouped.has(language) || seen.has(language)) {
return;
}
seen.add(language);
order.push(language);
};
normalizedLanguageOrder.forEach(pushLanguage);
sortedLanguages.forEach(pushLanguage);
const subtitleTrackIds = [];
const subtitleForcedTrackIndexes = [];
for (const language of order) {
const languageTracks = grouped.get(language) || [];
const forcedOnly = languageTracks.filter((track) => track.isForcedOnly).sort(compareForcedSubtitleCandidate)[0] || null;
const fullTracks = languageTracks.filter((track) => !track.isForcedOnly).sort(compareFullSubtitleCandidate);
const bestFull = fullTracks[0] || null;
const bestFullHasForced = fullTracks.filter((track) => track.fullHasForced).sort(compareFullSubtitleCandidate)[0] || null;
const explicit = normalizedVariantSelection[language] || null;
const fallbackFull = languageTracks.some((track) => track.selectedForEncode && !track.isForcedOnly) || Boolean(bestFull);
const fallbackForced = languageTracks.some((track) => track.selectedForEncode && track.isForcedOnly) || Boolean(forcedOnly);
const requestedFull = explicit
? Boolean(explicit.full)
: (hasExplicitVariantSelection ? false : fallbackFull);
const requestedForced = explicit
? Boolean(explicit.forced)
: (hasExplicitVariantSelection ? false : fallbackForced);
if (!requestedFull && !requestedForced) {
continue;
}
if (forcedOnly) {
subtitleTrackIds.push(forcedOnly.id);
if (requestedFull && bestFull) {
subtitleTrackIds.push(bestFull.id);
}
continue;
}
if (bestFullHasForced) {
if (requestedFull && requestedForced) {
subtitleTrackIds.push(bestFullHasForced.id);
subtitleTrackIds.push(bestFullHasForced.id);
subtitleForcedTrackIndexes.push(subtitleTrackIds.length);
} else if (requestedForced) {
subtitleTrackIds.push(bestFullHasForced.id);
subtitleForcedTrackIndexes.push(subtitleTrackIds.length);
} else if (requestedFull) {
subtitleTrackIds.push(bestFullHasForced.id);
}
continue;
}
if (requestedFull && bestFull) {
subtitleTrackIds.push(bestFull.id);
}
}
return {
subtitleTrackIds,
subtitleForcedTrackIndexes
};
}
function splitArgs(input) {
@@ -191,6 +416,8 @@ function buildHandBrakeCommandPreview({
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection = {},
selectedSubtitleLanguageOrder = [],
commandOutputPath = null,
presetOverride = null
}) {
@@ -211,7 +438,19 @@ function buildHandBrakeCommandPreview({
? Math.trunc(rawMappedTitleId)
: null;
const selectedSubtitleSet = new Set(normalizeTrackIdList(selectedSubtitleTrackIds).map((id) => String(id)));
const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
subtitleVariantSelection: selectedSubtitleVariantSelection,
subtitleLanguageOrder: selectedSubtitleLanguageOrder
});
const normalizedSelectedVariantSelection = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const hasExplicitSelectedVariantSelection = Object.keys(normalizedSelectedVariantSelection).length > 0;
const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0
? resolvedSubtitleSelection.subtitleTrackIds
: (hasExplicitSelectedVariantSelection
? []
: normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false }));
const selectedSubtitleSet = new Set(normalizeTrackIdList(subtitleTrackIds).map((id) => String(id)));
const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
const id = normalizeTrackId(track?.id);
return id !== null && selectedSubtitleSet.has(String(id));
@@ -223,10 +462,6 @@ function buildHandBrakeCommandPreview({
const subtitleDefaultTrackId = normalizeTrackIdList(
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
)[0] || null;
const subtitleForcedTrackId = normalizeTrackIdList(
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewForced || track?.forced)).map((track) => track?.id)
)[0] || null;
const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.subtitlePreviewForcedOnly || track?.forcedOnly));
const baseArgs = [
'-i',
@@ -248,7 +483,7 @@ function buildHandBrakeCommandPreview({
'-a',
normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none',
'-s',
normalizeTrackIdList(selectedSubtitleTrackIds).join(',') || 'none'
subtitleTrackIds.length > 0 ? subtitleTrackIds.join(',') : 'none'
];
if (subtitleBurnTrackId !== null) {
@@ -257,10 +492,8 @@ function buildHandBrakeCommandPreview({
if (subtitleDefaultTrackId !== null) {
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
}
if (subtitleForcedTrackId !== null) {
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
} else if (subtitleForcedOnly) {
overrideArgs.push('--subtitle-forced');
if (resolvedSubtitleSelection.subtitleForcedTrackIndexes.length > 0) {
overrideArgs.push(`--subtitle-forced=${resolvedSubtitleSelection.subtitleForcedTrackIndexes.join(',')}`);
}
const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs];
@@ -637,11 +870,191 @@ function TrackList({
type = 'generic',
allowSelection = false,
selectedTrackIds = [],
selectedSubtitleVariantSelection = {},
selectedSubtitleLanguageOrder = [],
onToggleTrack = null,
onToggleSubtitleVariant = null,
audioSelector = null
}) {
const orderedTracks = (Array.isArray(tracks) ? [...tracks] : [])
.sort((a, b) => {
const aId = normalizeTrackId(a?.id) ?? Number.MAX_SAFE_INTEGER;
const bId = normalizeTrackId(b?.id) ?? Number.MAX_SAFE_INTEGER;
if (aId !== bId) {
return aId - bId;
}
return String(a?.language || '').localeCompare(String(b?.language || ''));
});
if (type === 'subtitle') {
const selectedVariants = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const sortedSubtitleTracks = orderedTracks
.filter((track) => !isDuplicateSubtitleTrack(track));
const renderVariantRow = ({
key,
checked,
disabled,
onChange,
text,
confidence = null,
indent = false
}) => {
const confidenceColor = confidence === 'high'
? '#2e7d32'
: confidence === 'medium'
? '#f57c00'
: '#c62828';
return (
<div key={key} className="media-track-item" style={indent ? { paddingLeft: '1.4rem' } : null}>
<label className="readonly-check-row">
<input
type="checkbox"
checked={checked}
onChange={onChange}
readOnly={disabled}
disabled={disabled}
/>
<span>
{text}
{confidence ? <span style={{ color: confidenceColor, marginLeft: '0.35rem' }}></span> : null}
</span>
</label>
</div>
);
};
const resolveSubtitleActionInfo = (track, selected) => {
const base = String(track?.subtitlePreviewSummary || track?.subtitleActionSummary || '').trim();
if (allowSelection) {
return selected ? 'Übernehmen' : 'Nicht übernommen';
}
return base || (selected ? 'Übernehmen' : 'Nicht übernommen');
};
return (
<div>
<h4>{title}</h4>
{!tracks || tracks.length === 0 ? (
<p>Keine Einträge.</p>
) : (
<div className="media-track-list">
{sortedSubtitleTracks.map((track) => {
const burned = isBurnedSubtitleTrack(track);
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
const displayLanguage = toLang2(track?.language || track?.languageLabel || 'und');
const displayCodec = simplifyCodec('subtitle', track?.format, track?.description || track?.title);
const flags = resolveSubtitleTrackVariantFlags(track);
const confidenceRaw = String(track?.sourceConfidence || '').trim().toLowerCase();
const confidence = confidenceRaw === 'high' || confidenceRaw === 'medium' || confidenceRaw === 'low'
? confidenceRaw
: 'low';
const languageSelection = selectedVariants[language] || { full: false, forced: false };
if (flags.isForcedOnly) {
const checked = allowSelection ? Boolean(languageSelection.forced) : Boolean(track?.selectedForEncode);
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checked);
return (
<div key={`${title}-${track.id}-forced-only-group`} className="media-track-item">
{renderVariantRow({
key: `${title}-${track.id}-forced-only`,
checked,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'forced', event.target.checked);
},
text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})`,
confidence
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
}
if (flags.fullHasForced) {
const checkedFull = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode);
const checkedForced = allowSelection ? Boolean(languageSelection.forced) : false;
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checkedFull || checkedForced);
return (
<div key={`${title}-${track.id}-combo`} className="media-track-item">
<div className="media-track-item">
<small className="track-action-note">{`#${track.id} | ${displayLanguage}`}</small>
</div>
{renderVariantRow({
key: `${title}-${track.id}-combo-full`,
checked: checkedFull,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'full', event.target.checked);
},
text: `Komplette Untertitel (${displayCodec})`,
indent: true
})}
{renderVariantRow({
key: `${title}-${track.id}-combo-forced`,
checked: checkedForced,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'forced', event.target.checked);
},
text: `Automatische Übersetzungen (${displayCodec})`,
confidence,
indent: true
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
}
const checked = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode);
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checked);
return (
<div key={`${title}-${track.id}-full-group`} className="media-track-item">
{renderVariantRow({
key: `${title}-${track.id}-full`,
checked,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'full', event.target.checked);
},
text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})`
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
})}
</div>
)}
<div className="subtitle-footnote-list">
<small className="track-action-note">
Automatische Übersetzungen werden nur eingeblendet, wenn im Film eine andere Sprache gesprochen wird. Komplette Untertitel zeigen alle Dialoge an. Blu-ray Untertitel liegen meist als PGS (Bildformat) vor.
</small>
<small className="track-action-note">
Confidence-Index (nur für Automatische Übersetzungen): <span style={{ color: '#2e7d32' }}></span> high = explizites Track-Signal erkannt, <span style={{ color: '#f57c00' }}></span> medium = "forced" im Titel erkannt, <span style={{ color: '#c62828' }}></span> low = heuristische Einschätzung.
</small>
</div>
</div>
);
}
const selectedIds = normalizeTrackIdList(selectedTrackIds);
const checkedTrackOrder = (Array.isArray(tracks) ? tracks : [])
const checkedTrackOrder = orderedTracks
.map((track) => normalizeTrackId(track?.id))
.filter((trackId, index) => {
if (trackId === null) {
@@ -650,7 +1063,7 @@ function TrackList({
if (allowSelection) {
return selectedIds.includes(trackId);
}
const track = tracks[index];
const track = orderedTracks[index];
return Boolean(track?.selectedForEncode);
});
@@ -661,11 +1074,10 @@ function TrackList({
<p>Keine Einträge.</p>
) : (
<div className="media-track-list">
{tracks.map((track) => {
{orderedTracks.map((track) => {
const trackId = normalizeTrackId(track.id);
const burned = type === 'subtitle' ? isBurnedSubtitleTrack(track) : false;
const checked = allowSelection
? (trackId !== null && selectedIds.includes(trackId) && !(type === 'subtitle' && burned))
? (trackId !== null && selectedIds.includes(trackId))
: Boolean(track.selectedForEncode);
const selectedIndex = trackId !== null
? checkedTrackOrder.indexOf(trackId)
@@ -686,13 +1098,6 @@ function TrackList({
return base || buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
})()
: 'Nicht übernommen')
: type === 'subtitle'
? (checked
? (() => {
const base = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
return /^nicht übernommen$/i.test(base) ? 'Übernehmen' : (base || 'Übernehmen');
})()
: 'Nicht übernommen')
: null;
const displayLanguage = toLang2(track.language || track.languageLabel || 'und');
const displayHint = track.description || track.title;
@@ -700,9 +1105,7 @@ function TrackList({
const displayChannelCount = channelCount(track.channels);
const displayAudioTitle = audioChannelLabel(track.channels);
const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : '';
const disabled = !allowSelection || (type === 'subtitle' && burned);
const forcedOnlyTrack = type === 'subtitle' ? isForcedOnlySubtitleTrack(track) : false;
const forcedAvailable = type === 'subtitle' ? hasForcedSubtitleAvailable(track) : false;
const disabled = !allowSelection;
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
if (type === 'audio') {
@@ -716,14 +1119,6 @@ function TrackList({
displayText += ` | ${audioVariant}`;
}
}
if (type === 'subtitle' && burned) {
displayText += ' | burned';
} else if (type === 'subtitle' && forcedOnlyTrack) {
displayText += ' | forced-only';
} else if (type === 'subtitle' && forcedAvailable) {
displayText += ' | forced verfügbar';
}
return (
<div key={`${title}-${track.id}`} className="media-track-item">
<label className="readonly-check-row">
@@ -739,7 +1134,9 @@ function TrackList({
readOnly={disabled}
disabled={disabled}
/>
<span>{displayText}</span>
<span>
{displayText}
</span>
</label>
{actionInfo ? <small className="track-action-note">Encode: {actionInfo}</small> : null}
</div>
@@ -804,6 +1201,7 @@ export default function MediaInfoReviewPanel({
allowTrackSelection = false,
trackSelectionByTitle = {},
onTrackSelectionChange = null,
onSubtitleVariantSelectionChange = null,
availableScripts = [],
availableChains = [],
preEncodeItems = [],
@@ -934,7 +1332,7 @@ export default function MediaInfoReviewPanel({
<div><strong>Audio Copy-Mask:</strong> {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}</div>
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.forcedOnly ? 'forced-only' : '-'}{review.selectors?.subtitle?.burnBehavior === 'first' ? ' + burned(first)' : ''}</div>
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.burnBehavior === 'first' ? 'burned(first)' : '-'}</div>
</div>
{review.partial ? (
@@ -1213,18 +1611,44 @@ export default function MediaInfoReviewPanel({
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const selectableSubtitleTrackIds = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track))
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const selectableSubtitleTrackIdSet = new Set(selectableSubtitleTrackIds.map((id) => String(id)));
const sortedSelectableSubtitleTracks = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.slice()
.sort((a, b) => (normalizeTrackId(a?.id) || Number.MAX_SAFE_INTEGER) - (normalizeTrackId(b?.id) || Number.MAX_SAFE_INTEGER));
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleTrackIds = subtitleTracks
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleVariantSelection = {};
const defaultSubtitleLanguageOrder = [];
const subtitleLanguageOrderSeen = new Set();
for (const track of sortedSelectableSubtitleTracks) {
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!subtitleLanguageOrderSeen.has(language)) {
subtitleLanguageOrderSeen.add(language);
defaultSubtitleLanguageOrder.push(language);
}
if (!Boolean(track?.selectedByRule)) {
continue;
}
if (!defaultSubtitleVariantSelection[language]) {
defaultSubtitleVariantSelection[language] = { full: false, forced: false };
}
const flags = resolveSubtitleTrackVariantFlags(track);
if (flags.isForcedOnly) {
defaultSubtitleVariantSelection[language].forced = true;
} else {
defaultSubtitleVariantSelection[language].full = true;
}
}
const selectedAudioTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.audioTrackIds)
? titleSelectionEntry.audioTrackIds
@@ -1235,6 +1659,14 @@ export default function MediaInfoReviewPanel({
? titleSelectionEntry.subtitleTrackIds
: defaultSubtitleTrackIds
).filter((id) => selectableSubtitleTrackIdSet.has(String(id)));
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
);
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
? titleSelectionEntry.subtitleLanguageOrder
: defaultSubtitleLanguageOrder
);
const allowTrackSelectionForTitle = Boolean(
allowTrackSelection
&& allowTitleSelection
@@ -1302,15 +1734,19 @@ export default function MediaInfoReviewPanel({
/>
<TrackList
title={`Subtitles (Titel #${title.id})`}
tracks={allowTrackSelectionForTitle ? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track)) : subtitleTracks}
tracks={allowTrackSelectionForTitle
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
: subtitleTracks}
type="subtitle"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedSubtitleTrackIds}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
}}
/>
</div>
@@ -1320,6 +1756,8 @@ export default function MediaInfoReviewPanel({
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder,
commandOutputPath,
presetOverride: effectivePresetOverride
});
+253 -14
View File
@@ -15,6 +15,14 @@ function normalizeTitleId(value) {
return Math.trunc(parsed);
}
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizePlaylistId(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
@@ -155,10 +163,119 @@ function isBurnedSubtitleTrack(track) {
);
}
function isDuplicateSubtitleTrack(track) {
return Boolean(track?.duplicate);
}
function normalizeSubtitleLanguage(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return 'und';
}
if (raw.length >= 3) {
return raw.slice(0, 3);
}
return raw;
}
function normalizeSubtitleVariantSelectionMap(rawSelection) {
const map = {};
if (!rawSelection || typeof rawSelection !== 'object') {
return map;
}
for (const [language, value] of Object.entries(rawSelection)) {
const normalizedLanguage = normalizeSubtitleLanguage(language);
const full = Boolean(value?.full);
const forced = Boolean(value?.forced);
map[normalizedLanguage] = { full, forced };
}
return map;
}
function normalizeSubtitleLanguageOrder(rawOrder = []) {
const list = Array.isArray(rawOrder) ? rawOrder : [];
const seen = new Set();
const output = [];
for (const value of list) {
const language = normalizeSubtitleLanguage(value);
if (!language || seen.has(language)) {
continue;
}
seen.add(language);
output.push(language);
}
return output;
}
function resolveSubtitleTrackVariantFlags(track) {
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
const isForcedOnly = Boolean(
track?.isForcedOnly
?? track?.subtitlePreviewForcedOnly
?? track?.forcedOnly
?? subtitleType === 'forced'
);
const fullHasForced = !isForcedOnly && Boolean(
track?.fullHasForced
?? track?.subtitleFullHasForced
?? track?.hasForcedVariant
);
return { isForcedOnly, fullHasForced };
}
function buildDefaultSubtitleVariantSelection(subtitleTracks, selectedTracks) {
const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
const selected = Array.isArray(selectedTracks) ? selectedTracks : [];
const orderedTracks = [...tracks].sort((a, b) => {
const aId = normalizeTrackId(a?.id) ?? Number.MAX_SAFE_INTEGER;
const bId = normalizeTrackId(b?.id) ?? Number.MAX_SAFE_INTEGER;
if (aId !== bId) {
return aId - bId;
}
return String(a?.language || a?.languageLabel || '').localeCompare(String(b?.language || b?.languageLabel || ''));
});
const order = [];
const orderSeen = new Set();
for (const track of orderedTracks) {
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!orderSeen.has(language)) {
orderSeen.add(language);
order.push(language);
}
}
const selectionMap = {};
for (const track of selected) {
if (isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track)) {
continue;
}
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!selectionMap[language]) {
selectionMap[language] = { full: false, forced: false };
}
const flags = resolveSubtitleTrackVariantFlags(track);
if (flags.isForcedOnly) {
selectionMap[language].forced = true;
} else {
selectionMap[language].full = true;
}
}
return {
subtitleVariantSelection: selectionMap,
subtitleLanguageOrder: order
};
}
function buildDefaultTrackSelection(review) {
const titles = Array.isArray(review?.titles) ? review.titles : [];
const selection = {};
const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId);
const manualSelection = review?.manualTrackSelection && typeof review.manualTrackSelection === 'object'
? review.manualTrackSelection
: null;
const manualTitleId = normalizeTitleId(manualSelection?.titleId);
for (const title of titles) {
const titleId = normalizeTitleId(title?.id);
@@ -179,16 +296,49 @@ function buildDefaultTrackSelection(review) {
const subtitleSelectionSource = isEncodeInputTitle
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
: subtitleTracks.filter((track) => Boolean(track?.selectedByRule));
const defaultSubtitleVariantSelection = buildDefaultSubtitleVariantSelection(
subtitleTracks,
subtitleSelectionSource
);
const manualSelectionMatchesTitle = Boolean(
isEncodeInputTitle
&& manualSelection
&& (!manualTitleId || manualTitleId === titleId)
);
const manualAudioTrackIds = manualSelectionMatchesTitle
? normalizeTrackIdList(manualSelection?.audioTrackIds || [])
: [];
const manualSubtitleTrackIds = manualSelectionMatchesTitle
? normalizeTrackIdList(
Array.isArray(manualSelection?.subtitleTrackIdsOrdered)
? manualSelection.subtitleTrackIdsOrdered
: manualSelection?.subtitleTrackIds || []
)
: [];
const manualSubtitleVariantSelection = manualSelectionMatchesTitle
? normalizeSubtitleVariantSelectionMap(manualSelection?.subtitleVariantSelection || {})
: {};
const manualSubtitleLanguageOrder = manualSelectionMatchesTitle
? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || [])
: [];
selection[titleId] = {
audioTrackIds: normalizeTrackIdList(
audioSelectionSource.map((track) => track?.id)
),
subtitleTrackIds: normalizeTrackIdList(
audioTrackIds: manualAudioTrackIds.length > 0
? manualAudioTrackIds
: normalizeTrackIdList(audioSelectionSource.map((track) => track?.id)),
subtitleTrackIds: manualSubtitleTrackIds.length > 0
? manualSubtitleTrackIds
: normalizeTrackIdList(
subtitleSelectionSource
.filter((track) => !isBurnedSubtitleTrack(track))
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => track?.id)
)
),
subtitleVariantSelection: Object.keys(manualSubtitleVariantSelection).length > 0
? manualSubtitleVariantSelection
: defaultSubtitleVariantSelection.subtitleVariantSelection,
subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0
? manualSubtitleLanguageOrder
: defaultSubtitleVariantSelection.subtitleLanguageOrder
};
}
@@ -197,7 +347,12 @@ function buildDefaultTrackSelection(review) {
function defaultTrackSelectionForTitle(review, titleId) {
const defaults = buildDefaultTrackSelection(review);
return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] };
return defaults[titleId] || defaults[String(titleId)] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
}
function buildSettingsMap(categories) {
@@ -299,6 +454,7 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
}
export default function PipelineStatusCard({
jobId = null,
pipeline,
onAnalyze,
onReanalyze,
@@ -321,7 +477,9 @@ export default function PipelineStatusCard({
const stateLabel = getStatusLabel(state);
const progress = Number(pipeline?.progress || 0);
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
const retryJobId = pipeline?.context?.jobId;
const explicitJobId = normalizeJobId(jobId);
const contextJobId = normalizeJobId(pipeline?.context?.jobId);
const retryJobId = explicitJobId || contextJobId;
const queueLocked = Boolean(isQueued && retryJobId);
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
@@ -435,7 +593,12 @@ export default function PipelineStatusCard({
}
const defaults = buildDefaultTrackSelection(mediaInfoReview);
const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] };
const fallback = defaults[currentTitleId] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
return {
...prev,
[currentTitleId]: fallback
@@ -639,7 +802,12 @@ export default function PipelineStatusCard({
: null;
const fallbackSelection = encodeTitleId
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
: { audioTrackIds: [], subtitleTrackIds: [] };
: {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
const effectiveSelection = selectionEntry || fallbackSelection;
const encodeTitle = encodeTitleId
? (Array.isArray(mediaInfoReview?.titles)
@@ -648,17 +816,39 @@ export default function PipelineStatusCard({
: null;
const blockedSubtitleTrackIds = new Set(
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
.filter((track) => isBurnedSubtitleTrack(track))
.filter((track) => isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const availableSubtitleLanguages = new Set(
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'))
);
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(effectiveSelection?.subtitleVariantSelection || {});
const filteredVariantSelection = {};
for (const [language, value] of Object.entries(normalizedVariantSelection)) {
if (!availableSubtitleLanguages.has(language)) {
continue;
}
filteredVariantSelection[language] = {
full: Boolean(value?.full),
forced: Boolean(value?.forced)
};
}
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(effectiveSelection?.subtitleLanguageOrder || [])
.filter((language) => availableSubtitleLanguages.has(language));
const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0;
const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
.filter((id) => !blockedSubtitleTrackIds.has(String(id)));
const selectedTrackSelection = encodeTitleId
? {
[encodeTitleId]: {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
.filter((id) => !blockedSubtitleTrackIds.has(String(id)))
subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds,
subtitleVariantSelection: filteredVariantSelection,
subtitleLanguageOrder: normalizedLanguageOrder
}
}
: null;
@@ -1032,7 +1222,9 @@ export default function PipelineStatusCard({
setTrackSelectionByTitle((prev) => {
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
audioTrackIds: [],
subtitleTrackIds: []
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
const existing = normalizeTrackIdList(current?.[key] || []);
@@ -1049,6 +1241,53 @@ export default function PipelineStatusCard({
};
});
}}
onSubtitleVariantSelectionChange={(titleId, language, variant, checked) => {
const normalizedTitleId = normalizeTitleId(titleId);
const normalizedLanguage = normalizeSubtitleLanguage(language);
const normalizedVariant = String(variant || '').trim().toLowerCase() === 'forced' ? 'forced' : 'full';
if (!normalizedTitleId || !normalizedLanguage) {
return;
}
setTrackSelectionByTitle((prev) => {
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {});
const nextMap = { ...currentMap };
const currentEntry = nextMap[normalizedLanguage] || { full: false, forced: false };
nextMap[normalizedLanguage] = {
...currentEntry,
[normalizedVariant]: Boolean(checked)
};
const existingOrder = normalizeSubtitleLanguageOrder(current?.subtitleLanguageOrder || []);
const fallbackOrder = normalizeSubtitleLanguageOrder(
defaultTrackSelectionForTitle(mediaInfoReview, normalizedTitleId)?.subtitleLanguageOrder || []
);
let nextOrder = existingOrder.length > 0
? existingOrder
: fallbackOrder;
if (nextOrder.length === 0) {
nextOrder = [normalizedLanguage];
} else if (!nextOrder.includes(normalizedLanguage) && fallbackOrder.includes(normalizedLanguage)) {
nextOrder = fallbackOrder;
} else if (!nextOrder.includes(normalizedLanguage)) {
nextOrder = [...nextOrder, normalizedLanguage];
}
return {
...prev,
[normalizedTitleId]: {
...current,
subtitleVariantSelection: nextMap,
subtitleLanguageOrder: nextOrder
}
};
});
}}
availableScripts={scriptCatalog}
availableChains={chainCatalog}
preEncodeItems={preEncodeItems}
+383 -83
View File
@@ -1,30 +1,51 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
import { Divider } from 'primereact/divider';
import { Badge } from 'primereact/badge';
import { Divider } from 'primereact/divider';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { RadioButton } from 'primereact/radiobutton';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import ConverterFileExplorer from '../components/ConverterFileExplorer';
import ConverterUploadPanel from '../components/ConverterUploadPanel';
import ConverterJobCard from '../components/ConverterJobCard';
import JobDetailDialog from '../components/JobDetailDialog';
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.ts', '.m2ts', '.iso', '.m4v', '.flv', '.webm']);
function isAudioEntry(e) {
if (e.detectedMediaType === 'audio') return true;
const p = (e.relPath || '').toLowerCase();
const dot = p.lastIndexOf('.');
if (dot === -1) return false;
return AUDIO_EXTS.has(p.slice(dot));
}
function isVideoEntry(e) {
if (e.detectedMediaType === 'video' || e.detectedMediaType === 'iso') return true;
const p = (e.relPath || '').toLowerCase();
const dot = p.lastIndexOf('.');
if (dot === -1) return false;
return VIDEO_EXTS.has(p.slice(dot));
}
function normalizeJobId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
}
function parseConverterPlan(job) {
try {
return JSON.parse(job?.encode_plan_json || '{}');
} catch (_err) {
return {};
}
}
export default function ConverterPage() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
@@ -35,7 +56,20 @@ export default function ConverterPage() {
const [selectedEntries, setSelectedEntries] = useState([]);
const [jobModeVisible, setJobModeVisible] = useState(false);
const [audioMode, setAudioMode] = useState('individual');
const [jobModalAction, setJobModalAction] = useState('create');
const [assignTargetJobId, setAssignTargetJobId] = useState(null);
const [creatingJobs, setCreatingJobs] = useState(false);
const [jobEntries, setJobEntries] = useState([]);
const [detailVisible, setDetailVisible] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedJob, setSelectedJob] = useState(null);
const [logLoadingMode, setLogLoadingMode] = useState(null);
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
const [cancelDetailBusy, setCancelDetailBusy] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined);
// Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click
// die Selection nicht löscht bevor die Jobs erstellt werden
const jobEntriesRef = useRef([]);
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
@@ -55,7 +89,8 @@ export default function ConverterPage() {
return () => clearInterval(interval);
}, [loadJobs]);
useWebSocket((message) => {
useWebSocket({
onMessage: (message) => {
if (!message?.type || !message?.payload) return;
if (message.type === 'PIPELINE_PROGRESS') {
@@ -74,34 +109,71 @@ export default function ConverterPage() {
}
}
if (message.type === 'PIPELINE_UPDATE' || message.type === 'CONVERTER_SCAN_UPDATE') {
if (
message.type === 'PIPELINE_UPDATE' ||
message.type === 'PIPELINE_STATE_CHANGED' ||
message.type === 'CONVERTER_SCAN_UPDATE'
) {
loadJobs();
if (message.type === 'CONVERTER_SCAN_UPDATE') {
if (message.type === 'CONVERTER_SCAN_UPDATE' || message.type === 'PIPELINE_STATE_CHANGED') {
setExplorerRefreshToken((t) => t + 1);
}
}
}
});
const assignableJobs = useMemo(() => (
jobs
.map((job) => ({ job, plan: parseConverterPlan(job) }))
.filter(({ job, plan }) => {
const status = String(job?.status || '').trim().toUpperCase();
const mediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
return status === 'READY_TO_START' && mediaType === 'audio' && !Boolean(plan?.isFolder);
})
.map(({ job, plan }) => {
const rawTitle = String(job?.title || '').trim();
const title = rawTitle ? `#${job.id} | ${rawTitle}` : `#${job.id}`;
const fileCount = Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0
? plan.inputPaths.length
: (plan?.inputPath ? 1 : 0);
return {
label: `${title}${fileCount > 0 ? ` (${fileCount} Datei${fileCount !== 1 ? 'en' : ''})` : ''}`,
value: job.id
};
})
), [jobs]);
const handleSelectionChange = (entries) => setSelectedEntries(entries || []);
const handleOpenJobModal = () => {
const hasAudio = selectedEntries.some(isAudioEntry);
if (!hasAudio) {
// Keine Audio-Dateien (nur Videos/Ordner) kein Modal, direkt Einzeljobs
// Explorer expandiert Ordner bereits zu Einzel-Dateien
const entries = [...selectedEntries];
jobEntriesRef.current = entries;
setJobEntries(entries);
const hasAudio = entries.some(isAudioEntry);
const hasAssignableJobs = assignableJobs.length > 0;
if (!hasAudio && !hasAssignableJobs) {
// Nur Videos oder sonstige Dateien direkt individual erstellen
handleCreateJobsFromSelection('individual');
return;
}
setAudioMode('individual');
setJobModalAction('create');
setAssignTargetJobId(assignableJobs[0]?.value || null);
setJobModeVisible(true);
};
const handleCreateJobsFromSelection = async (explicitMode) => {
if (selectedEntries.length === 0) return;
const handleCreateJobsFromSelection = async (explicitAudioMode) => {
const entries = jobEntriesRef.current;
if (entries.length === 0) return;
const resolvedAudioMode = typeof explicitAudioMode === 'string' ? explicitAudioMode : audioMode;
setCreatingJobs(true);
setJobModeVisible(false);
try {
const relPaths = selectedEntries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterCreateJobsFromSelection(relPaths, explicitMode ?? audioMode);
const relPaths = entries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterCreateJobsFromSelection(relPaths, resolvedAudioMode);
const newJobs = result?.jobs || [];
toastRef.current?.show({
severity: 'success',
@@ -109,6 +181,8 @@ export default function ConverterPage() {
detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`,
life: 3500
});
jobEntriesRef.current = [];
setJobEntries([]);
setSelectedEntries([]);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
@@ -124,10 +198,54 @@ export default function ConverterPage() {
}
};
const handleAssignSelectionToJob = async (targetJobId) => {
const entries = jobEntriesRef.current;
if (entries.length === 0) return;
const normalizedJobId = Number(targetJobId);
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) return;
setCreatingJobs(true);
setJobModeVisible(false);
try {
const relPaths = entries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterAssignFilesToJob(normalizedJobId, relPaths);
const addedCount = Array.isArray(result?.addedRelPaths) ? result.addedRelPaths.length : 0;
toastRef.current?.show({
severity: 'success',
summary: 'Dateien zugewiesen',
detail: addedCount > 0
? `${addedCount} Datei${addedCount !== 1 ? 'en' : ''} zu Job #${normalizedJobId} hinzugefügt.`
: `Keine neuen Dateien zu Job #${normalizedJobId} hinzugefügt.`,
life: 3600
});
jobEntriesRef.current = [];
setJobEntries([]);
setSelectedEntries([]);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Dateien konnten dem Job nicht zugewiesen werden.',
life: 4600
});
} finally {
setCreatingJobs(false);
}
};
const handleJobModeConfirm = async ({ action, audioMode: selectedAudioMode, jobId }) => {
if (action === 'assign') {
await handleAssignSelectionToJob(jobId || assignTargetJobId);
return;
}
await handleCreateJobsFromSelection(selectedAudioMode || audioMode);
};
const handleUploaded = (folders) => {
setExplorerRefreshToken((t) => t + 1);
if (folders?.length > 0) {
// Explorer in den ersten hochgeladenen Ordner navigieren
setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() });
toastRef.current?.show({
severity: 'success',
@@ -148,6 +266,135 @@ export default function ConverterPage() {
});
};
const handleOpenJobDetails = async (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) {
return;
}
setSelectedJob({
...row,
logs: [],
log: '',
logMeta: {
loaded: false,
total: Number(row?.log_count || 0),
returned: 0,
truncated: false
}
});
setDetailVisible(true);
setDetailLoading(true);
try {
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Details konnten nicht geladen werden',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setDetailLoading(false);
}
};
const handleLoadDetailLog = async (job, mode = 'tail') => {
const jobId = Number(job?.id || selectedJob?.id || 0);
if (!jobId) {
return;
}
setLogLoadingMode(mode);
try {
const response = await api.getJob(jobId, {
includeLogs: true,
includeAllLogs: mode === 'all',
logTailLines: mode === 'all' ? null : 800
});
setSelectedJob(response.job);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Log konnte nicht geladen werden',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setLogLoadingMode(null);
}
};
const handleDeleteDetailEntry = async (job) => {
const jobId = Number(job?.id || 0);
if (!jobId) {
return;
}
const confirmed = window.confirm(`Historieneintrag Job #${jobId} löschen?`);
if (!confirmed) {
return;
}
setDeleteEntryBusy(true);
try {
await api.deleteConverterJob(jobId);
handleJobDeleted(jobId);
setDetailVisible(false);
setSelectedJob(null);
toastRef.current?.show({
severity: 'success',
summary: 'Eintrag gelöscht',
detail: `Job #${jobId} wurde entfernt.`,
life: 3200
});
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setDeleteEntryBusy(false);
}
};
const handleCancelDetailJob = async (job) => {
const jobId = Number(job?.id || selectedJob?.id || 0);
if (!jobId) {
return;
}
const confirmed = window.confirm(`Job #${jobId} abbrechen?`);
if (!confirmed) {
return;
}
setCancelDetailBusy(true);
try {
await api.cancelConverterJob(jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Abbruch gesendet',
detail: `Job #${jobId} wird abgebrochen.`,
life: 2800
});
await loadJobs();
if (detailVisible) {
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Abbruch fehlgeschlagen',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setCancelDetailBusy(false);
}
};
const handleJobDeleted = (jobId) => {
setJobs((prev) => prev.filter((j) => j.id !== jobId));
setJobProgress((prev) => {
@@ -155,12 +402,32 @@ export default function ConverterPage() {
delete next[jobId];
return next;
});
setExplorerRefreshToken((t) => t + 1);
if (Number(selectedJob?.id || 0) === Number(jobId || 0)) {
setDetailVisible(false);
setSelectedJob(null);
setDetailLoading(false);
setLogLoadingMode(null);
}
};
const handleJobCancelled = () => setTimeout(loadJobs, 1000);
const handleJobInputsChanged = async (_jobId, _payload) => {
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
};
const activeJobs = jobs.filter((j) => !['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const finishedJobs = jobs.filter((j) => ['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const activeJobs = jobs.filter((j) => !['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
// Auto-expand: ersten Job aufklappen, wenn keiner expanded ist (außer user hat explizit zugeklappt)
useEffect(() => {
const normalizedExpanded = Number(expandedJobId) || null;
const hasExpanded = activeJobs.some((j) => Number(j.id) === normalizedExpanded);
if (hasExpanded) return;
if (expandedJobId === null) return; // explizit vom User zugeklappt
if (activeJobs.length === 0) return;
setExpandedJobId(Number(activeJobs[0].id));
}, [activeJobs, expandedJobId]);
const jobsCardHeader = (
<div className="converter-card-header">
@@ -192,6 +459,7 @@ export default function ConverterPage() {
onSelectionChange={handleSelectionChange}
refreshToken={explorerRefreshToken}
navigateToPath={explorerNavigateTo}
onAssignmentChanged={loadJobs}
/>
{selectedEntries.length > 0 && (
<div className="converter-selection-bar">
@@ -210,46 +478,28 @@ export default function ConverterPage() {
{/* Jobs */}
<Card header={jobsCardHeader}>
{jobs.length === 0 ? (
{activeJobs.length === 0 ? (
<p className="converter-jobs-empty-hint">
<small>Keine Jobs vorhanden. Dateien im Import-Ordner auswählen oder per Upload hochladen.</small>
<small>Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.</small>
</p>
) : (
<>
{activeJobs.length > 0 && (
<>
<p className="converter-jobs-group-label">Aktiv</p>
<div className="converter-jobs-list">
<div className="dashboard-job-list converter-jobs-list">
{activeJobs.map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={jobProgress[job.id] || null}
isExpanded={Number(job.id) === Number(expandedJobId)}
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onStarted={handleJobStarted}
onDeleted={handleJobDeleted}
onCancelled={handleJobCancelled}
onOpenDetails={handleOpenJobDetails}
onInputsChanged={handleJobInputsChanged}
/>
))}
</div>
</>
)}
{activeJobs.length > 0 && finishedJobs.length > 0 && <Divider />}
{finishedJobs.length > 0 && (
<>
<p className="converter-jobs-group-label">Abgeschlossen</p>
<div className="converter-jobs-list">
{finishedJobs.slice(0, 20).map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={null}
onDeleted={handleJobDeleted}
/>
))}
</div>
</>
)}
</>
)}
</Card>
@@ -260,39 +510,83 @@ export default function ConverterPage() {
<JobModeDialog
visible={jobModeVisible}
entries={selectedEntries}
entries={jobEntries}
audioMode={audioMode}
onAudioModeChange={setAudioMode}
action={jobModalAction}
onActionChange={setJobModalAction}
assignableJobs={assignableJobs}
assignJobId={assignTargetJobId}
onAssignJobIdChange={setAssignTargetJobId}
busy={creatingJobs}
onConfirm={handleCreateJobsFromSelection}
onConfirm={handleJobModeConfirm}
onHide={() => setJobModeVisible(false)}
/>
<JobDetailDialog
visible={detailVisible}
job={selectedJob}
detailLoading={detailLoading}
onLoadLog={handleLoadDetailLog}
logLoadingMode={logLoadingMode}
onCancel={handleCancelDetailJob}
cancelBusy={cancelDetailBusy}
onDeleteEntry={handleDeleteDetailEntry}
deleteEntryBusy={deleteEntryBusy}
onHide={() => {
setDetailVisible(false);
setSelectedJob(null);
setDetailLoading(false);
setLogLoadingMode(null);
setDeleteEntryBusy(false);
setCancelDetailBusy(false);
}}
/>
</div>
);
}
// Job-Modus-Dialog
function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, onConfirm, onHide }) {
function JobModeDialog({
visible,
entries,
audioMode,
onAudioModeChange,
action,
onActionChange,
assignableJobs,
assignJobId,
onAssignJobIdChange,
busy,
onConfirm,
onHide
}) {
const audioEntries = entries.filter(isAudioEntry);
const nonAudioEntries = entries.filter((e) => !isAudioEntry(e));
const hasAudio = audioEntries.length > 0;
const hasNonAudio = nonAudioEntries.length > 0;
const onlyAudio = hasAudio && !hasNonAudio;
const mixed = hasAudio && hasNonAudio;
const videoEntries = entries.filter(isVideoEntry);
const otherEntries = entries.filter((e) => !isAudioEntry(e) && !isVideoEntry(e));
// Anzahl der Jobs die entstehen werden
const hasAudio = audioEntries.length > 0;
const hasVideo = videoEntries.length > 0;
const hasOther = otherEntries.length > 0;
const hasAssignableJobs = Array.isArray(assignableJobs) && assignableJobs.length > 0;
// Videos immer individual, audio per Modus
const audioJobCount = audioMode === 'shared' ? 1 : audioEntries.length;
const totalJobs = nonAudioEntries.length + audioJobCount;
const totalJobs = videoEntries.length + otherEntries.length + audioJobCount;
const isAssignMode = action === 'assign';
const confirmLabel = isAssignMode
? 'Dateien zuweisen'
: `${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`;
const footer = (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
<Button
label={`${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`}
label={confirmLabel}
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
disabled={busy}
onClick={onConfirm}
disabled={busy || (isAssignMode && !assignJobId)}
onClick={() => onConfirm({ action, audioMode, jobId: assignJobId })}
/>
</div>
);
@@ -306,26 +600,27 @@ function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, o
style={{ width: '440px' }}
modal
>
{/* Nur Videos / Ordner gewählt */}
{!hasAudio && (
<p style={{ margin: 0, lineHeight: 1.6 }}>
Für jede ausgewählte Datei / jeden Ordner wird ein eigener Job angelegt.
<br />
<strong>{nonAudioEntries.length} Job{nonAudioEntries.length !== 1 ? 's' : ''}</strong> werden erstellt.
{/* Videos / Sonstige immer individual */}
{(hasVideo || hasOther) && (
<p style={{ marginTop: 0, marginBottom: hasAudio ? 8 : 0, lineHeight: 1.6 }}>
<strong>Videos / Sonstige ({videoEntries.length + otherEntries.length}):</strong> Je eine Datei ein eigener Job.
</p>
)}
{/* Nur Audio gewählt */}
{onlyAudio && (
{hasAudio && (hasVideo || hasOther) && <Divider style={{ margin: '8px 0' }} />}
{/* Audio: Abfrage */}
{hasAudio && (
<div>
<p style={{ marginTop: 0, marginBottom: 12 }}>
Wie sollen die <strong>{audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''}</strong> verarbeitet werden?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, opacity: isAssignMode ? 0.6 : 1 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
disabled={isAssignMode}
onChange={() => onAudioModeChange('individual')}
/>
<span>Für jede Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
@@ -334,6 +629,7 @@ function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, o
<RadioButton
value="shared"
checked={audioMode === 'shared'}
disabled={isAssignMode}
onChange={() => onAudioModeChange('shared')}
/>
<span>Ein gemeinsamer Job für alle Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
@@ -342,35 +638,39 @@ function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, o
</div>
)}
{/* Gemischte Auswahl */}
{mixed && (
{hasAssignableJobs && (
<>
<Divider style={{ margin: '12px 0' }} />
<div>
<p style={{ marginTop: 0, marginBottom: 8 }}>
<strong>Videos/Ordner ({nonAudioEntries.length}):</strong> Für jede Datei ein eigener Job.
<p style={{ marginTop: 0, marginBottom: 10 }}>
Optional: Dateien einem <strong>nicht gestarteten Job</strong> direkt zuweisen.
</p>
<Divider style={{ margin: '8px 0' }} />
<p style={{ marginBottom: 10 }}>
<strong>Audiodateien ({audioEntries.length}):</strong> Wie verarbeiten?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
onChange={() => onAudioModeChange('individual')}
value="create"
checked={!isAssignMode}
onChange={() => onActionChange('create')}
/>
<span>Für jede Audio-Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
<span>Neue Jobs anlegen</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
<RadioButton
value="shared"
checked={audioMode === 'shared'}
onChange={() => onAudioModeChange('shared')}
value="assign"
checked={isAssignMode}
onChange={() => onActionChange('assign')}
/>
<span>Ein gemeinsamer Job für alle Audio-Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
<span>Zu bestehendem Job zuweisen</span>
</label>
<Dropdown
value={assignJobId}
options={assignableJobs}
onChange={(e) => onAssignJobIdChange(e.value)}
placeholder="Job auswählen …"
disabled={!isAssignMode}
style={{ width: '100%' }}
/>
</div>
</div>
</>
)}
</Dialog>
);
+32 -5
View File
@@ -832,9 +832,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const existingContext = currentPipeline?.context && typeof currentPipeline.context === 'object'
? currentPipeline.context
: {};
return {
...currentPipeline,
context: {
const mergedCurrentContext = {
...computedContext,
...existingContext,
rawPath: existingContext.rawPath || computedContext.rawPath,
@@ -847,7 +845,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
canRestartReviewFromRaw:
existingContext.canRestartReviewFromRaw ?? computedContext.canRestartReviewFromRaw
}
};
// The card always represents `job.id`; never let stale live context override it.
mergedCurrentContext.jobId = jobId;
return {
...currentPipeline,
context: mergedCurrentContext
};
}
@@ -878,6 +881,8 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
}
: computedContext;
// The card always represents `job.id`; never let stale live context override it.
mergedContext.jobId = jobId;
return {
state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
@@ -1073,6 +1078,21 @@ export default function DashboardPage({
setQueueState(normalizeQueue(queueResponse.value?.queue));
}
const shouldDisplayOnDashboard = (job) => {
const rawMediaType = String(job?.media_type || '').trim().toLowerCase();
const resolvedMediaType = String(job?.mediaType || '').trim().toLowerCase();
const planMediaProfile = String(job?.encodePlan?.mediaProfile || '').trim().toLowerCase();
const converterPlanType = String(job?.encodePlan?.converterMediaType || '').trim().toLowerCase();
const isConverterJob = (
rawMediaType === 'converter'
|| resolvedMediaType === 'converter'
|| planMediaProfile === 'converter'
|| converterPlanType === 'audio'
|| converterPlanType === 'video'
|| converterPlanType === 'iso'
);
if (isConverterJob) {
return false;
}
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
if (!dashboardStatuses.has(normalizedStatus)) {
return false;
@@ -1980,7 +2000,7 @@ export default function DashboardPage({
setJobBusy(normalizedJobId, true);
try {
const response = await api.restartReviewFromRaw(normalizedJobId);
const response = await api.restartReviewFromRaw(normalizedJobId, { reuseCurrentJob: true });
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
@@ -2935,6 +2955,7 @@ export default function DashboardPage({
})()}
{!isCdJob && !isAudiobookJob ? (
<PipelineStatusCard
jobId={jobId}
pipeline={pipelineForJob}
onAnalyze={handleAnalyze}
onReanalyze={handleReanalyze}
@@ -3041,6 +3062,9 @@ export default function DashboardPage({
const hasScriptSummary = hasQueueScriptSummary(item);
const detailKey = buildRunningQueueScriptKey(item?.jobId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
const jobProgressEntry = pipeline?.jobProgress?.[String(item.jobId)] || null;
const rawQueueProgress = Number(jobProgressEntry?.progress ?? 0);
const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
return (
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
<div className="pipeline-queue-item running queue-job-item">
@@ -3051,6 +3075,9 @@ export default function DashboardPage({
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
</strong>
<small>{getStatusLabel(item.status)}</small>
{clampedQueueProgress > 0 && (
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
)}
</div>
{hasScriptSummary ? (
<button
+41 -3
View File
@@ -412,6 +412,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
const [pendingRestartRow, setPendingRestartRow] = useState(null);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
@@ -589,6 +590,25 @@ export default function HistoryPage({ refreshToken = 0 }) {
setSelectedJob(response.job);
};
const refreshDetailAfterReplacement = async (sourceJobId, replacementJobId = null) => {
if (!detailVisible) {
return;
}
const sourceId = Number(sourceJobId || 0);
const replacementId = Number(replacementJobId || 0);
const selectedId = Number(selectedJob?.id || 0);
const hasReplacement = replacementId > 0 && replacementId !== sourceId;
if (hasReplacement && selectedId === sourceId) {
const response = await api.getJob(replacementId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
return;
}
await refreshDetailIfOpen(hasReplacement ? replacementId : sourceId);
};
// Conflict Modal Helpers
const mergeOutputFoldersForJob = (job, folders = []) => {
@@ -681,13 +701,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
try {
const response = await api.restartEncodeWithLastSettings(job.id, options);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
if (result.queued) {
toastRef.current?.show({ severity: 'info', summary: 'Encode-Neustart in Queue', detail: result.queuePosition > 0 ? `Position ${result.queuePosition}` : 'In der Warteschlange eingeplant.', life: 3500 });
} else {
toastRef.current?.show({ severity: 'success', summary: 'Encode-Neustart gestartet', detail: 'Letzte bestätigte Einstellungen werden verwendet.', life: 3500 });
}
await load();
await refreshDetailIfOpen(job.id);
await refreshDetailAfterReplacement(job.id, replacementJobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
@@ -703,7 +724,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
setActionBusy(true);
try {
await api.restartReviewFromRaw(job.id, options);
const response = await api.restartReviewFromRaw(job.id, options);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Review-Neustart',
@@ -711,7 +734,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
life: 3500
});
await load();
await refreshDetailIfOpen(job.id);
await refreshDetailAfterReplacement(job.id, replacementJobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
@@ -938,6 +961,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const handleReencode = async (row) => {
const mediaType = resolveMediaType(row);
if (mediaType === 'bluray' || mediaType === 'dvd') {
setPendingRestartRow(row);
handleAssignOmdb(row);
return;
}
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
return;
}
@@ -1044,6 +1073,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
setMetadataDialogContext(null);
await load();
await refreshDetailIfOpen(payload.jobId);
const rowToRestart = pendingRestartRow;
if (rowToRestart && Number(rowToRestart.id) === Number(payload.jobId)) {
setPendingRestartRow(null);
if (!(await maybeOpenOutputConflictModal(rowToRestart, 'reencode'))) {
await executeHistoryAction(rowToRestart, 'reencode');
}
}
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
} finally {
@@ -1769,6 +1806,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
onHide={() => {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
setPendingRestartRow(null);
}}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
+140 -18
View File
@@ -2575,6 +2575,16 @@ body {
align-items: center;
}
@media (max-width: 1150px) and (min-width: 901px) {
.history-dv-toolbar {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto auto;
}
.history-dv-toolbar > *:first-child {
grid-column: 1 / -1;
}
}
.history-dv-layout-toggle {
display: flex;
justify-content: flex-end;
@@ -3475,6 +3485,23 @@ body {
gap: 0.15rem;
}
.subtitle-footnote-list {
display: grid;
gap: 0.2rem;
margin-top: 0.2rem;
width: 100%;
font-size: 0.72rem;
}
.subtitle-footnote-list .track-action-note {
margin-left: 0;
display: block;
width: 100%;
max-width: none;
line-height: 1.3;
font-size: inherit;
}
.track-action-note {
margin-left: 1.7rem;
color: var(--rip-muted);
@@ -4929,7 +4956,7 @@ body {
.explorer {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-columns: 320px 1fr;
grid-template-rows: 1fr auto;
gap: 0;
border: 1px solid var(--rip-border, #d9bc8d);
@@ -5070,7 +5097,7 @@ body {
.explorer-row {
display: grid;
grid-template-columns: 28px 2fr repeat(2, minmax(80px, 1fr));
grid-template-columns: 28px 2fr minmax(90px, 0.8fr) minmax(90px, 0.8fr) minmax(170px, 1.4fr);
gap: 10px;
align-items: center;
padding: 8px 12px;
@@ -5086,6 +5113,14 @@ body {
border-left: 3px solid var(--primary-color);
padding-left: 9px;
}
.explorer-row.row-active {
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
outline: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent);
outline-offset: -1px;
}
.explorer-row.row-active.selected {
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
}
.explorer-row.header {
font-size: 11px;
text-transform: uppercase;
@@ -5135,6 +5170,39 @@ body {
}
.explorer-row.header .row-checkbox { pointer-events: none; }
.row-job {
min-width: 0;
text-align: left;
}
.row-job-assignment {
display: inline-block;
max-width: 100%;
font-size: 0.74rem;
color: var(--primary-color, #8e4d2d);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-job-empty {
font-size: 0.74rem;
color: var(--rip-muted, #6a4d38);
}
.explorer-row.row-locked {
opacity: 0.7;
cursor: default;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 6px,
rgba(0,0,0,0.025) 6px,
rgba(0,0,0,0.025) 12px
) !important;
}
.footer-count {
font-weight: 600;
color: var(--rip-ink, #2f180f);
@@ -5216,6 +5284,20 @@ body {
}
.tree-caret.disabled { width: 18px; flex: 0 0 18px; }
.tree-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 18px;
}
.tree-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--primary-color);
border-radius: 3px;
}
.tree-icon.folder { color: var(--rip-brown-700, #6f3922); }
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -5293,9 +5375,7 @@ body {
/* ===== Job Card ===== */
.converter-jobs-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.6rem;
}
.converter-jobs-empty {
@@ -5305,15 +5385,16 @@ body {
.converter-job-card {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 8px;
padding: 0.75rem 1rem;
background: var(--rip-panel, #fffaf1);
border-radius: 0.6rem;
padding: 0.6rem 0.7rem;
background: var(--rip-panel-soft, #fdf5e7);
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.6rem;
}
.converter-job-card.status-done {
.converter-job-card.status-done,
.converter-job-card.status-finished {
border-color: var(--green-300, #74c69d);
background: #f6fff9;
}
@@ -5330,7 +5411,7 @@ body {
.converter-job-card-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.3rem;
}
.converter-job-card-title-row {
@@ -5342,7 +5423,7 @@ body {
.converter-job-card-title {
font-weight: 600;
font-size: 0.9rem;
font-size: 0.92rem;
flex: 1;
min-width: 0;
overflow: hidden;
@@ -5351,7 +5432,7 @@ body {
}
.converter-job-card-meta {
font-size: 0.78rem;
font-size: 0.8rem;
color: var(--rip-muted, #6a4d38);
}
@@ -5364,8 +5445,10 @@ body {
.converter-job-card-progress-meta {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
font-size: 0.78rem;
color: var(--rip-muted, #6a4d38);
gap: 0.5rem;
flex-wrap: wrap;
}
.converter-job-card-error {
@@ -5377,7 +5460,7 @@ body {
}
.converter-job-card-output {
font-size: 0.78rem;
font-size: 0.8rem;
color: var(--rip-muted, #6a4d38);
}
@@ -5385,13 +5468,13 @@ body {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.25rem;
margin-top: 0.1rem;
}
/* Inline-Konfiguration für READY_TO_START Jobs */
.converter-job-card.is-ready {
border-color: var(--amber-400, #f59e0b);
background: var(--surface-50, #fafaf9);
border-color: #d9b26d;
background: #fff7e8;
}
.cjc-config {
@@ -5438,3 +5521,42 @@ body {
justify-content: flex-end;
padding-top: 0.25rem;
}
.cjc-section-label {
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--rip-muted, #6a4d38);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.converter-mb-results {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 6px;
overflow-y: auto;
max-height: 12rem;
margin-bottom: 0.75rem;
}
.converter-mb-result-row {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--rip-border, #d9bc8d);
transition: background 0.12s;
}
.converter-mb-result-row:last-child {
border-bottom: none;
}
.converter-mb-result-row:hover {
background: var(--surface-100, #f5f0eb);
}
.converter-mb-result-row.selected {
background: rgba(142, 77, 45, 0.1);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.12.0-15",
"version": "0.12.0-16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.12.0-15",
"version": "0.12.0-16",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.12.0-15",
"version": "0.12.0-16",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",