0.12.0-16 misc fixes
This commit is contained in:
@@ -41,7 +41,12 @@
|
|||||||
"Read(//opt/**)",
|
"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, 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 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",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const path = require('path');
|
|||||||
const { SourcePlugin } = require('./PluginBase');
|
const { SourcePlugin } = require('./PluginBase');
|
||||||
const { spawnTrackedProcess } = require('../services/processRunner');
|
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||||
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
|
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 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']);
|
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');
|
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);
|
args.push(outputFile);
|
||||||
return { cmd: ffmpegCmd, args };
|
return { cmd: ffmpegCmd, args };
|
||||||
}
|
}
|
||||||
@@ -345,12 +359,110 @@ class ConverterPlugin extends SourcePlugin {
|
|||||||
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
|
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
|
||||||
const formatOptions = encodePlan.audioFormatOptions || {};
|
const formatOptions = encodePlan.audioFormatOptions || {};
|
||||||
const isFolder = Boolean(encodePlan.isFolder);
|
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)) {
|
if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) {
|
||||||
throw new Error(`Unbekanntes Audio-Ausgabeformat: ${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
|
// Ordner-Modus: alle Audio-Dateien im Ordner konvertieren
|
||||||
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
|
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
|
||||||
|
|
||||||
@@ -373,15 +485,21 @@ class ConverterPlugin extends SourcePlugin {
|
|||||||
const fileName = audioFiles[i];
|
const fileName = audioFiles[i];
|
||||||
const inputFile = path.join(inputPath, fileName);
|
const inputFile = path.join(inputPath, fileName);
|
||||||
const baseName = path.basename(fileName, path.extname(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(
|
ctx.emitProgress(
|
||||||
Math.round((i / audioFiles.length) * 100),
|
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({
|
await _spawnAndWait({
|
||||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||||
@@ -409,7 +527,11 @@ class ConverterPlugin extends SourcePlugin {
|
|||||||
});
|
});
|
||||||
ctx.emitProgress(0, `Audio: Encoding läuft …`);
|
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({
|
await _spawnAndWait({
|
||||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
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) ───────────────────────────────
|
// ── Datei-Operationen (reines FS, keine DB) ───────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -299,7 +383,7 @@ router.post(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
logger.info('post:jobs:cancel', { jobId });
|
logger.info('post:jobs:cancel', { jobId });
|
||||||
const result = await pipelineService.cancelJob(jobId);
|
const result = await pipelineService.cancel(jobId);
|
||||||
res.json({ result });
|
res.json({ result });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -313,7 +397,8 @@ router.delete(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
logger.info('delete:jobs', { 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 });
|
res.json({ ok: true });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -385,8 +385,15 @@ router.post(
|
|||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
const keepBoth = Boolean(req.body?.keepBoth);
|
const keepBoth = Boolean(req.body?.keepBoth);
|
||||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||||
logger.info('post:restart-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob);
|
||||||
const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders });
|
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 });
|
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.
|
* Rip and encode a CD.
|
||||||
*
|
*
|
||||||
@@ -458,10 +487,12 @@ async function ripAndEncode(options) {
|
|||||||
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
|
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
|
||||||
|
|
||||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||||
|
const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null;
|
||||||
|
|
||||||
if (skipRip) {
|
if (skipRip) {
|
||||||
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
|
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
|
||||||
for (const track of tracksToRip) {
|
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)) {
|
if (!fs.existsSync(wavFile)) {
|
||||||
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${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++) {
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
assertNotCancelled(isCancelled);
|
assertNotCancelled(isCancelled);
|
||||||
const track = tracksToRip[i];
|
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);
|
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
||||||
onProgress && onProgress({
|
onProgress && onProgress({
|
||||||
phase: 'encode',
|
phase: 'encode',
|
||||||
@@ -582,7 +613,7 @@ async function ripAndEncode(options) {
|
|||||||
for (let i = 0; i < tracksToRip.length; i++) {
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
assertNotCancelled(isCancelled);
|
assertNotCancelled(isCancelled);
|
||||||
const track = tracksToRip[i];
|
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)) {
|
if (!fs.existsSync(wavFile)) {
|
||||||
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${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;
|
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();
|
const db = await getDb();
|
||||||
await db.run(
|
await db.run(
|
||||||
`UPDATE converter_scan_entries SET job_id = ? WHERE rel_path = ?`,
|
`UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`,
|
||||||
[jobId, relPath]
|
[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() {
|
async function getRawDir() {
|
||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
||||||
@@ -480,7 +586,7 @@ async function createFolder(parentRelPath, name) {
|
|||||||
|
|
||||||
const TREE_MAX_DEPTH = 8;
|
const TREE_MAX_DEPTH = 8;
|
||||||
|
|
||||||
function buildRawTree(rawDir, relPath, depth) {
|
function buildRawTree(rawDir, relPath, depth, assignments = new Map()) {
|
||||||
if (depth >= TREE_MAX_DEPTH) return [];
|
if (depth >= TREE_MAX_DEPTH) return [];
|
||||||
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
|
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
|
||||||
let dirents;
|
let dirents;
|
||||||
@@ -503,19 +609,23 @@ function buildRawTree(rawDir, relPath, depth) {
|
|||||||
if (dirent.name.startsWith('.')) continue;
|
if (dirent.name.startsWith('.')) continue;
|
||||||
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
|
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
|
||||||
if (dirent.isDirectory()) {
|
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);
|
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
||||||
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
|
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
|
||||||
} else if (dirent.isFile()) {
|
} else if (dirent.isFile()) {
|
||||||
let size = 0;
|
let size = 0;
|
||||||
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
|
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
|
||||||
|
const assignment = assignments.get(childRel) || null;
|
||||||
nodes.push({
|
nodes.push({
|
||||||
name: dirent.name,
|
name: dirent.name,
|
||||||
type: 'file',
|
type: 'file',
|
||||||
path: childRel,
|
path: childRel,
|
||||||
size,
|
size,
|
||||||
detectedMediaType: detectMediaType(dirent.name),
|
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)) {
|
if (!rawDir || !fs.existsSync(rawDir)) {
|
||||||
return { rawDir: rawDir || null, tree: null };
|
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);
|
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
||||||
return {
|
return {
|
||||||
rawDir,
|
rawDir,
|
||||||
@@ -541,6 +674,10 @@ module.exports = {
|
|||||||
getEntryById,
|
getEntryById,
|
||||||
getEntryByRelPath,
|
getEntryByRelPath,
|
||||||
markEntryAsJob,
|
markEntryAsJob,
|
||||||
|
setEntryJobAssignment,
|
||||||
|
clearEntryJobAssignment,
|
||||||
|
clearAssignmentsForJob,
|
||||||
|
assignEntriesToJob,
|
||||||
getRawDir,
|
getRawDir,
|
||||||
normalizeRelPath,
|
normalizeRelPath,
|
||||||
getTree,
|
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() {
|
async getRunningJobs() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const [rows, settings] = await Promise.all([
|
const [rows, settings] = await Promise.all([
|
||||||
@@ -4357,21 +4426,72 @@ class HistoryService {
|
|||||||
const rawRoot = normalizeComparablePath(effectiveRawDir);
|
const rawRoot = normalizeComparablePath(effectiveRawDir);
|
||||||
const stat = fs.lstatSync(rawPath);
|
const stat = fs.lstatSync(rawPath);
|
||||||
const isFile = stat.isFile();
|
const isFile = stat.isFile();
|
||||||
|
const plan = resolvedPaths.encodePlan || {};
|
||||||
|
|
||||||
// Für Converter-Jobs: Datei liegt in einem Unterordner → Ordner löschen
|
// Für Converter-Jobs: nur die zum Job gehörenden Dateien löschen.
|
||||||
let deletePath = rawPath;
|
// Der übergeordnete Ordner wird nur entfernt, wenn er danach leer ist.
|
||||||
if (isFile && resolvedPaths.mediaType === 'converter') {
|
// Ausnahme: Ordner-Jobs (isFolder=true) haben einen dedizierten Ordner → komplett löschen.
|
||||||
const parentDir = normalizeComparablePath(path.dirname(rawPath));
|
const isConverterFolder = resolvedPaths.mediaType === 'converter' && Boolean(plan.isFolder);
|
||||||
if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
|
const isSharedAudio = resolvedPaths.mediaType === 'converter' && Boolean(plan.isSharedAudio);
|
||||||
deletePath = parentDir;
|
const isConverterFileJob = resolvedPaths.mediaType === 'converter' && !isConverterFolder;
|
||||||
|
|
||||||
|
if (isConverterFileJob) {
|
||||||
|
// Bestimme die zu löschenden Dateien dieses Jobs
|
||||||
|
let filesToDelete = null;
|
||||||
|
if (isSharedAudio && Array.isArray(plan.inputPaths) && plan.inputPaths.length > 0) {
|
||||||
|
// Shared-Audio-Job: nur die explizit gelisteten Einzeldateien entfernen
|
||||||
|
filesToDelete = plan.inputPaths.map(normalizeComparablePath).filter(Boolean);
|
||||||
|
} else if (isFile) {
|
||||||
|
// Einzeldatei-Job: nur diese eine Datei entfernen
|
||||||
|
filesToDelete = [rawPath];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const keepRoot = deletePath === rawRoot;
|
if (filesToDelete) {
|
||||||
const result = deleteFilesRecursively(deletePath, keepRoot);
|
let filesDeleted = 0;
|
||||||
summary.raw.deleted = true;
|
const parentDirs = new Set();
|
||||||
summary.raw.filesDeleted = result.filesDeleted;
|
for (const filePath of filesToDelete) {
|
||||||
summary.raw.dirsRemoved = result.dirsRemoved;
|
if (!isPathInside(rawRoot, filePath)) continue;
|
||||||
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
const fStat = fs.lstatSync(filePath);
|
||||||
|
if (!fStat.isFile()) continue;
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
filesDeleted++;
|
||||||
|
const parentDir = normalizeComparablePath(path.dirname(filePath));
|
||||||
|
if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
|
||||||
|
parentDirs.add(parentDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Übergeordneten Ordner löschen, wenn er nach dem Löschen leer ist
|
||||||
|
let dirsRemoved = 0;
|
||||||
|
for (const parentDir of parentDirs) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(parentDir)) continue;
|
||||||
|
const remaining = fs.readdirSync(parentDir);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
fs.rmdirSync(parentDir);
|
||||||
|
dirsRemoved++;
|
||||||
|
}
|
||||||
|
} catch (_err) { /* ignore */ }
|
||||||
|
}
|
||||||
|
summary.raw.deleted = true;
|
||||||
|
summary.raw.filesDeleted = filesDeleted;
|
||||||
|
summary.raw.dirsRemoved = dirsRemoved;
|
||||||
|
} else {
|
||||||
|
// Fallback: rawPath ist ein Verzeichnis ohne explizite Dateiliste
|
||||||
|
const keepRoot = rawPath === rawRoot;
|
||||||
|
const result = deleteFilesRecursively(rawPath, keepRoot);
|
||||||
|
summary.raw.deleted = true;
|
||||||
|
summary.raw.filesDeleted = result.filesDeleted;
|
||||||
|
summary.raw.dirsRemoved = result.dirsRemoved;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regulärer Job oder dedizierter Converter-Ordner-Job (isFolder=true): gesamten Pfad löschen
|
||||||
|
const keepRoot = rawPath === rawRoot;
|
||||||
|
const result = deleteFilesRecursively(rawPath, keepRoot);
|
||||||
|
summary.raw.deleted = true;
|
||||||
|
summary.raw.filesDeleted = result.filesDeleted;
|
||||||
|
summary.raw.dirsRemoved = result.dirsRemoved;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,93 @@
|
|||||||
const settingsService = require('./settingsService');
|
const settingsService = require('./settingsService');
|
||||||
const logger = require('./logger').child('OMDB');
|
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 {
|
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) {
|
async search(query) {
|
||||||
if (!query || query.trim().length === 0) {
|
if (!query || query.trim().length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -13,20 +99,18 @@ class OmdbService {
|
|||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
|
||||||
|
|
||||||
const type = settings.omdb_default_type || 'movie';
|
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('apikey', apiKey);
|
||||||
url.searchParams.set('s', query.trim());
|
url.searchParams.set('s', query.trim());
|
||||||
url.searchParams.set('type', type);
|
url.searchParams.set('type', type);
|
||||||
|
|
||||||
const response = await fetch(url);
|
const data = await this.requestJson(url, { query, action: 'search' }, { timeoutMs });
|
||||||
if (!response.ok) {
|
if (!data || typeof data !== 'object') {
|
||||||
logger.error('search:http-failed', { query, status: response.status });
|
return [];
|
||||||
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.Response === 'False' || !Array.isArray(data.Search)) {
|
if (data.Response === 'False' || !Array.isArray(data.Search)) {
|
||||||
logger.warn('search:no-results', { query, response: data.Response, error: data.Error });
|
logger.warn('search:no-results', { query, response: data.Response, error: data.Error });
|
||||||
return [];
|
return [];
|
||||||
@@ -54,19 +138,17 @@ class OmdbService {
|
|||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return null;
|
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('apikey', apiKey);
|
||||||
url.searchParams.set('i', normalizedId);
|
url.searchParams.set('i', normalizedId);
|
||||||
url.searchParams.set('plot', 'full');
|
url.searchParams.set('plot', 'full');
|
||||||
|
|
||||||
const response = await fetch(url);
|
const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
|
||||||
if (!response.ok) {
|
if (!data || typeof data !== 'object') {
|
||||||
logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status });
|
return null;
|
||||||
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.Response === 'False') {
|
if (data.Response === 'False') {
|
||||||
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
|
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+2344
-196
File diff suppressed because it is too large
Load Diff
@@ -156,6 +156,40 @@ function normalizeTrackIds(rawList) {
|
|||||||
return output;
|
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) {
|
function normalizeNonNegativeInteger(rawValue) {
|
||||||
if (rawValue === null || rawValue === undefined) {
|
if (rawValue === null || rawValue === undefined) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1209,10 +1243,14 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds);
|
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 subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null;
|
||||||
const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null;
|
const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null;
|
||||||
const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null;
|
const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null;
|
||||||
|
const subtitleForcedTrackIndexes = normalizePositiveIndexes(
|
||||||
|
rawSelection.subtitleForcedTrackIndexes,
|
||||||
|
subtitleTrackIds.length
|
||||||
|
);
|
||||||
const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly);
|
const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly);
|
||||||
const filteredExtra = removeSelectionArgs(extra);
|
const filteredExtra = removeSelectionArgs(extra);
|
||||||
const overrideArgs = [
|
const overrideArgs = [
|
||||||
@@ -1227,7 +1265,9 @@ class SettingsService {
|
|||||||
if (subtitleDefaultTrackId !== null) {
|
if (subtitleDefaultTrackId !== null) {
|
||||||
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
|
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}`);
|
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
|
||||||
} else if (subtitleForcedOnly) {
|
} else if (subtitleForcedOnly) {
|
||||||
overrideArgs.push('--subtitle-forced');
|
overrideArgs.push('--subtitle-forced');
|
||||||
@@ -1245,6 +1285,7 @@ class SettingsService {
|
|||||||
subtitleTrackIds,
|
subtitleTrackIds,
|
||||||
subtitleBurnTrackId,
|
subtitleBurnTrackId,
|
||||||
subtitleDefaultTrackId,
|
subtitleDefaultTrackId,
|
||||||
|
subtitleForcedTrackIndexes,
|
||||||
subtitleForcedTrackId,
|
subtitleForcedTrackId,
|
||||||
subtitleForcedOnly
|
subtitleForcedOnly
|
||||||
}
|
}
|
||||||
@@ -1258,6 +1299,7 @@ class SettingsService {
|
|||||||
subtitleTrackIds,
|
subtitleTrackIds,
|
||||||
subtitleBurnTrackId,
|
subtitleBurnTrackId,
|
||||||
subtitleDefaultTrackId,
|
subtitleDefaultTrackId,
|
||||||
|
subtitleForcedTrackIndexes,
|
||||||
subtitleForcedTrackId,
|
subtitleForcedTrackId,
|
||||||
subtitleForcedOnly
|
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_COPY_MASK = ['aac', 'ac3', 'eac3', 'truehd', 'dts', 'dtshd', 'mp3', 'flac'];
|
||||||
const DEFAULT_AUDIO_FALLBACK = 'av_aac';
|
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 = {
|
const ISO2_TO_3_LANGUAGE = {
|
||||||
de: 'deu',
|
de: 'deu',
|
||||||
en: 'eng',
|
en: 'eng',
|
||||||
@@ -271,7 +281,7 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
|
|||||||
channels: item?.Channels || item?.Channel_s_ || null
|
channels: item?.Channels || item?.Channel_s_ || null
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const subtitleTracks = tracks
|
const subtitleTracksRaw = tracks
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const type = String(item?.['@type'] || '').toLowerCase();
|
const type = String(item?.['@type'] || '').toLowerCase();
|
||||||
return type === 'text' || type === 'subtitle';
|
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'),
|
language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'),
|
||||||
languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und',
|
languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und',
|
||||||
title: item?.Title || null,
|
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
|
const videoTracks = tracks
|
||||||
.filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video')
|
.filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video')
|
||||||
@@ -350,6 +366,467 @@ function parseTrackIdList(raw) {
|
|||||||
.filter((item) => Number.isFinite(item));
|
.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) {
|
function parseEncoderList(raw) {
|
||||||
return String(raw || '')
|
return String(raw || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -638,7 +1115,10 @@ function buildTrackSelectors(settings, presetProfile) {
|
|||||||
|
|
||||||
function selectTrackIds(tracks, selector, trackType) {
|
function selectTrackIds(tracks, selector, trackType) {
|
||||||
const available = Array.isArray(tracks) ? tracks : [];
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,13 +1128,13 @@ function selectTrackIds(tracks, selector, trackType) {
|
|||||||
|
|
||||||
if (selector.mode === 'all') {
|
if (selector.mode === 'all') {
|
||||||
if (selector.firstOnly) {
|
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') {
|
if (selector.mode === 'explicit') {
|
||||||
const explicit = available
|
const explicit = selectable
|
||||||
.filter((track) => selector.explicitIds.includes(track.id))
|
.filter((track) => selector.explicitIds.includes(track.id))
|
||||||
.map((track) => track.id);
|
.map((track) => track.id);
|
||||||
if (selector.firstOnly) {
|
if (selector.firstOnly) {
|
||||||
@@ -664,7 +1144,7 @@ function selectTrackIds(tracks, selector, trackType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selector.mode === 'language') {
|
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) {
|
if (selector.firstOnly) {
|
||||||
return matches.length > 0 ? [matches[0].id] : [];
|
return matches.length > 0 ? [matches[0].id] : [];
|
||||||
}
|
}
|
||||||
@@ -672,11 +1152,11 @@ function selectTrackIds(tracks, selector, trackType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selector.mode === 'first') {
|
if (selector.mode === 'first') {
|
||||||
return [available[0].id];
|
return [selectable[0].id];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trackType === 'audio') {
|
if (trackType === 'audio') {
|
||||||
return [available[0].id];
|
return [selectable[0].id];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -916,21 +1396,45 @@ function buildMediainfoReview({
|
|||||||
const normalizedSubtitle = title.subtitleTracks.map((track) => {
|
const normalizedSubtitle = title.subtitleTracks.map((track) => {
|
||||||
const selectedByRule = selectedSubtitleIds.includes(track.id);
|
const selectedByRule = selectedSubtitleIds.includes(track.id);
|
||||||
const subtitleFlags = computeSubtitleFlags(track.id, selectedSubtitleIds, trackSelectors.subtitle);
|
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
|
const subtitlePreviewSummary = !selectedByRule
|
||||||
? 'Nicht übernommen'
|
? 'Nicht übernommen'
|
||||||
: (subtitleFlags.flags.length > 0
|
: (subtitlePreviewFlags.length > 0
|
||||||
? `Übernehmen (${subtitleFlags.flags.join(', ')})`
|
? `Übernehmen (${subtitlePreviewFlags.join(', ')})`
|
||||||
: 'Übernehmen');
|
: 'Übernehmen');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...track,
|
...track,
|
||||||
selectedByRule,
|
selectedByRule,
|
||||||
subtitlePreviewSummary,
|
subtitlePreviewSummary,
|
||||||
subtitlePreviewFlags: subtitleFlags.flags,
|
subtitlePreviewFlags: selectedByRule ? subtitlePreviewFlags : [],
|
||||||
subtitlePreviewBurnIn: subtitleFlags.burned,
|
subtitlePreviewBurnIn: subtitleFlags.burned,
|
||||||
subtitlePreviewForced: subtitleFlags.forced,
|
subtitlePreviewForced: selectedByRule ? (subtitleFlags.forced || inferredForced) : false,
|
||||||
subtitlePreviewForcedOnly: subtitleFlags.forcedOnly,
|
subtitlePreviewForcedOnly: selectedByRule ? (subtitleFlags.forcedOnly || inferredForcedOnly) : false,
|
||||||
subtitlePreviewDefaultTrack: subtitleFlags.default
|
subtitlePreviewDefaultTrack: selectedByRule ? (subtitleFlags.default || inferredDefault) : false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,13 +44,26 @@ function sanitizeFileNameWithExtension(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTemplate(template, values) {
|
function renderTemplate(template, values) {
|
||||||
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
const rawSource = String(template || '${title} (${year})');
|
||||||
const val = values[key.trim()];
|
const resolveToken = (rawKey) => {
|
||||||
|
const key = String(rawKey || '').trim();
|
||||||
|
const val = values[key];
|
||||||
if (val === undefined || val === null || val === '') {
|
if (val === undefined || val === null || val === '') {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
return String(val);
|
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']) {
|
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -617,6 +617,7 @@ export const api = {
|
|||||||
const body = {};
|
const body = {};
|
||||||
if (options.keepBoth) body.keepBoth = true;
|
if (options.keepBoth) body.keepBoth = true;
|
||||||
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
|
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}`, {
|
const result = await request(`/pipeline/restart-review/${jobId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
||||||
@@ -920,6 +921,33 @@ export const api = {
|
|||||||
body: JSON.stringify({ relPaths, audioMode })
|
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) {
|
converterUploadFiles(formData) {
|
||||||
return request('/converter/upload', { method: 'POST', body: 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 tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
|
||||||
|
|
||||||
|
const contextJobId = Number(context?.jobId || 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return;
|
return;
|
||||||
@@ -103,7 +105,7 @@ export default function CdMetadataDialog({
|
|||||||
titles[t.position] = t.title || `Track ${t.position}`;
|
titles[t.position] = t.title || `Track ${t.position}`;
|
||||||
}
|
}
|
||||||
setTrackTitles(titles);
|
setTrackTitles(titles);
|
||||||
}, [visible, context]);
|
}, [visible, contextJobId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ function normalizePosition(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTrackText(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) {
|
function normalizeYear(value) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function mediaTypeBadge(type) {
|
|||||||
return <Tag value={m.label} severity={m.severity} />;
|
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) {
|
function getNodeByPath(root, targetPath) {
|
||||||
if (!root) return null;
|
if (!root) return null;
|
||||||
if ((root.path || '') === (targetPath || '')) return root;
|
if ((root.path || '') === (targetPath || '')) return root;
|
||||||
@@ -34,6 +34,8 @@ function getNodeByPath(root, targetPath) {
|
|||||||
if (child.type === 'folder') {
|
if (child.type === 'folder') {
|
||||||
const found = getNodeByPath(child, targetPath);
|
const found = getNodeByPath(child, targetPath);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
|
} else if ((child.path || '') === (targetPath || '')) {
|
||||||
|
return child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -78,6 +80,52 @@ function collectDescendantFilePaths(node) {
|
|||||||
return result;
|
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 */
|
/** Nur "Wurzel"-Selektionen: Pfade, die keinen ausgewählten Elternpfad haben */
|
||||||
function getRootSelections(paths) {
|
function getRootSelections(paths) {
|
||||||
const set = new Set(paths);
|
const set = new Set(paths);
|
||||||
@@ -98,7 +146,12 @@ function collectFolderPaths(node, result = []) {
|
|||||||
|
|
||||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ConverterFileExplorer({ onSelectionChange, refreshToken, navigateToPath }) {
|
export default function ConverterFileExplorer({
|
||||||
|
onSelectionChange,
|
||||||
|
refreshToken,
|
||||||
|
navigateToPath,
|
||||||
|
onAssignmentChanged
|
||||||
|
}) {
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
const explorerRef = useRef(null);
|
const explorerRef = useRef(null);
|
||||||
|
|
||||||
@@ -111,8 +164,11 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
const [currentPath, setCurrentPath] = useState('');
|
const [currentPath, setCurrentPath] = useState('');
|
||||||
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
|
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
|
||||||
|
|
||||||
// Auswahl
|
// Auswahl (Checkbox → Job-Zuweisung)
|
||||||
const [selectedPaths, setSelectedPaths] = useState([]);
|
const [selectedPaths, setSelectedPaths] = useState([]);
|
||||||
|
// Aktive Zeilen-Auswahl (Klick → Rename/Delete/Move)
|
||||||
|
const [activePaths, setActivePaths] = useState([]);
|
||||||
|
const [activeAnchor, setActiveAnchor] = useState(null);
|
||||||
|
|
||||||
// Seitenleiste
|
// Seitenleiste
|
||||||
const [sidebarQuery, setSidebarQuery] = useState('');
|
const [sidebarQuery, setSidebarQuery] = useState('');
|
||||||
@@ -123,10 +179,13 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
const [renameName, setRenameName] = useState('');
|
const [renameName, setRenameName] = useState('');
|
||||||
const [moveTarget, setMoveTarget] = useState('');
|
const [moveTarget, setMoveTarget] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [jobUnassignTarget, setJobUnassignTarget] = useState(null);
|
||||||
|
|
||||||
// Stabile Ref für onSelectionChange
|
// Stabile Ref für onSelectionChange
|
||||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||||
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
|
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
|
||||||
|
const onAssignmentChangedRef = useRef(onAssignmentChanged);
|
||||||
|
useEffect(() => { onAssignmentChangedRef.current = onAssignmentChanged; }, [onAssignmentChanged]);
|
||||||
|
|
||||||
// ── Baum laden ─────────────────────────────────────────────────────────────
|
// ── Baum laden ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -155,35 +214,42 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navigateToPath]);
|
}, [navigateToPath]);
|
||||||
|
|
||||||
// Auswahl an Eltern melden: nur Wurzel-Selektionen (keine redundanten Kind-Pfade)
|
// Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
const rootPaths = getRootSelections(selectedPaths);
|
const rootPaths = getRootSelections(selectedPaths);
|
||||||
const report = rootPaths.map((p) => {
|
const report = [];
|
||||||
|
for (const p of rootPaths) {
|
||||||
const node = getNodeByPath(tree, p);
|
const node = getNodeByPath(tree, p);
|
||||||
if (!node) return null;
|
if (!node) continue;
|
||||||
return {
|
if (node.type === 'folder') {
|
||||||
relPath: node.path,
|
const fileNodes = collectDescendantSelectableFileNodes(node);
|
||||||
entryType: node.type === 'folder' ? 'directory' : 'file',
|
if (fileNodes.length > 0) {
|
||||||
detectedMediaType: node.detectedMediaType || null,
|
for (const fn of fileNodes) {
|
||||||
detectedFormat: node.detectedFormat || null
|
report.push({
|
||||||
};
|
relPath: fn.path,
|
||||||
}).filter(Boolean);
|
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: 'file',
|
||||||
|
detectedMediaType: node.detectedMediaType || null,
|
||||||
|
detectedFormat: node.detectedFormat || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
onSelectionChangeRef.current?.(report);
|
onSelectionChangeRef.current?.(report);
|
||||||
}, [selectedPaths, tree]);
|
}, [selectedPaths, tree]);
|
||||||
|
|
||||||
// Outside-Click → Auswahl aufheben
|
// Auswahl bleibt erhalten — kein Outside-Click-Handler
|
||||||
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]);
|
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -194,7 +260,8 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
|
|
||||||
function navigateTo(pathStr) {
|
function navigateTo(pathStr) {
|
||||||
setCurrentPath(pathStr || '');
|
setCurrentPath(pathStr || '');
|
||||||
setSelectedPaths([]);
|
setActivePaths([]);
|
||||||
|
setActiveAnchor(null);
|
||||||
setExpandedFolders((prev) => new Set(prev).add(pathStr || ''));
|
setExpandedFolders((prev) => new Set(prev).add(pathStr || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,10 +303,31 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
function handleCheckboxChange(item, checked) {
|
function handleCheckboxChange(item, checked) {
|
||||||
const p = item.path || '';
|
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') {
|
if (item.type === 'folder') {
|
||||||
const folderNode = getNodeByPath(tree, p);
|
const folderNode = getNodeByPath(tree, p);
|
||||||
const descendantFiles = collectDescendantFilePaths(folderNode);
|
const descendantFiles = collectDescendantSelectableFilePaths(folderNode);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
|
if (descendantFiles.length === 0) return;
|
||||||
setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles])));
|
setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles])));
|
||||||
} else {
|
} else {
|
||||||
const toRemove = new Set([p, ...descendantFiles]);
|
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) {
|
function handleOpen(item) {
|
||||||
if (item.type !== 'folder') return;
|
if (item.type !== 'folder') return;
|
||||||
navigateTo(item.path || '');
|
navigateTo(item.path || '');
|
||||||
@@ -294,16 +411,16 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRename() {
|
async function handleRename() {
|
||||||
if (!selectedPaths.length) return;
|
if (!activePaths.length) return;
|
||||||
const name = renameName.trim();
|
const name = renameName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.converterRenameFile(rootSelected[0], name);
|
await api.converterRenameFile(activePaths[0], name);
|
||||||
toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `→ ${name}`, life: 2500 });
|
toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `→ ${name}`, life: 2500 });
|
||||||
setRenameName('');
|
setRenameName('');
|
||||||
setActiveModal('');
|
setActiveModal('');
|
||||||
setSelectedPaths([]);
|
setActivePaths([]);
|
||||||
await loadTree();
|
await loadTree();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
|
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() {
|
async function handleDeleteSelected() {
|
||||||
if (!rootSelected.length) return;
|
if (!activePaths.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
for (const p of rootSelected) {
|
for (const p of activePaths) {
|
||||||
await api.converterDeleteFile(p);
|
await api.converterDeleteFile(p);
|
||||||
}
|
}
|
||||||
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${rootSelected.length} Eintrag/Einträge`, life: 2500 });
|
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${activePaths.length} Eintrag/Einträge`, life: 2500 });
|
||||||
setSelectedPaths([]);
|
setActivePaths([]);
|
||||||
|
setSelectedPaths((prev) => prev.filter((x) => !activePaths.includes(x)));
|
||||||
setActiveModal('');
|
setActiveModal('');
|
||||||
await loadTree();
|
await loadTree();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -327,14 +445,14 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleMoveSelected() {
|
async function handleMoveSelected() {
|
||||||
if (!selectedPaths.length) return;
|
if (!activePaths.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const isRoot = moveTarget === '' || moveTarget === '__root__';
|
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 });
|
toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 });
|
||||||
setMoveTarget('');
|
setMoveTarget('');
|
||||||
setSelectedPaths([]);
|
setActivePaths([]);
|
||||||
setActiveModal('');
|
setActiveModal('');
|
||||||
await loadTree();
|
await loadTree();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -342,6 +460,49 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
} finally { setBusy(false); }
|
} 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) ───────────────────────────────────────────────
|
// ── Ordnerbaum (Seitenleiste) ───────────────────────────────────────────────
|
||||||
|
|
||||||
function renderFolderTree(node, depth) {
|
function renderFolderTree(node, depth) {
|
||||||
@@ -352,6 +513,9 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
const childFolders = (node.children || []).filter((c) => c.type === 'folder');
|
const childFolders = (node.children || []).filter((c) => c.type === 'folder');
|
||||||
const hasChildren = childFolders.length > 0;
|
const hasChildren = childFolders.length > 0;
|
||||||
|
|
||||||
|
const nodeSel = isSelected(key);
|
||||||
|
const nodeIndet = isIndeterminate(node);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
|
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
|
||||||
<div
|
<div
|
||||||
@@ -374,6 +538,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
) : (
|
) : (
|
||||||
<span className="tree-caret disabled" aria-hidden="true" />
|
<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">
|
<span className="tree-icon folder">
|
||||||
<i className="pi pi-folder" />
|
<i className="pi pi-folder" />
|
||||||
</span>
|
</span>
|
||||||
@@ -389,9 +562,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
const allFolders = tree ? collectFolderPaths(tree) : [{ label: '/ (Root)', value: '' }];
|
const allFolders = tree ? collectFolderPaths(tree) : [{ label: '/ (Root)', value: '' }];
|
||||||
const breadcrumb = buildBreadcrumb(currentPath);
|
const breadcrumb = buildBreadcrumb(currentPath);
|
||||||
const rootSelected = getRootSelections(selectedPaths);
|
const rootSelected = getRootSelections(selectedPaths);
|
||||||
const canRename = rootSelected.length === 1;
|
// Aktionen basieren auf activePaths (Zeilen-Klick-Auswahl)
|
||||||
const canDelete = selectedPaths.length > 0;
|
const hasLockedActive = activePaths.some((p) => {
|
||||||
const canMove = rootSelected.length === 1;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -500,11 +679,22 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
>
|
>
|
||||||
<i className="pi pi-folder-plus" />
|
<i className="pi pi-folder-plus" />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const node = getNodeByPath(tree, rootSelected[0]);
|
const node = getNodeByPath(tree, activePaths[0]);
|
||||||
setRenameName(node?.name || '');
|
setRenameName(node?.name || '');
|
||||||
setActiveModal('rename');
|
setActiveModal('rename');
|
||||||
}}
|
}}
|
||||||
@@ -545,6 +735,7 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<span>Typ</span>
|
<span>Typ</span>
|
||||||
<span>Größe</span>
|
<span>Größe</span>
|
||||||
|
<span>Job</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zeilen */}
|
{/* Zeilen */}
|
||||||
@@ -553,31 +744,36 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
Leer.
|
Leer.
|
||||||
</div>
|
</div>
|
||||||
) : currentItems.map((item) => {
|
) : 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 indet = isIndeterminate(item);
|
||||||
|
const active = activePaths.includes(item.path || '');
|
||||||
|
const jobTitle = String(item.jobTitle || '').trim();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.path}
|
key={item.path}
|
||||||
className={`explorer-row${sel ? ' selected' : ''}`}
|
className={`explorer-row${sel ? ' selected' : ''}${active ? ' row-active' : ''}${locked ? ' row-locked' : ''}`}
|
||||||
onClick={() => {
|
onClick={(e) => handleRowClick(e, item)}
|
||||||
if (item.type === 'folder') {
|
onDoubleClick={() => item.type === 'folder' && handleOpen(item)}
|
||||||
handleOpen(item);
|
|
||||||
} else {
|
|
||||||
handleCheckboxChange(item, !sel);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="row"
|
role="row"
|
||||||
tabIndex={0}
|
tabIndex={locked ? -1 : 0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
|
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">
|
||||||
<input
|
{locked ? (
|
||||||
type="checkbox"
|
<i className="pi pi-lock" style={{ fontSize: '0.8rem', color: 'var(--rip-muted)' }} title="Datei wird gerade verarbeitet" />
|
||||||
checked={sel}
|
) : (
|
||||||
ref={(el) => { if (el) el.indeterminate = indet; }}
|
<input
|
||||||
onChange={(e) => handleCheckboxChange(item, e.target.checked)}
|
type="checkbox"
|
||||||
aria-label={`${item.name} auswählen`}
|
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>
|
||||||
<span className="row-name">
|
<span className="row-name">
|
||||||
<span className="row-icon">
|
<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' }}>
|
<span style={{ textAlign: 'right', color: 'var(--rip-muted)', fontSize: '0.78rem' }}>
|
||||||
{formatBytes(item.size)}
|
{formatBytes(item.size)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -603,6 +817,7 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
|
<span />
|
||||||
<span className="footer-count">{getRootSelections(selectedPaths).length} ausgewählt</span>
|
<span className="footer-count">{getRootSelections(selectedPaths).length} ausgewählt</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -618,6 +833,40 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
|
|||||||
|
|
||||||
{/* ── Dialoge ─────────────────────────────────────────────────────────── */}
|
{/* ── 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 */}
|
{/* Neuer Ordner */}
|
||||||
<Dialog
|
<Dialog
|
||||||
header="Neuen Ordner erstellen"
|
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)) {
|
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||||
return 'audiobook';
|
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];
|
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
|
||||||
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
|
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||||
@@ -325,6 +337,26 @@ function resolveMediaType(job) {
|
|||||||
return 'other';
|
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) {
|
function resolveCdDetails(job) {
|
||||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
||||||
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||||
@@ -595,10 +627,12 @@ export default function JobDetailDialog({
|
|||||||
onDownloadArchive,
|
onDownloadArchive,
|
||||||
onDownloadOutputFolder,
|
onDownloadOutputFolder,
|
||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
|
onCancel,
|
||||||
isQueued = false,
|
isQueued = false,
|
||||||
omdbAssignBusy = false,
|
omdbAssignBusy = false,
|
||||||
cdMetadataAssignBusy = false,
|
cdMetadataAssignBusy = false,
|
||||||
actionBusy = false,
|
actionBusy = false,
|
||||||
|
cancelBusy = false,
|
||||||
reencodeBusy = false,
|
reencodeBusy = false,
|
||||||
deleteEntryBusy = false,
|
deleteEntryBusy = false,
|
||||||
downloadBusyTarget = null,
|
downloadBusyTarget = null,
|
||||||
@@ -609,11 +643,19 @@ export default function JobDetailDialog({
|
|||||||
const isUnencodedOrphanImport = Boolean(
|
const isUnencodedOrphanImport = Boolean(
|
||||||
job?.makemkvInfo?.source === 'orphan_raw_import' && !job?.handbrakeInfo
|
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 showFinalLog = !running;
|
||||||
const mediaType = resolveMediaType(job);
|
const mediaType = resolveMediaType(job);
|
||||||
const isCd = mediaType === 'cd';
|
const isCd = mediaType === 'cd';
|
||||||
const isAudiobook = mediaType === 'audiobook';
|
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 = [
|
const cdRawPathCandidates = [
|
||||||
job?.raw_path,
|
job?.raw_path,
|
||||||
job?.makemkvInfo?.rawPath,
|
job?.makemkvInfo?.rawPath,
|
||||||
@@ -704,6 +746,62 @@ export default function JobDetailDialog({
|
|||||||
&& mediaType !== 'audiobook'
|
&& mediaType !== 'audiobook'
|
||||||
&& typeof onRestartReview === 'function'
|
&& 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 canDeleteEntry = !running && typeof onDeleteEntry === 'function';
|
||||||
const queueLocked = Boolean(isQueued && job?.id);
|
const queueLocked = Boolean(isQueued && job?.id);
|
||||||
const logCount = Number(job?.log_count || 0);
|
const logCount = Number(job?.log_count || 0);
|
||||||
@@ -719,7 +817,7 @@ export default function JobDetailDialog({
|
|||||||
? 'DVD'
|
? 'DVD'
|
||||||
: isCd
|
: isCd
|
||||||
? 'Audio CD'
|
? 'Audio CD'
|
||||||
: (isAudiobook ? 'Audiobook' : 'Sonstiges Medium');
|
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : 'Sonstiges Medium'));
|
||||||
const mediaTypeIcon = mediaType === 'bluray'
|
const mediaTypeIcon = mediaType === 'bluray'
|
||||||
? blurayIndicatorIcon
|
? blurayIndicatorIcon
|
||||||
: mediaType === 'dvd'
|
: mediaType === 'dvd'
|
||||||
@@ -742,6 +840,11 @@ export default function JobDetailDialog({
|
|||||||
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||||
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
||||||
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -759,9 +862,9 @@ export default function JobDetailDialog({
|
|||||||
|
|
||||||
<div className="job-head-row">
|
<div className="job-head-row">
|
||||||
{job.poster_url && job.poster_url !== 'N/A' ? (
|
{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">
|
<div className="job-film-info-grid">
|
||||||
@@ -812,6 +915,70 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<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 className="job-infos-sep">|</span>
|
||||||
<span><strong>Encode:</strong> <BoolState value={job?.encodeSuccess} /></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>
|
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
|
||||||
{/* Zeile 3+4: Pfade */}
|
{/* Zeile 3+4: Pfade */}
|
||||||
<PathField
|
<PathField
|
||||||
label={isCd ? 'WAV:' : 'RAW:'}
|
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
||||||
value={isCd ? (job.raw_path || job.output_path) : job.raw_path}
|
value={isCd ? (job.raw_path || job.output_path) : job.raw_path}
|
||||||
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||||
downloadDisabled={!canDownloadRaw}
|
downloadDisabled={!canDownloadRaw}
|
||||||
@@ -990,7 +1157,72 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Encode-Konfiguration</h4>
|
<h4>Encode-Konfiguration</h4>
|
||||||
{/* Zeile 1: Preset + Mindestlaufzeit */}
|
{/* Zeile 1: Preset + Mindestlaufzeit */}
|
||||||
@@ -1193,13 +1425,13 @@ export default function JobDetailDialog({
|
|||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Logs</h4>
|
<h4>Logs</h4>
|
||||||
<div className="job-json-grid">
|
<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}
|
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
|
||||||
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? 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}
|
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
||||||
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
|
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
|
||||||
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : 'HandBrake Info')} value={job.handbrakeInfo} />
|
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1257,6 +1489,27 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="actions-section">
|
<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 ? (
|
{isCd ? (
|
||||||
<div className="actions-group">
|
<div className="actions-group">
|
||||||
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audio CD</div>
|
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audio CD</div>
|
||||||
@@ -1304,73 +1557,77 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="actions-group">
|
canShowGeneralEncodeActions ? (
|
||||||
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
|
<div className="actions-group">
|
||||||
{canResumeReady ? (
|
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
|
||||||
<div className="action-item">
|
{canResumeReady ? (
|
||||||
<Button
|
<div className="action-item">
|
||||||
label="Im Dashboard öffnen"
|
<Button
|
||||||
icon="pi pi-window-maximize"
|
label="Im Dashboard öffnen"
|
||||||
severity="info"
|
icon="pi pi-window-maximize"
|
||||||
outlined
|
severity="info"
|
||||||
size="small"
|
outlined
|
||||||
onClick={() => onResumeReady?.(job)}
|
size="small"
|
||||||
loading={actionBusy}
|
onClick={() => onResumeReady?.(job)}
|
||||||
/>
|
loading={actionBusy}
|
||||||
<span className="action-desc">Öffnet den wartenden Job im Dashboard zur Weiterverarbeitung.</span>
|
/>
|
||||||
</div>
|
<span className="action-desc">Öffnet den wartenden Job im Dashboard zur Weiterverarbeitung.</span>
|
||||||
) : null}
|
</div>
|
||||||
{typeof onRestartEncode === 'function' ? (
|
) : null}
|
||||||
<div className="action-item">
|
{typeof onRestartEncode === 'function' ? (
|
||||||
<Button
|
<div className="action-item">
|
||||||
label="Encode neu starten"
|
<Button
|
||||||
icon="pi pi-play"
|
label="Encode neu starten"
|
||||||
severity="success"
|
icon="pi pi-play"
|
||||||
size="small"
|
severity="success"
|
||||||
onClick={() => onRestartEncode?.(job)}
|
size="small"
|
||||||
loading={actionBusy}
|
onClick={() => onRestartEncode?.(job)}
|
||||||
disabled={!canRestartEncode}
|
loading={actionBusy}
|
||||||
/>
|
disabled={!canRestartEncode}
|
||||||
<span className="action-desc">Startet {isAudiobook ? 'FFmpeg' : 'HandBrake'} mit den zuletzt gespeicherten Einstellungen direkt neu — ohne Review.</span>
|
/>
|
||||||
</div>
|
<span className="action-desc">Startet {isAudiobook ? 'FFmpeg' : 'HandBrake'} mit den zuletzt gespeicherten Einstellungen direkt neu — ohne Review.</span>
|
||||||
) : null}
|
</div>
|
||||||
{typeof onRestartReview === 'function' ? (
|
) : null}
|
||||||
<div className="action-item">
|
{typeof onRestartReview === 'function' ? (
|
||||||
<Button
|
<div className="action-item">
|
||||||
label={isAudiobook ? 'Kapitel-Auswahl öffnen' : 'Spur-Auswahl öffnen'}
|
<Button
|
||||||
icon="pi pi-list"
|
label={isAudiobook ? 'Kapitel-Auswahl öffnen' : 'Spur-Auswahl öffnen'}
|
||||||
severity="info"
|
icon="pi pi-list"
|
||||||
outlined
|
severity="info"
|
||||||
size="small"
|
outlined
|
||||||
onClick={() => onRestartReview?.(job)}
|
size="small"
|
||||||
loading={actionBusy}
|
onClick={() => onRestartReview?.(job)}
|
||||||
disabled={!canRestartReview || isUnencodedOrphanImport}
|
loading={actionBusy}
|
||||||
/>
|
disabled={!canRestartReview || isUnencodedOrphanImport}
|
||||||
<span className="action-desc">{isAudiobook
|
/>
|
||||||
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
|
<span className="action-desc">{isAudiobook
|
||||||
: 'Öffnet die Spur- und Preset-Auswahl erneut, um Einstellungen vor dem Encode zu ändern.'
|
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
|
||||||
}</span>
|
: 'Öffnet die Spur- und Preset-Auswahl erneut, um Einstellungen vor dem Encode zu ändern.'
|
||||||
</div>
|
}</span>
|
||||||
) : null}
|
</div>
|
||||||
<div className="action-item">
|
) : null}
|
||||||
<Button
|
{typeof onReencode === 'function' ? (
|
||||||
label="Neustart"
|
<div className="action-item">
|
||||||
icon="pi pi-sync"
|
<Button
|
||||||
severity="info"
|
label="Neustart"
|
||||||
size="small"
|
icon="pi pi-sync"
|
||||||
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
|
severity="info"
|
||||||
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
|
size="small"
|
||||||
disabled={isUnencodedOrphanImport ? !canRestartReview : (!canReencode || typeof onReencode !== 'function')}
|
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
|
||||||
/>
|
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
|
||||||
<span className="action-desc">{isAudiobook
|
disabled={isUnencodedOrphanImport ? !canRestartReview : !canReencode}
|
||||||
? '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 className="action-desc">{isAudiobook
|
||||||
}</span>
|
? '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>
|
</div>
|
||||||
</div>
|
) : null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCd && !isAudiobook && typeof onAssignOmdb === 'function' ? (
|
{!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
|
||||||
<div className="actions-group">
|
<div className="actions-group">
|
||||||
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||||
<div className="action-item">
|
<div className="action-item">
|
||||||
@@ -1389,47 +1646,49 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="actions-group">
|
{typeof onDeleteFiles === 'function' ? (
|
||||||
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
|
<div className="actions-group">
|
||||||
<div className="action-item">
|
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
|
||||||
<Button
|
<div className="action-item">
|
||||||
label="RAW löschen"
|
<Button
|
||||||
icon="pi pi-trash"
|
label="RAW löschen"
|
||||||
severity="warning"
|
icon="pi pi-trash"
|
||||||
outlined
|
severity="warning"
|
||||||
size="small"
|
outlined
|
||||||
onClick={() => onDeleteFiles?.(job, 'raw')}
|
size="small"
|
||||||
loading={actionBusy}
|
onClick={() => onDeleteFiles?.(job, 'raw')}
|
||||||
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
|
loading={actionBusy}
|
||||||
/>
|
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' : (isConverter ? 'Output löschen' : 'Movie löschen'))}
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="warning"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDeleteFiles?.(job, 'movie')}
|
||||||
|
loading={actionBusy}
|
||||||
|
disabled={!hasAnyOutputFolder}
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
label="Beides löschen"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDeleteFiles?.(job, 'both')}
|
||||||
|
loading={actionBusy}
|
||||||
|
disabled={!job.rawStatus?.exists && !job.outputStatus?.exists}
|
||||||
|
/>
|
||||||
|
<span className="action-desc">Löscht RAW-Quelldateien und Ausgabe gemeinsam.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="action-item">
|
) : null}
|
||||||
<Button
|
|
||||||
label={isCd ? 'Audio löschen' : (isAudiobook ? 'Ausgabe 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'}
|
|
||||||
/>
|
|
||||||
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
|
|
||||||
</div>
|
|
||||||
<div className="action-item">
|
|
||||||
<Button
|
|
||||||
label="Beides löschen"
|
|
||||||
icon="pi pi-times"
|
|
||||||
severity="danger"
|
|
||||||
size="small"
|
|
||||||
onClick={() => onDeleteFiles?.(job, 'both')}
|
|
||||||
loading={actionBusy}
|
|
||||||
disabled={(!job.rawStatus?.exists && !job.outputStatus?.exists) || typeof onDeleteFiles !== 'function'}
|
|
||||||
/>
|
|
||||||
<span className="action-desc">Löscht RAW-Quelldateien und Ausgabe gemeinsam.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -65,21 +65,246 @@ function isBurnedSubtitleTrack(track) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isForcedOnlySubtitleTrack(track) {
|
function isDuplicateSubtitleTrack(track) {
|
||||||
const summary = `${track?.title || ''} ${track?.description || ''} ${track?.languageLabel || ''}`.toLowerCase();
|
return Boolean(track?.duplicate);
|
||||||
return Boolean(
|
|
||||||
track?.forcedTrack
|
|
||||||
|| /forced only/.test(summary)
|
|
||||||
|| /nur erzwungen/.test(summary)
|
|
||||||
|| /\berzwungen\b/.test(summary)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasForcedSubtitleAvailable(track) {
|
function normalizeTrackIdSequence(values, options = {}) {
|
||||||
const sourceTrackIds = normalizeTrackIdList(
|
const list = Array.isArray(values) ? values : [];
|
||||||
Array.isArray(track?.forcedSourceTrackIds) ? track.forcedSourceTrackIds : []
|
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) {
|
function splitArgs(input) {
|
||||||
@@ -191,6 +416,8 @@ function buildHandBrakeCommandPreview({
|
|||||||
title,
|
title,
|
||||||
selectedAudioTrackIds,
|
selectedAudioTrackIds,
|
||||||
selectedSubtitleTrackIds,
|
selectedSubtitleTrackIds,
|
||||||
|
selectedSubtitleVariantSelection = {},
|
||||||
|
selectedSubtitleLanguageOrder = [],
|
||||||
commandOutputPath = null,
|
commandOutputPath = null,
|
||||||
presetOverride = null
|
presetOverride = null
|
||||||
}) {
|
}) {
|
||||||
@@ -211,7 +438,19 @@ function buildHandBrakeCommandPreview({
|
|||||||
? Math.trunc(rawMappedTitleId)
|
? Math.trunc(rawMappedTitleId)
|
||||||
: null;
|
: 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 selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
|
||||||
const id = normalizeTrackId(track?.id);
|
const id = normalizeTrackId(track?.id);
|
||||||
return id !== null && selectedSubtitleSet.has(String(id));
|
return id !== null && selectedSubtitleSet.has(String(id));
|
||||||
@@ -223,10 +462,6 @@ function buildHandBrakeCommandPreview({
|
|||||||
const subtitleDefaultTrackId = normalizeTrackIdList(
|
const subtitleDefaultTrackId = normalizeTrackIdList(
|
||||||
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
|
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
|
||||||
)[0] || null;
|
)[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 = [
|
const baseArgs = [
|
||||||
'-i',
|
'-i',
|
||||||
@@ -248,7 +483,7 @@ function buildHandBrakeCommandPreview({
|
|||||||
'-a',
|
'-a',
|
||||||
normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none',
|
normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none',
|
||||||
'-s',
|
'-s',
|
||||||
normalizeTrackIdList(selectedSubtitleTrackIds).join(',') || 'none'
|
subtitleTrackIds.length > 0 ? subtitleTrackIds.join(',') : 'none'
|
||||||
];
|
];
|
||||||
|
|
||||||
if (subtitleBurnTrackId !== null) {
|
if (subtitleBurnTrackId !== null) {
|
||||||
@@ -257,10 +492,8 @@ function buildHandBrakeCommandPreview({
|
|||||||
if (subtitleDefaultTrackId !== null) {
|
if (subtitleDefaultTrackId !== null) {
|
||||||
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
|
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
|
||||||
}
|
}
|
||||||
if (subtitleForcedTrackId !== null) {
|
if (resolvedSubtitleSelection.subtitleForcedTrackIndexes.length > 0) {
|
||||||
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
|
overrideArgs.push(`--subtitle-forced=${resolvedSubtitleSelection.subtitleForcedTrackIndexes.join(',')}`);
|
||||||
} else if (subtitleForcedOnly) {
|
|
||||||
overrideArgs.push('--subtitle-forced');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs];
|
const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs];
|
||||||
@@ -637,11 +870,191 @@ function TrackList({
|
|||||||
type = 'generic',
|
type = 'generic',
|
||||||
allowSelection = false,
|
allowSelection = false,
|
||||||
selectedTrackIds = [],
|
selectedTrackIds = [],
|
||||||
|
selectedSubtitleVariantSelection = {},
|
||||||
|
selectedSubtitleLanguageOrder = [],
|
||||||
onToggleTrack = null,
|
onToggleTrack = null,
|
||||||
|
onToggleSubtitleVariant = null,
|
||||||
audioSelector = 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 selectedIds = normalizeTrackIdList(selectedTrackIds);
|
||||||
const checkedTrackOrder = (Array.isArray(tracks) ? tracks : [])
|
const checkedTrackOrder = orderedTracks
|
||||||
.map((track) => normalizeTrackId(track?.id))
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
.filter((trackId, index) => {
|
.filter((trackId, index) => {
|
||||||
if (trackId === null) {
|
if (trackId === null) {
|
||||||
@@ -650,7 +1063,7 @@ function TrackList({
|
|||||||
if (allowSelection) {
|
if (allowSelection) {
|
||||||
return selectedIds.includes(trackId);
|
return selectedIds.includes(trackId);
|
||||||
}
|
}
|
||||||
const track = tracks[index];
|
const track = orderedTracks[index];
|
||||||
return Boolean(track?.selectedForEncode);
|
return Boolean(track?.selectedForEncode);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -661,11 +1074,10 @@ function TrackList({
|
|||||||
<p>Keine Einträge.</p>
|
<p>Keine Einträge.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="media-track-list">
|
<div className="media-track-list">
|
||||||
{tracks.map((track) => {
|
{orderedTracks.map((track) => {
|
||||||
const trackId = normalizeTrackId(track.id);
|
const trackId = normalizeTrackId(track.id);
|
||||||
const burned = type === 'subtitle' ? isBurnedSubtitleTrack(track) : false;
|
|
||||||
const checked = allowSelection
|
const checked = allowSelection
|
||||||
? (trackId !== null && selectedIds.includes(trackId) && !(type === 'subtitle' && burned))
|
? (trackId !== null && selectedIds.includes(trackId))
|
||||||
: Boolean(track.selectedForEncode);
|
: Boolean(track.selectedForEncode);
|
||||||
const selectedIndex = trackId !== null
|
const selectedIndex = trackId !== null
|
||||||
? checkedTrackOrder.indexOf(trackId)
|
? checkedTrackOrder.indexOf(trackId)
|
||||||
@@ -686,23 +1098,14 @@ function TrackList({
|
|||||||
return base || buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
|
return base || buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
|
||||||
})()
|
})()
|
||||||
: 'Nicht übernommen')
|
: 'Nicht übernommen')
|
||||||
: type === 'subtitle'
|
: null;
|
||||||
? (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 displayLanguage = toLang2(track.language || track.languageLabel || 'und');
|
||||||
const displayHint = track.description || track.title;
|
const displayHint = track.description || track.title;
|
||||||
const displayCodec = simplifyCodec(type, track.format, displayHint);
|
const displayCodec = simplifyCodec(type, track.format, displayHint);
|
||||||
const displayChannelCount = channelCount(track.channels);
|
const displayChannelCount = channelCount(track.channels);
|
||||||
const displayAudioTitle = audioChannelLabel(track.channels);
|
const displayAudioTitle = audioChannelLabel(track.channels);
|
||||||
const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : '';
|
const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : '';
|
||||||
const disabled = !allowSelection || (type === 'subtitle' && burned);
|
const disabled = !allowSelection;
|
||||||
const forcedOnlyTrack = type === 'subtitle' ? isForcedOnlySubtitleTrack(track) : false;
|
|
||||||
const forcedAvailable = type === 'subtitle' ? hasForcedSubtitleAvailable(track) : false;
|
|
||||||
|
|
||||||
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
|
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
|
||||||
if (type === 'audio') {
|
if (type === 'audio') {
|
||||||
@@ -716,14 +1119,6 @@ function TrackList({
|
|||||||
displayText += ` | ${audioVariant}`;
|
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 (
|
return (
|
||||||
<div key={`${title}-${track.id}`} className="media-track-item">
|
<div key={`${title}-${track.id}`} className="media-track-item">
|
||||||
<label className="readonly-check-row">
|
<label className="readonly-check-row">
|
||||||
@@ -739,7 +1134,9 @@ function TrackList({
|
|||||||
readOnly={disabled}
|
readOnly={disabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<span>{displayText}</span>
|
<span>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{actionInfo ? <small className="track-action-note">Encode: {actionInfo}</small> : null}
|
{actionInfo ? <small className="track-action-note">Encode: {actionInfo}</small> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -804,6 +1201,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
allowTrackSelection = false,
|
allowTrackSelection = false,
|
||||||
trackSelectionByTitle = {},
|
trackSelectionByTitle = {},
|
||||||
onTrackSelectionChange = null,
|
onTrackSelectionChange = null,
|
||||||
|
onSubtitleVariantSelectionChange = null,
|
||||||
availableScripts = [],
|
availableScripts = [],
|
||||||
availableChains = [],
|
availableChains = [],
|
||||||
preEncodeItems = [],
|
preEncodeItems = [],
|
||||||
@@ -934,7 +1332,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
<div><strong>Audio Copy-Mask:</strong> {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}</div>
|
<div><strong>Audio Copy-Mask:</strong> {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}</div>
|
||||||
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
|
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
|
||||||
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</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>
|
</div>
|
||||||
|
|
||||||
{review.partial ? (
|
{review.partial ? (
|
||||||
@@ -1213,18 +1611,44 @@ export default function MediaInfoReviewPanel({
|
|||||||
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
|
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
|
||||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||||
const selectableSubtitleTrackIds = subtitleTracks
|
const selectableSubtitleTrackIds = subtitleTracks
|
||||||
.filter((track) => !isBurnedSubtitleTrack(track))
|
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
|
||||||
.map((track) => normalizeTrackId(track?.id))
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
.filter((id) => id !== null);
|
.filter((id) => id !== null);
|
||||||
const selectableSubtitleTrackIdSet = new Set(selectableSubtitleTrackIds.map((id) => String(id)));
|
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 : [])
|
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||||
.filter((track) => Boolean(track?.selectedByRule))
|
.filter((track) => Boolean(track?.selectedByRule))
|
||||||
.map((track) => normalizeTrackId(track?.id))
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
.filter((id) => id !== null);
|
.filter((id) => id !== null);
|
||||||
const defaultSubtitleTrackIds = subtitleTracks
|
const defaultSubtitleTrackIds = subtitleTracks
|
||||||
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
|
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
|
||||||
.map((track) => normalizeTrackId(track?.id))
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
.filter((id) => id !== null);
|
.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(
|
const selectedAudioTrackIds = normalizeTrackIdList(
|
||||||
Array.isArray(titleSelectionEntry?.audioTrackIds)
|
Array.isArray(titleSelectionEntry?.audioTrackIds)
|
||||||
? titleSelectionEntry.audioTrackIds
|
? titleSelectionEntry.audioTrackIds
|
||||||
@@ -1235,6 +1659,14 @@ export default function MediaInfoReviewPanel({
|
|||||||
? titleSelectionEntry.subtitleTrackIds
|
? titleSelectionEntry.subtitleTrackIds
|
||||||
: defaultSubtitleTrackIds
|
: defaultSubtitleTrackIds
|
||||||
).filter((id) => selectableSubtitleTrackIdSet.has(String(id)));
|
).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(
|
const allowTrackSelectionForTitle = Boolean(
|
||||||
allowTrackSelection
|
allowTrackSelection
|
||||||
&& allowTitleSelection
|
&& allowTitleSelection
|
||||||
@@ -1302,15 +1734,19 @@ export default function MediaInfoReviewPanel({
|
|||||||
/>
|
/>
|
||||||
<TrackList
|
<TrackList
|
||||||
title={`Subtitles (Titel #${title.id})`}
|
title={`Subtitles (Titel #${title.id})`}
|
||||||
tracks={allowTrackSelectionForTitle ? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track)) : subtitleTracks}
|
tracks={allowTrackSelectionForTitle
|
||||||
|
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
|
||||||
|
: subtitleTracks}
|
||||||
type="subtitle"
|
type="subtitle"
|
||||||
allowSelection={allowTrackSelectionForTitle}
|
allowSelection={allowTrackSelectionForTitle}
|
||||||
selectedTrackIds={selectedSubtitleTrackIds}
|
selectedTrackIds={selectedSubtitleTrackIds}
|
||||||
onToggleTrack={(trackId, checked) => {
|
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
|
||||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
|
||||||
|
onToggleSubtitleVariant={(language, variant, checked) => {
|
||||||
|
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
|
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1320,6 +1756,8 @@ export default function MediaInfoReviewPanel({
|
|||||||
title,
|
title,
|
||||||
selectedAudioTrackIds,
|
selectedAudioTrackIds,
|
||||||
selectedSubtitleTrackIds,
|
selectedSubtitleTrackIds,
|
||||||
|
selectedSubtitleVariantSelection,
|
||||||
|
selectedSubtitleLanguageOrder,
|
||||||
commandOutputPath,
|
commandOutputPath,
|
||||||
presetOverride: effectivePresetOverride
|
presetOverride: effectivePresetOverride
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ function normalizeTitleId(value) {
|
|||||||
return Math.trunc(parsed);
|
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) {
|
function normalizePlaylistId(value) {
|
||||||
const raw = String(value || '').trim().toLowerCase();
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
if (!raw) {
|
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) {
|
function buildDefaultTrackSelection(review) {
|
||||||
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
||||||
const selection = {};
|
const selection = {};
|
||||||
const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId);
|
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) {
|
for (const title of titles) {
|
||||||
const titleId = normalizeTitleId(title?.id);
|
const titleId = normalizeTitleId(title?.id);
|
||||||
@@ -179,16 +296,49 @@ function buildDefaultTrackSelection(review) {
|
|||||||
const subtitleSelectionSource = isEncodeInputTitle
|
const subtitleSelectionSource = isEncodeInputTitle
|
||||||
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
|
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
|
||||||
: subtitleTracks.filter((track) => Boolean(track?.selectedByRule));
|
: 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] = {
|
selection[titleId] = {
|
||||||
audioTrackIds: normalizeTrackIdList(
|
audioTrackIds: manualAudioTrackIds.length > 0
|
||||||
audioSelectionSource.map((track) => track?.id)
|
? manualAudioTrackIds
|
||||||
),
|
: normalizeTrackIdList(audioSelectionSource.map((track) => track?.id)),
|
||||||
subtitleTrackIds: normalizeTrackIdList(
|
subtitleTrackIds: manualSubtitleTrackIds.length > 0
|
||||||
subtitleSelectionSource
|
? manualSubtitleTrackIds
|
||||||
.filter((track) => !isBurnedSubtitleTrack(track))
|
: normalizeTrackIdList(
|
||||||
.map((track) => track?.id)
|
subtitleSelectionSource
|
||||||
)
|
.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) {
|
function defaultTrackSelectionForTitle(review, titleId) {
|
||||||
const defaults = buildDefaultTrackSelection(review);
|
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) {
|
function buildSettingsMap(categories) {
|
||||||
@@ -299,6 +454,7 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PipelineStatusCard({
|
export default function PipelineStatusCard({
|
||||||
|
jobId = null,
|
||||||
pipeline,
|
pipeline,
|
||||||
onAnalyze,
|
onAnalyze,
|
||||||
onReanalyze,
|
onReanalyze,
|
||||||
@@ -321,7 +477,9 @@ export default function PipelineStatusCard({
|
|||||||
const stateLabel = getStatusLabel(state);
|
const stateLabel = getStatusLabel(state);
|
||||||
const progress = Number(pipeline?.progress || 0);
|
const progress = Number(pipeline?.progress || 0);
|
||||||
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
|
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 queueLocked = Boolean(isQueued && retryJobId);
|
||||||
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
||||||
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
||||||
@@ -435,7 +593,12 @@ export default function PipelineStatusCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaults = buildDefaultTrackSelection(mediaInfoReview);
|
const defaults = buildDefaultTrackSelection(mediaInfoReview);
|
||||||
const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] };
|
const fallback = defaults[currentTitleId] || {
|
||||||
|
audioTrackIds: [],
|
||||||
|
subtitleTrackIds: [],
|
||||||
|
subtitleVariantSelection: {},
|
||||||
|
subtitleLanguageOrder: []
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[currentTitleId]: fallback
|
[currentTitleId]: fallback
|
||||||
@@ -639,7 +802,12 @@ export default function PipelineStatusCard({
|
|||||||
: null;
|
: null;
|
||||||
const fallbackSelection = encodeTitleId
|
const fallbackSelection = encodeTitleId
|
||||||
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
|
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
|
||||||
: { audioTrackIds: [], subtitleTrackIds: [] };
|
: {
|
||||||
|
audioTrackIds: [],
|
||||||
|
subtitleTrackIds: [],
|
||||||
|
subtitleVariantSelection: {},
|
||||||
|
subtitleLanguageOrder: []
|
||||||
|
};
|
||||||
const effectiveSelection = selectionEntry || fallbackSelection;
|
const effectiveSelection = selectionEntry || fallbackSelection;
|
||||||
const encodeTitle = encodeTitleId
|
const encodeTitle = encodeTitleId
|
||||||
? (Array.isArray(mediaInfoReview?.titles)
|
? (Array.isArray(mediaInfoReview?.titles)
|
||||||
@@ -648,17 +816,39 @@ export default function PipelineStatusCard({
|
|||||||
: null;
|
: null;
|
||||||
const blockedSubtitleTrackIds = new Set(
|
const blockedSubtitleTrackIds = new Set(
|
||||||
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
||||||
.filter((track) => isBurnedSubtitleTrack(track))
|
.filter((track) => isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track))
|
||||||
.map((track) => normalizeTrackId(track?.id))
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
.filter((id) => id !== null)
|
.filter((id) => id !== null)
|
||||||
.map((id) => String(id))
|
.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
|
const selectedTrackSelection = encodeTitleId
|
||||||
? {
|
? {
|
||||||
[encodeTitleId]: {
|
[encodeTitleId]: {
|
||||||
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
|
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
|
||||||
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
|
subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds,
|
||||||
.filter((id) => !blockedSubtitleTrackIds.has(String(id)))
|
subtitleVariantSelection: filteredVariantSelection,
|
||||||
|
subtitleLanguageOrder: normalizedLanguageOrder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -1032,7 +1222,9 @@ export default function PipelineStatusCard({
|
|||||||
setTrackSelectionByTitle((prev) => {
|
setTrackSelectionByTitle((prev) => {
|
||||||
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
|
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
|
||||||
audioTrackIds: [],
|
audioTrackIds: [],
|
||||||
subtitleTrackIds: []
|
subtitleTrackIds: [],
|
||||||
|
subtitleVariantSelection: {},
|
||||||
|
subtitleLanguageOrder: []
|
||||||
};
|
};
|
||||||
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
|
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
|
||||||
const existing = normalizeTrackIdList(current?.[key] || []);
|
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}
|
availableScripts={scriptCatalog}
|
||||||
availableChains={chainCatalog}
|
availableChains={chainCatalog}
|
||||||
preEncodeItems={preEncodeItems}
|
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 { Button } from 'primereact/button';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import { Divider } from 'primereact/divider';
|
|
||||||
import { Badge } from 'primereact/badge';
|
import { Badge } from 'primereact/badge';
|
||||||
|
import { Divider } from 'primereact/divider';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import { RadioButton } from 'primereact/radiobutton';
|
import { RadioButton } from 'primereact/radiobutton';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import ConverterFileExplorer from '../components/ConverterFileExplorer';
|
import ConverterFileExplorer from '../components/ConverterFileExplorer';
|
||||||
import ConverterUploadPanel from '../components/ConverterUploadPanel';
|
import ConverterUploadPanel from '../components/ConverterUploadPanel';
|
||||||
import ConverterJobCard from '../components/ConverterJobCard';
|
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 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) {
|
function isAudioEntry(e) {
|
||||||
|
if (e.detectedMediaType === 'audio') return true;
|
||||||
const p = (e.relPath || '').toLowerCase();
|
const p = (e.relPath || '').toLowerCase();
|
||||||
const dot = p.lastIndexOf('.');
|
const dot = p.lastIndexOf('.');
|
||||||
if (dot === -1) return false;
|
if (dot === -1) return false;
|
||||||
return AUDIO_EXTS.has(p.slice(dot));
|
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) {
|
function normalizeJobId(value) {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
|
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() {
|
export default function ConverterPage() {
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
const [jobs, setJobs] = useState([]);
|
const [jobs, setJobs] = useState([]);
|
||||||
@@ -35,7 +56,20 @@ export default function ConverterPage() {
|
|||||||
const [selectedEntries, setSelectedEntries] = useState([]);
|
const [selectedEntries, setSelectedEntries] = useState([]);
|
||||||
const [jobModeVisible, setJobModeVisible] = useState(false);
|
const [jobModeVisible, setJobModeVisible] = useState(false);
|
||||||
const [audioMode, setAudioMode] = useState('individual');
|
const [audioMode, setAudioMode] = useState('individual');
|
||||||
|
const [jobModalAction, setJobModalAction] = useState('create');
|
||||||
|
const [assignTargetJobId, setAssignTargetJobId] = useState(null);
|
||||||
const [creatingJobs, setCreatingJobs] = useState(false);
|
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 () => {
|
const loadJobs = useCallback(async () => {
|
||||||
setLoadingJobs(true);
|
setLoadingJobs(true);
|
||||||
@@ -55,53 +89,91 @@ export default function ConverterPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadJobs]);
|
}, [loadJobs]);
|
||||||
|
|
||||||
useWebSocket((message) => {
|
useWebSocket({
|
||||||
if (!message?.type || !message?.payload) return;
|
onMessage: (message) => {
|
||||||
|
if (!message?.type || !message?.payload) return;
|
||||||
|
|
||||||
if (message.type === 'PIPELINE_PROGRESS') {
|
if (message.type === 'PIPELINE_PROGRESS') {
|
||||||
const payload = message.payload;
|
const payload = message.payload;
|
||||||
const jobId = normalizeJobId(payload?.activeJobId);
|
const jobId = normalizeJobId(payload?.activeJobId);
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
setJobProgress((prev) => ({
|
setJobProgress((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[jobId]: {
|
[jobId]: {
|
||||||
progress: payload?.progress ?? null,
|
progress: payload?.progress ?? null,
|
||||||
eta: payload?.eta ?? null,
|
eta: payload?.eta ?? null,
|
||||||
statusText: payload?.statusText ?? null,
|
statusText: payload?.statusText ?? null,
|
||||||
state: payload?.state ?? null
|
state: payload?.state ?? null
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'PIPELINE_UPDATE' || message.type === 'CONVERTER_SCAN_UPDATE') {
|
if (
|
||||||
loadJobs();
|
message.type === 'PIPELINE_UPDATE' ||
|
||||||
if (message.type === 'CONVERTER_SCAN_UPDATE') {
|
message.type === 'PIPELINE_STATE_CHANGED' ||
|
||||||
setExplorerRefreshToken((t) => t + 1);
|
message.type === 'CONVERTER_SCAN_UPDATE'
|
||||||
|
) {
|
||||||
|
loadJobs();
|
||||||
|
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 handleSelectionChange = (entries) => setSelectedEntries(entries || []);
|
||||||
|
|
||||||
const handleOpenJobModal = () => {
|
const handleOpenJobModal = () => {
|
||||||
const hasAudio = selectedEntries.some(isAudioEntry);
|
// Explorer expandiert Ordner bereits zu Einzel-Dateien
|
||||||
if (!hasAudio) {
|
const entries = [...selectedEntries];
|
||||||
// Keine Audio-Dateien (nur Videos/Ordner) → kein Modal, direkt Einzeljobs
|
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');
|
handleCreateJobsFromSelection('individual');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAudioMode('individual');
|
setAudioMode('individual');
|
||||||
|
setJobModalAction('create');
|
||||||
|
setAssignTargetJobId(assignableJobs[0]?.value || null);
|
||||||
setJobModeVisible(true);
|
setJobModeVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateJobsFromSelection = async (explicitMode) => {
|
const handleCreateJobsFromSelection = async (explicitAudioMode) => {
|
||||||
if (selectedEntries.length === 0) return;
|
const entries = jobEntriesRef.current;
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
const resolvedAudioMode = typeof explicitAudioMode === 'string' ? explicitAudioMode : audioMode;
|
||||||
setCreatingJobs(true);
|
setCreatingJobs(true);
|
||||||
setJobModeVisible(false);
|
setJobModeVisible(false);
|
||||||
try {
|
try {
|
||||||
const relPaths = selectedEntries.map((e) => e.relPath).filter(Boolean);
|
const relPaths = entries.map((e) => e.relPath).filter(Boolean);
|
||||||
const result = await api.converterCreateJobsFromSelection(relPaths, explicitMode ?? audioMode);
|
const result = await api.converterCreateJobsFromSelection(relPaths, resolvedAudioMode);
|
||||||
const newJobs = result?.jobs || [];
|
const newJobs = result?.jobs || [];
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
@@ -109,6 +181,8 @@ export default function ConverterPage() {
|
|||||||
detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`,
|
detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`,
|
||||||
life: 3500
|
life: 3500
|
||||||
});
|
});
|
||||||
|
jobEntriesRef.current = [];
|
||||||
|
setJobEntries([]);
|
||||||
setSelectedEntries([]);
|
setSelectedEntries([]);
|
||||||
setExplorerRefreshToken((t) => t + 1);
|
setExplorerRefreshToken((t) => t + 1);
|
||||||
await loadJobs();
|
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) => {
|
const handleUploaded = (folders) => {
|
||||||
setExplorerRefreshToken((t) => t + 1);
|
setExplorerRefreshToken((t) => t + 1);
|
||||||
if (folders?.length > 0) {
|
if (folders?.length > 0) {
|
||||||
// Explorer in den ersten hochgeladenen Ordner navigieren
|
|
||||||
setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() });
|
setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() });
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'success',
|
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) => {
|
const handleJobDeleted = (jobId) => {
|
||||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||||
setJobProgress((prev) => {
|
setJobProgress((prev) => {
|
||||||
@@ -155,12 +402,32 @@ export default function ConverterPage() {
|
|||||||
delete next[jobId];
|
delete next[jobId];
|
||||||
return next;
|
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 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 activeJobs = jobs.filter((j) => !['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
|
||||||
const finishedJobs = jobs.filter((j) => ['DONE', '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 = (
|
const jobsCardHeader = (
|
||||||
<div className="converter-card-header">
|
<div className="converter-card-header">
|
||||||
@@ -192,6 +459,7 @@ export default function ConverterPage() {
|
|||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
refreshToken={explorerRefreshToken}
|
refreshToken={explorerRefreshToken}
|
||||||
navigateToPath={explorerNavigateTo}
|
navigateToPath={explorerNavigateTo}
|
||||||
|
onAssignmentChanged={loadJobs}
|
||||||
/>
|
/>
|
||||||
{selectedEntries.length > 0 && (
|
{selectedEntries.length > 0 && (
|
||||||
<div className="converter-selection-bar">
|
<div className="converter-selection-bar">
|
||||||
@@ -210,46 +478,28 @@ export default function ConverterPage() {
|
|||||||
|
|
||||||
{/* Jobs */}
|
{/* Jobs */}
|
||||||
<Card header={jobsCardHeader}>
|
<Card header={jobsCardHeader}>
|
||||||
{jobs.length === 0 ? (
|
{activeJobs.length === 0 ? (
|
||||||
<p className="converter-jobs-empty-hint">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="dashboard-job-list converter-jobs-list">
|
||||||
{activeJobs.length > 0 && (
|
{activeJobs.map((job) => (
|
||||||
<>
|
<ConverterJobCard
|
||||||
<p className="converter-jobs-group-label">Aktiv</p>
|
key={job.id}
|
||||||
<div className="converter-jobs-list">
|
job={job}
|
||||||
{activeJobs.map((job) => (
|
jobProgress={jobProgress[job.id] || null}
|
||||||
<ConverterJobCard
|
isExpanded={Number(job.id) === Number(expandedJobId)}
|
||||||
key={job.id}
|
onExpand={() => setExpandedJobId(Number(job.id))}
|
||||||
job={job}
|
onCollapse={() => setExpandedJobId(null)}
|
||||||
jobProgress={jobProgress[job.id] || null}
|
onStarted={handleJobStarted}
|
||||||
onStarted={handleJobStarted}
|
onDeleted={handleJobDeleted}
|
||||||
onDeleted={handleJobDeleted}
|
onCancelled={handleJobCancelled}
|
||||||
onCancelled={handleJobCancelled}
|
onOpenDetails={handleOpenJobDetails}
|
||||||
/>
|
onInputsChanged={handleJobInputsChanged}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
</>
|
</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>
|
</Card>
|
||||||
|
|
||||||
@@ -260,39 +510,83 @@ export default function ConverterPage() {
|
|||||||
|
|
||||||
<JobModeDialog
|
<JobModeDialog
|
||||||
visible={jobModeVisible}
|
visible={jobModeVisible}
|
||||||
entries={selectedEntries}
|
entries={jobEntries}
|
||||||
audioMode={audioMode}
|
audioMode={audioMode}
|
||||||
onAudioModeChange={setAudioMode}
|
onAudioModeChange={setAudioMode}
|
||||||
|
action={jobModalAction}
|
||||||
|
onActionChange={setJobModalAction}
|
||||||
|
assignableJobs={assignableJobs}
|
||||||
|
assignJobId={assignTargetJobId}
|
||||||
|
onAssignJobIdChange={setAssignTargetJobId}
|
||||||
busy={creatingJobs}
|
busy={creatingJobs}
|
||||||
onConfirm={handleCreateJobsFromSelection}
|
onConfirm={handleJobModeConfirm}
|
||||||
onHide={() => setJobModeVisible(false)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Job-Modus-Dialog ──────────────────────────────────────────────────────────
|
// ── 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 audioEntries = entries.filter(isAudioEntry);
|
||||||
const nonAudioEntries = entries.filter((e) => !isAudioEntry(e));
|
const videoEntries = entries.filter(isVideoEntry);
|
||||||
const hasAudio = audioEntries.length > 0;
|
const otherEntries = entries.filter((e) => !isAudioEntry(e) && !isVideoEntry(e));
|
||||||
const hasNonAudio = nonAudioEntries.length > 0;
|
|
||||||
const onlyAudio = hasAudio && !hasNonAudio;
|
|
||||||
const mixed = hasAudio && hasNonAudio;
|
|
||||||
|
|
||||||
// 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 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 = (
|
const footer = (
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
|
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
|
||||||
<Button
|
<Button
|
||||||
label={`${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`}
|
label={confirmLabel}
|
||||||
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
|
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
|
||||||
disabled={busy}
|
disabled={busy || (isAssignMode && !assignJobId)}
|
||||||
onClick={onConfirm}
|
onClick={() => onConfirm({ action, audioMode, jobId: assignJobId })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -306,26 +600,27 @@ function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, o
|
|||||||
style={{ width: '440px' }}
|
style={{ width: '440px' }}
|
||||||
modal
|
modal
|
||||||
>
|
>
|
||||||
{/* Nur Videos / Ordner gewählt */}
|
{/* Videos / Sonstige immer individual */}
|
||||||
{!hasAudio && (
|
{(hasVideo || hasOther) && (
|
||||||
<p style={{ margin: 0, lineHeight: 1.6 }}>
|
<p style={{ marginTop: 0, marginBottom: hasAudio ? 8 : 0, lineHeight: 1.6 }}>
|
||||||
Für jede ausgewählte Datei / jeden Ordner wird ein eigener Job angelegt.
|
<strong>Videos / Sonstige ({videoEntries.length + otherEntries.length}):</strong> Je eine Datei ein eigener Job.
|
||||||
<br />
|
|
||||||
<strong>{nonAudioEntries.length} Job{nonAudioEntries.length !== 1 ? 's' : ''}</strong> werden erstellt.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nur Audio gewählt */}
|
{hasAudio && (hasVideo || hasOther) && <Divider style={{ margin: '8px 0' }} />}
|
||||||
{onlyAudio && (
|
|
||||||
|
{/* Audio: Abfrage */}
|
||||||
|
{hasAudio && (
|
||||||
<div>
|
<div>
|
||||||
<p style={{ marginTop: 0, marginBottom: 12 }}>
|
<p style={{ marginTop: 0, marginBottom: 12 }}>
|
||||||
Wie sollen die <strong>{audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''}</strong> verarbeitet werden?
|
Wie sollen die <strong>{audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''}</strong> verarbeitet werden?
|
||||||
</p>
|
</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' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||||
<RadioButton
|
<RadioButton
|
||||||
value="individual"
|
value="individual"
|
||||||
checked={audioMode === 'individual'}
|
checked={audioMode === 'individual'}
|
||||||
|
disabled={isAssignMode}
|
||||||
onChange={() => onAudioModeChange('individual')}
|
onChange={() => onAudioModeChange('individual')}
|
||||||
/>
|
/>
|
||||||
<span>Für jede Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
|
<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
|
<RadioButton
|
||||||
value="shared"
|
value="shared"
|
||||||
checked={audioMode === 'shared'}
|
checked={audioMode === 'shared'}
|
||||||
|
disabled={isAssignMode}
|
||||||
onChange={() => onAudioModeChange('shared')}
|
onChange={() => onAudioModeChange('shared')}
|
||||||
/>
|
/>
|
||||||
<span>Ein gemeinsamer Job für alle Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gemischte Auswahl */}
|
{hasAssignableJobs && (
|
||||||
{mixed && (
|
<>
|
||||||
<div>
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
<p style={{ marginTop: 0, marginBottom: 8 }}>
|
<div>
|
||||||
<strong>Videos/Ordner ({nonAudioEntries.length}):</strong> Für jede Datei ein eigener Job.
|
<p style={{ marginTop: 0, marginBottom: 10 }}>
|
||||||
</p>
|
Optional: Dateien einem <strong>nicht gestarteten Job</strong> direkt zuweisen.
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
</p>
|
||||||
<p style={{ marginBottom: 10 }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
|
||||||
<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' }}>
|
|
||||||
<RadioButton
|
<RadioButton
|
||||||
value="individual"
|
value="create"
|
||||||
checked={audioMode === 'individual'}
|
checked={!isAssignMode}
|
||||||
onChange={() => onAudioModeChange('individual')}
|
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>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
|
||||||
<RadioButton
|
<RadioButton
|
||||||
value="shared"
|
value="assign"
|
||||||
checked={audioMode === 'shared'}
|
checked={isAssignMode}
|
||||||
onChange={() => onAudioModeChange('shared')}
|
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>
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
value={assignJobId}
|
||||||
|
options={assignableJobs}
|
||||||
|
onChange={(e) => onAssignJobIdChange(e.value)}
|
||||||
|
placeholder="Job auswählen …"
|
||||||
|
disabled={!isAssignMode}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -832,22 +832,25 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
const existingContext = currentPipeline?.context && typeof currentPipeline.context === 'object'
|
const existingContext = currentPipeline?.context && typeof currentPipeline.context === 'object'
|
||||||
? currentPipeline.context
|
? currentPipeline.context
|
||||||
: {};
|
: {};
|
||||||
|
const mergedCurrentContext = {
|
||||||
|
...computedContext,
|
||||||
|
...existingContext,
|
||||||
|
rawPath: existingContext.rawPath || computedContext.rawPath,
|
||||||
|
outputPath: existingContext.outputPath || computedContext.outputPath,
|
||||||
|
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
||||||
|
? existingContext.tracks
|
||||||
|
: computedContext.tracks,
|
||||||
|
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
|
||||||
|
canRestartEncodeFromLastSettings:
|
||||||
|
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 {
|
return {
|
||||||
...currentPipeline,
|
...currentPipeline,
|
||||||
context: {
|
context: mergedCurrentContext
|
||||||
...computedContext,
|
|
||||||
...existingContext,
|
|
||||||
rawPath: existingContext.rawPath || computedContext.rawPath,
|
|
||||||
outputPath: existingContext.outputPath || computedContext.outputPath,
|
|
||||||
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
|
||||||
? existingContext.tracks
|
|
||||||
: computedContext.tracks,
|
|
||||||
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
|
|
||||||
canRestartEncodeFromLastSettings:
|
|
||||||
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
|
|
||||||
canRestartReviewFromRaw:
|
|
||||||
existingContext.canRestartReviewFromRaw ?? computedContext.canRestartReviewFromRaw
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,6 +881,8 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
|
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
|
||||||
}
|
}
|
||||||
: computedContext;
|
: computedContext;
|
||||||
|
// The card always represents `job.id`; never let stale live context override it.
|
||||||
|
mergedContext.jobId = jobId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
|
state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
|
||||||
@@ -1073,6 +1078,21 @@ export default function DashboardPage({
|
|||||||
setQueueState(normalizeQueue(queueResponse.value?.queue));
|
setQueueState(normalizeQueue(queueResponse.value?.queue));
|
||||||
}
|
}
|
||||||
const shouldDisplayOnDashboard = (job) => {
|
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();
|
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
|
||||||
if (!dashboardStatuses.has(normalizedStatus)) {
|
if (!dashboardStatuses.has(normalizedStatus)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1980,7 +2000,7 @@ export default function DashboardPage({
|
|||||||
|
|
||||||
setJobBusy(normalizedJobId, true);
|
setJobBusy(normalizedJobId, true);
|
||||||
try {
|
try {
|
||||||
const response = await api.restartReviewFromRaw(normalizedJobId);
|
const response = await api.restartReviewFromRaw(normalizedJobId, { reuseCurrentJob: true });
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
@@ -2935,6 +2955,7 @@ export default function DashboardPage({
|
|||||||
})()}
|
})()}
|
||||||
{!isCdJob && !isAudiobookJob ? (
|
{!isCdJob && !isAudiobookJob ? (
|
||||||
<PipelineStatusCard
|
<PipelineStatusCard
|
||||||
|
jobId={jobId}
|
||||||
pipeline={pipelineForJob}
|
pipeline={pipelineForJob}
|
||||||
onAnalyze={handleAnalyze}
|
onAnalyze={handleAnalyze}
|
||||||
onReanalyze={handleReanalyze}
|
onReanalyze={handleReanalyze}
|
||||||
@@ -3041,6 +3062,9 @@ export default function DashboardPage({
|
|||||||
const hasScriptSummary = hasQueueScriptSummary(item);
|
const hasScriptSummary = hasQueueScriptSummary(item);
|
||||||
const detailKey = buildRunningQueueScriptKey(item?.jobId);
|
const detailKey = buildRunningQueueScriptKey(item?.jobId);
|
||||||
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
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 (
|
return (
|
||||||
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
||||||
<div className="pipeline-queue-item running queue-job-item">
|
<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}
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
</strong>
|
</strong>
|
||||||
<small>{getStatusLabel(item.status)}</small>
|
<small>{getStatusLabel(item.status)}</small>
|
||||||
|
{clampedQueueProgress > 0 && (
|
||||||
|
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasScriptSummary ? (
|
{hasScriptSummary ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||||
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
|
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
|
||||||
|
const [pendingRestartRow, setPendingRestartRow] = useState(null);
|
||||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||||
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||||
@@ -589,6 +590,25 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
setSelectedJob(response.job);
|
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 ─────────────────────────────────────────────────
|
// ── Conflict Modal Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const mergeOutputFoldersForJob = (job, folders = []) => {
|
const mergeOutputFoldersForJob = (job, folders = []) => {
|
||||||
@@ -681,13 +701,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
try {
|
try {
|
||||||
const response = await api.restartEncodeWithLastSettings(job.id, options);
|
const response = await api.restartEncodeWithLastSettings(job.id, options);
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
|
const replacementJobId = normalizeJobId(result?.jobId);
|
||||||
if (result.queued) {
|
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 });
|
toastRef.current?.show({ severity: 'info', summary: 'Encode-Neustart in Queue', detail: result.queuePosition > 0 ? `Position ${result.queuePosition}` : 'In der Warteschlange eingeplant.', life: 3500 });
|
||||||
} else {
|
} else {
|
||||||
toastRef.current?.show({ severity: 'success', summary: 'Encode-Neustart gestartet', detail: 'Letzte bestätigte Einstellungen werden verwendet.', life: 3500 });
|
toastRef.current?.show({ severity: 'success', summary: 'Encode-Neustart gestartet', detail: 'Letzte bestätigte Einstellungen werden verwendet.', life: 3500 });
|
||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
await refreshDetailIfOpen(job.id);
|
await refreshDetailAfterReplacement(job.id, replacementJobId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -703,7 +724,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
}
|
}
|
||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
try {
|
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({
|
toastRef.current?.show({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Review-Neustart',
|
summary: 'Review-Neustart',
|
||||||
@@ -711,7 +734,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
life: 3500
|
life: 3500
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
await refreshDetailIfOpen(job.id);
|
await refreshDetailAfterReplacement(job.id, replacementJobId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -938,6 +961,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReencode = async (row) => {
|
const handleReencode = async (row) => {
|
||||||
|
const mediaType = resolveMediaType(row);
|
||||||
|
if (mediaType === 'bluray' || mediaType === 'dvd') {
|
||||||
|
setPendingRestartRow(row);
|
||||||
|
handleAssignOmdb(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
|
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1073,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
setMetadataDialogContext(null);
|
setMetadataDialogContext(null);
|
||||||
await load();
|
await load();
|
||||||
await refreshDetailIfOpen(payload.jobId);
|
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) {
|
} catch (error) {
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
|
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1769,6 +1806,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
onHide={() => {
|
onHide={() => {
|
||||||
setMetadataDialogVisible(false);
|
setMetadataDialogVisible(false);
|
||||||
setMetadataDialogContext(null);
|
setMetadataDialogContext(null);
|
||||||
|
setPendingRestartRow(null);
|
||||||
}}
|
}}
|
||||||
onSubmit={handleOmdbSubmit}
|
onSubmit={handleOmdbSubmit}
|
||||||
onSearch={handleOmdbSearch}
|
onSearch={handleOmdbSearch}
|
||||||
|
|||||||
+140
-18
@@ -2575,6 +2575,16 @@ body {
|
|||||||
align-items: center;
|
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 {
|
.history-dv-layout-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -3475,6 +3485,23 @@ body {
|
|||||||
gap: 0.15rem;
|
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 {
|
.track-action-note {
|
||||||
margin-left: 1.7rem;
|
margin-left: 1.7rem;
|
||||||
color: var(--rip-muted);
|
color: var(--rip-muted);
|
||||||
@@ -4929,7 +4956,7 @@ body {
|
|||||||
|
|
||||||
.explorer {
|
.explorer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 320px 1fr;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border: 1px solid var(--rip-border, #d9bc8d);
|
border: 1px solid var(--rip-border, #d9bc8d);
|
||||||
@@ -5070,7 +5097,7 @@ body {
|
|||||||
|
|
||||||
.explorer-row {
|
.explorer-row {
|
||||||
display: grid;
|
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;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -5086,6 +5113,14 @@ body {
|
|||||||
border-left: 3px solid var(--primary-color);
|
border-left: 3px solid var(--primary-color);
|
||||||
padding-left: 9px;
|
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 {
|
.explorer-row.header {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -5135,6 +5170,39 @@ body {
|
|||||||
}
|
}
|
||||||
.explorer-row.header .row-checkbox { pointer-events: none; }
|
.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 {
|
.footer-count {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--rip-ink, #2f180f);
|
color: var(--rip-ink, #2f180f);
|
||||||
@@ -5216,6 +5284,20 @@ body {
|
|||||||
}
|
}
|
||||||
.tree-caret.disabled { width: 18px; flex: 0 0 18px; }
|
.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-icon.folder { color: var(--rip-brown-700, #6f3922); }
|
||||||
|
|
||||||
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -5293,9 +5375,7 @@ body {
|
|||||||
/* ===== Job Card ===== */
|
/* ===== Job Card ===== */
|
||||||
|
|
||||||
.converter-jobs-list {
|
.converter-jobs-list {
|
||||||
display: flex;
|
gap: 0.6rem;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.converter-jobs-empty {
|
.converter-jobs-empty {
|
||||||
@@ -5305,15 +5385,16 @@ body {
|
|||||||
|
|
||||||
.converter-job-card {
|
.converter-job-card {
|
||||||
border: 1px solid var(--rip-border, #d9bc8d);
|
border: 1px solid var(--rip-border, #d9bc8d);
|
||||||
border-radius: 8px;
|
border-radius: 0.6rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.6rem 0.7rem;
|
||||||
background: var(--rip-panel, #fffaf1);
|
background: var(--rip-panel-soft, #fdf5e7);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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);
|
border-color: var(--green-300, #74c69d);
|
||||||
background: #f6fff9;
|
background: #f6fff9;
|
||||||
}
|
}
|
||||||
@@ -5330,7 +5411,7 @@ body {
|
|||||||
.converter-job-card-header {
|
.converter-job-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.converter-job-card-title-row {
|
.converter-job-card-title-row {
|
||||||
@@ -5342,7 +5423,7 @@ body {
|
|||||||
|
|
||||||
.converter-job-card-title {
|
.converter-job-card-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9rem;
|
font-size: 0.92rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -5351,7 +5432,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.converter-job-card-meta {
|
.converter-job-card-meta {
|
||||||
font-size: 0.78rem;
|
font-size: 0.8rem;
|
||||||
color: var(--rip-muted, #6a4d38);
|
color: var(--rip-muted, #6a4d38);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5364,8 +5445,10 @@ body {
|
|||||||
.converter-job-card-progress-meta {
|
.converter-job-card-progress-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.75rem;
|
font-size: 0.78rem;
|
||||||
color: var(--rip-muted, #6a4d38);
|
color: var(--rip-muted, #6a4d38);
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.converter-job-card-error {
|
.converter-job-card-error {
|
||||||
@@ -5377,7 +5460,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.converter-job-card-output {
|
.converter-job-card-output {
|
||||||
font-size: 0.78rem;
|
font-size: 0.8rem;
|
||||||
color: var(--rip-muted, #6a4d38);
|
color: var(--rip-muted, #6a4d38);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5385,13 +5468,13 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline-Konfiguration für READY_TO_START Jobs */
|
/* Inline-Konfiguration für READY_TO_START Jobs */
|
||||||
.converter-job-card.is-ready {
|
.converter-job-card.is-ready {
|
||||||
border-color: var(--amber-400, #f59e0b);
|
border-color: #d9b26d;
|
||||||
background: var(--surface-50, #fafaf9);
|
background: #fff7e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cjc-config {
|
.cjc-config {
|
||||||
@@ -5438,3 +5521,42 @@ body {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding-top: 0.25rem;
|
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",
|
"name": "ripster",
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.0-15",
|
"version": "0.12.0-16",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user