0.12.0-16 misc fixes
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0-15",
|
||||
"version": "0.12.0-16",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2306
-158
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-15",
|
||||
"version": "0.12.0-16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
<strong>
|
||||
{jobUnassignTarget?.jobTitle || (jobUnassignTarget?.jobId ? `#${jobUnassignTarget.jobId}` : '-')}
|
||||
</strong>
|
||||
entfernt werden?
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
{/* Neuer Ordner */}
|
||||
<Dialog
|
||||
header="Neuen Ordner erstellen"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user