0.12.0-16 misc fixes
This commit is contained in:
@@ -5,7 +5,7 @@ const path = require('path');
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir, sanitizeFileName } = require('../utils/files');
|
||||
const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files');
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']);
|
||||
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
|
||||
@@ -74,6 +74,20 @@ function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {
|
||||
args.push('-codec:a', 'copy');
|
||||
}
|
||||
|
||||
// Metadata tags
|
||||
const trackTitle = opts.trackTitle ? String(opts.trackTitle).trim() : null;
|
||||
const trackArtist = (opts.trackArtist || opts.albumArtist)
|
||||
? String(opts.trackArtist || opts.albumArtist).trim() : null;
|
||||
const albumTitle = opts.albumTitle ? String(opts.albumTitle).trim() : null;
|
||||
const albumYear = opts.albumYear ? String(opts.albumYear).trim() : null;
|
||||
const trackNumber = opts.trackNumber != null ? String(opts.trackNumber) : null;
|
||||
|
||||
if (trackTitle) args.push('-metadata', `title=${trackTitle}`);
|
||||
if (trackArtist) args.push('-metadata', `artist=${trackArtist}`);
|
||||
if (albumTitle) args.push('-metadata', `album=${albumTitle}`);
|
||||
if (albumYear) args.push('-metadata', `date=${albumYear}`);
|
||||
if (trackNumber) args.push('-metadata', `track=${trackNumber}`);
|
||||
|
||||
args.push(outputFile);
|
||||
return { cmd: ffmpegCmd, args };
|
||||
}
|
||||
@@ -345,12 +359,110 @@ class ConverterPlugin extends SourcePlugin {
|
||||
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
|
||||
const formatOptions = encodePlan.audioFormatOptions || {};
|
||||
const isFolder = Boolean(encodePlan.isFolder);
|
||||
const isSharedAudio = Boolean(encodePlan.isSharedAudio)
|
||||
|| (Array.isArray(encodePlan.inputPaths) && encodePlan.inputPaths.length > 0 && !isFolder);
|
||||
|
||||
// Metadata from user config
|
||||
const planMetadata = encodePlan.metadata || {};
|
||||
const planTracks = Array.isArray(encodePlan.tracks) ? encodePlan.tracks : [];
|
||||
|
||||
if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) {
|
||||
throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`);
|
||||
}
|
||||
|
||||
if (isFolder) {
|
||||
/** Build per-track metadata opts merged with album-level metadata */
|
||||
function trackMetaOpts(position, defaultBaseName) {
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
return {
|
||||
...formatOptions,
|
||||
trackTitle: trackMeta.title || defaultBaseName || null,
|
||||
trackArtist: trackMeta.artist || planMetadata.albumArtist || null,
|
||||
albumTitle: planMetadata.albumTitle || null,
|
||||
albumArtist: planMetadata.albumArtist || null,
|
||||
albumYear: planMetadata.albumYear ? String(planMetadata.albumYear) : null,
|
||||
trackNumber: position
|
||||
};
|
||||
}
|
||||
|
||||
/** Build output filename with optional track number + title */
|
||||
function buildOutputFileName(position, trackMeta, defaultBaseName) {
|
||||
const numStr = String(position).padStart(2, '0');
|
||||
const title = trackMeta?.title ? sanitizeFileName(trackMeta.title) : null;
|
||||
const templateRaw = String(encodePlan.audioTrackTemplate || '').trim();
|
||||
if (templateRaw) {
|
||||
const rendered = renderTemplate(templateRaw, {
|
||||
trackNr: numStr,
|
||||
trackNumber: String(position),
|
||||
title: title || sanitizeFileName(defaultBaseName) || `Track ${numStr}`,
|
||||
artist: sanitizeFileName(trackMeta?.artist || planMetadata.albumArtist || '') || 'unknown',
|
||||
album: sanitizeFileName(planMetadata.albumTitle || '') || 'unknown',
|
||||
year: planMetadata.albumYear || 'unknown'
|
||||
});
|
||||
const sanitized = sanitizeFileName(rendered);
|
||||
if (sanitized) {
|
||||
return `${sanitized}.${outputFormat}`;
|
||||
}
|
||||
}
|
||||
if (title) return `${numStr} - ${title}.${outputFormat}`;
|
||||
return `${sanitizeFileName(defaultBaseName) || numStr}.${outputFormat}`;
|
||||
}
|
||||
|
||||
if (isSharedAudio) {
|
||||
// Freigegebene Audio-Dateien (mehrere Einzeldateien in einem Job)
|
||||
const inputPaths = encodePlan.inputPaths || [];
|
||||
if (inputPaths.length === 0) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): inputPaths leer für shared-audio-Job');
|
||||
}
|
||||
if (!outputDir) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für shared-audio-Job');
|
||||
}
|
||||
ensureDir(outputDir);
|
||||
|
||||
ctx.logger.info('converter:encode:audio:shared:start', {
|
||||
jobId: job?.id, fileCount: inputPaths.length, outputDir, format: outputFormat
|
||||
});
|
||||
|
||||
for (let i = 0; i < inputPaths.length; i++) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const inputFile = inputPaths[i];
|
||||
const fileName = path.basename(inputFile);
|
||||
const baseName = path.basename(fileName, path.extname(fileName));
|
||||
const position = i + 1;
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
const outputFileName = buildOutputFileName(position, trackMeta, baseName);
|
||||
const outputFile = path.join(outputDir, outputFileName);
|
||||
|
||||
ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
|
||||
ctx.emitProgress(
|
||||
Math.round((i / inputPaths.length) * 100),
|
||||
`Audio: ${position}/${inputPaths.length} — ${fileName}`
|
||||
);
|
||||
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputFile, outputFile, outputFormat,
|
||||
trackMetaOpts(position, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
jobId: job?.id,
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info('converter:encode:audio:shared:done', {
|
||||
jobId: job?.id, fileCount: inputPaths.length, outputDir
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: inputPaths.length };
|
||||
} else if (isFolder) {
|
||||
// Ordner-Modus: alle Audio-Dateien im Ordner konvertieren
|
||||
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
|
||||
|
||||
@@ -373,15 +485,21 @@ class ConverterPlugin extends SourcePlugin {
|
||||
const fileName = audioFiles[i];
|
||||
const inputFile = path.join(inputPath, fileName);
|
||||
const baseName = path.basename(fileName, path.extname(fileName));
|
||||
const outputFile = path.join(outputDir, `${sanitizeFileName(baseName)}.${outputFormat}`);
|
||||
const position = i + 1;
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
const outputFileName = buildOutputFileName(position, trackMeta, baseName);
|
||||
const outputFile = path.join(outputDir, outputFileName);
|
||||
|
||||
ctx.logger.info(`converter:encode:audio:track:${i + 1}`, { inputFile, outputFile });
|
||||
ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
|
||||
ctx.emitProgress(
|
||||
Math.round((i / audioFiles.length) * 100),
|
||||
`Audio: ${i + 1}/${audioFiles.length} — ${fileName}`
|
||||
`Audio: ${position}/${audioFiles.length} — ${fileName}`
|
||||
);
|
||||
|
||||
const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, outputFormat, formatOptions);
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputFile, outputFile, outputFormat,
|
||||
trackMetaOpts(position, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
@@ -409,7 +527,11 @@ class ConverterPlugin extends SourcePlugin {
|
||||
});
|
||||
ctx.emitProgress(0, `Audio: Encoding läuft …`);
|
||||
|
||||
const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputPath, outputPath, outputFormat, formatOptions);
|
||||
const baseName = path.basename(inputPath, path.extname(inputPath));
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputPath, outputPath, outputFormat,
|
||||
trackMetaOpts(1, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
|
||||
@@ -146,6 +146,90 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/assign-files
|
||||
* Body: { relPaths: string[] }
|
||||
* Fügt ausgewählte Dateien einem bestehenden (nicht gestarteten) Converter-Job hinzu.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/assign-files',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (relPaths.length === 0) {
|
||||
return res.status(400).json({ detail: 'Keine Dateien übergeben.' });
|
||||
}
|
||||
logger.info('post:jobs:assign-files', { jobId, count: relPaths.length });
|
||||
const result = await pipelineService.assignConverterFilesToJob(jobId, relPaths);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/remove-file
|
||||
* Body: { relPath: string }
|
||||
* Entfernt eine Datei aus einem bestehenden Converter-Job.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/remove-file',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (!relPath) {
|
||||
return res.status(400).json({ detail: 'relPath fehlt.' });
|
||||
}
|
||||
logger.info('post:jobs:remove-file', { jobId, relPath });
|
||||
const result = await pipelineService.removeConverterFileFromJob(jobId, relPath);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/remove-input
|
||||
* Body: { inputPath: string }
|
||||
* Entfernt eine Datei aus einem Converter-Job anhand des absoluten Input-Pfads.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/remove-input',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const inputPath = String(req.body?.inputPath || '').trim();
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (!inputPath) {
|
||||
return res.status(400).json({ detail: 'inputPath fehlt.' });
|
||||
}
|
||||
logger.info('post:jobs:remove-input', { jobId, inputPath });
|
||||
const result = await pipelineService.removeConverterInputFromJob(jobId, inputPath);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/config
|
||||
* Body: partial config draft (outputFormat, presets, metadata, tracks, MusicBrainz-UI-Stand)
|
||||
* Speichert den Draft für READY_TO_START Jobs persistent im encode_plan_json.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/config',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
logger.debug('post:jobs:config', { jobId });
|
||||
const result = await pipelineService.updateConverterJobConfig(jobId, req.body || {});
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
// ── Datei-Operationen (reines FS, keine DB) ───────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -299,7 +383,7 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:jobs:cancel', { jobId });
|
||||
const result = await pipelineService.cancelJob(jobId);
|
||||
const result = await pipelineService.cancel(jobId);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
@@ -313,7 +397,8 @@ router.delete(
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('delete:jobs', { jobId });
|
||||
await historyService.deleteJob(jobId);
|
||||
await historyService.deleteJob(jobId, 'none', { includeRelated: false });
|
||||
await converterScanService.clearAssignmentsForJob(jobId);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -385,8 +385,15 @@ router.post(
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:restart-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders });
|
||||
const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob);
|
||||
logger.info('post:restart-review', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
keepBoth,
|
||||
reuseCurrentJob,
|
||||
deleteFolderCount: deleteFolders.length
|
||||
});
|
||||
const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders, reuseCurrentJob });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -381,6 +381,35 @@ async function runProcessTracked({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir.
|
||||
* Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files).
|
||||
*/
|
||||
function getSortedPlainWavFiles(rawWavDir) {
|
||||
try {
|
||||
return fs.readdirSync(rawWavDir)
|
||||
.filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the WAV file path for a given track position.
|
||||
* Primary: track01.cdda.wav (cdparanoia format)
|
||||
* Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames)
|
||||
*/
|
||||
function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) {
|
||||
const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`);
|
||||
if (fs.existsSync(cddaPath)) return cddaPath;
|
||||
if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) {
|
||||
const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]);
|
||||
if (fs.existsSync(fallback)) return fallback;
|
||||
}
|
||||
return cddaPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rip and encode a CD.
|
||||
*
|
||||
@@ -458,10 +487,12 @@ async function ripAndEncode(options) {
|
||||
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
|
||||
|
||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||
const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null;
|
||||
|
||||
if (skipRip) {
|
||||
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
|
||||
for (const track of tracksToRip) {
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
|
||||
}
|
||||
@@ -550,7 +581,7 @@ async function ripAndEncode(options) {
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
@@ -582,7 +613,7 @@ async function ripAndEncode(options) {
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
|
||||
|
||||
@@ -251,14 +251,120 @@ async function getEntryByRelPath(relPath) {
|
||||
return row || null;
|
||||
}
|
||||
|
||||
async function markEntryAsJob(relPath, jobId) {
|
||||
async function setEntryJobAssignment(relPath, jobId) {
|
||||
const normalizedRelPath = normalizeRelPath(relPath);
|
||||
if (normalizedRelPath === null || normalizedRelPath === '') {
|
||||
throw makeError('Ungültiger relPath für Job-Zuweisung.', 400);
|
||||
}
|
||||
const normalizedJobId = Number(jobId);
|
||||
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
||||
throw makeError('Ungültige jobId für Job-Zuweisung.', 400);
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const existing = await db.get(
|
||||
`SELECT entry_type, file_size, detected_media_type, detected_format
|
||||
FROM converter_scan_entries
|
||||
WHERE rel_path = ?`,
|
||||
[normalizedRelPath]
|
||||
);
|
||||
|
||||
let entryType = String(existing?.entry_type || 'file').trim() || 'file';
|
||||
let fileSize = existing?.file_size ?? null;
|
||||
let detectedMediaType = existing?.detected_media_type ?? null;
|
||||
let detectedFormat = existing?.detected_format ?? null;
|
||||
|
||||
const rawDir = await getRawDir();
|
||||
const absPath = rawDir ? path.join(rawDir, normalizedRelPath) : null;
|
||||
if (absPath && fs.existsSync(absPath)) {
|
||||
try {
|
||||
const stat = fs.statSync(absPath);
|
||||
entryType = stat.isDirectory() ? 'directory' : 'file';
|
||||
fileSize = stat.isFile() ? stat.size : null;
|
||||
if (stat.isFile()) {
|
||||
const fileName = path.basename(normalizedRelPath);
|
||||
detectedMediaType = detectMediaType(fileName);
|
||||
detectedFormat = detectFormat(fileName);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Keep existing metadata values if stat/read fails.
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO converter_scan_entries (
|
||||
rel_path,
|
||||
entry_type,
|
||||
file_size,
|
||||
detected_media_type,
|
||||
detected_format,
|
||||
job_id,
|
||||
last_seen_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(rel_path) DO UPDATE SET
|
||||
entry_type = excluded.entry_type,
|
||||
file_size = excluded.file_size,
|
||||
detected_media_type = excluded.detected_media_type,
|
||||
detected_format = excluded.detected_format,
|
||||
job_id = excluded.job_id,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`,
|
||||
[
|
||||
normalizedRelPath,
|
||||
entryType,
|
||||
fileSize,
|
||||
detectedMediaType,
|
||||
detectedFormat,
|
||||
Math.trunc(normalizedJobId)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function clearEntryJobAssignment(relPath, expectedJobId = null) {
|
||||
const normalizedRelPath = normalizeRelPath(relPath);
|
||||
if (normalizedRelPath === null || normalizedRelPath === '') {
|
||||
throw makeError('Ungültiger relPath für Job-Entfernung.', 400);
|
||||
}
|
||||
const db = await getDb();
|
||||
const normalizedExpected = Number(expectedJobId);
|
||||
if (Number.isFinite(normalizedExpected) && normalizedExpected > 0) {
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ? AND job_id = ?`,
|
||||
[normalizedRelPath, Math.trunc(normalizedExpected)]
|
||||
);
|
||||
return;
|
||||
}
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ?`,
|
||||
[normalizedRelPath]
|
||||
);
|
||||
}
|
||||
|
||||
async function clearAssignmentsForJob(jobId) {
|
||||
const normalizedJobId = Number(jobId);
|
||||
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
||||
throw makeError('Ungültige jobId für Job-Entfernung.', 400);
|
||||
}
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = ? WHERE rel_path = ?`,
|
||||
[jobId, relPath]
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`,
|
||||
[Math.trunc(normalizedJobId)]
|
||||
);
|
||||
}
|
||||
|
||||
async function assignEntriesToJob(relPaths, jobId) {
|
||||
const normalizedPaths = Array.isArray(relPaths) ? relPaths : [];
|
||||
for (const relPath of normalizedPaths) {
|
||||
await setEntryJobAssignment(relPath, jobId);
|
||||
}
|
||||
}
|
||||
|
||||
async function markEntryAsJob(relPath, jobId) {
|
||||
await setEntryJobAssignment(relPath, jobId);
|
||||
}
|
||||
|
||||
async function getRawDir() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
||||
@@ -480,7 +586,7 @@ async function createFolder(parentRelPath, name) {
|
||||
|
||||
const TREE_MAX_DEPTH = 8;
|
||||
|
||||
function buildRawTree(rawDir, relPath, depth) {
|
||||
function buildRawTree(rawDir, relPath, depth, assignments = new Map()) {
|
||||
if (depth >= TREE_MAX_DEPTH) return [];
|
||||
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
|
||||
let dirents;
|
||||
@@ -503,19 +609,23 @@ function buildRawTree(rawDir, relPath, depth) {
|
||||
if (dirent.name.startsWith('.')) continue;
|
||||
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
|
||||
if (dirent.isDirectory()) {
|
||||
const children = buildRawTree(rawDir, childRel, depth + 1);
|
||||
const children = buildRawTree(rawDir, childRel, depth + 1, assignments);
|
||||
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
||||
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
|
||||
} else if (dirent.isFile()) {
|
||||
let size = 0;
|
||||
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
|
||||
const assignment = assignments.get(childRel) || null;
|
||||
nodes.push({
|
||||
name: dirent.name,
|
||||
type: 'file',
|
||||
path: childRel,
|
||||
size,
|
||||
detectedMediaType: detectMediaType(dirent.name),
|
||||
detectedFormat: detectFormat(dirent.name)
|
||||
detectedFormat: detectFormat(dirent.name),
|
||||
jobId: assignment?.jobId || null,
|
||||
jobTitle: assignment?.jobTitle || null,
|
||||
jobStatus: assignment?.jobStatus || null
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -527,7 +637,30 @@ async function getTree() {
|
||||
if (!rawDir || !fs.existsSync(rawDir)) {
|
||||
return { rawDir: rawDir || null, tree: null };
|
||||
}
|
||||
const children = buildRawTree(rawDir, '', 0);
|
||||
const db = await getDb();
|
||||
const rows = await db.all(`
|
||||
SELECT
|
||||
e.rel_path,
|
||||
e.job_id,
|
||||
j.title AS job_title,
|
||||
j.detected_title AS job_detected_title,
|
||||
j.status AS job_status
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
WHERE e.job_id IS NOT NULL
|
||||
`);
|
||||
const assignments = new Map();
|
||||
for (const row of rows) {
|
||||
const rel = String(row?.rel_path || '').trim();
|
||||
const jobId = Number(row?.job_id);
|
||||
if (!rel || !Number.isFinite(jobId) || jobId <= 0) continue;
|
||||
assignments.set(rel, {
|
||||
jobId: Math.trunc(jobId),
|
||||
jobTitle: String(row?.job_title || row?.job_detected_title || '').trim() || null,
|
||||
jobStatus: String(row?.job_status || '').trim() || null
|
||||
});
|
||||
}
|
||||
const children = buildRawTree(rawDir, '', 0, assignments);
|
||||
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
||||
return {
|
||||
rawDir,
|
||||
@@ -541,6 +674,10 @@ module.exports = {
|
||||
getEntryById,
|
||||
getEntryByRelPath,
|
||||
markEntryAsJob,
|
||||
setEntryJobAssignment,
|
||||
clearEntryJobAssignment,
|
||||
clearAssignmentsForJob,
|
||||
assignEntriesToJob,
|
||||
getRawDir,
|
||||
normalizeRelPath,
|
||||
getTree,
|
||||
|
||||
@@ -2991,6 +2991,75 @@ class HistoryService {
|
||||
}));
|
||||
}
|
||||
|
||||
async findLatestReplacementJobId(sourceJobId) {
|
||||
const normalizedSourceJobId = normalizeJobIdValue(sourceJobId);
|
||||
if (!normalizedSourceJobId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT a.job_id
|
||||
FROM job_lineage_artifacts a
|
||||
JOIN jobs j ON j.id = a.job_id
|
||||
WHERE a.source_job_id = ?
|
||||
ORDER BY a.id DESC
|
||||
`,
|
||||
[normalizedSourceJobId]
|
||||
);
|
||||
|
||||
for (const row of (Array.isArray(rows) ? rows : [])) {
|
||||
const candidateJobId = normalizeJobIdValue(row?.job_id);
|
||||
if (!candidateJobId || candidateJobId === normalizedSourceJobId) {
|
||||
continue;
|
||||
}
|
||||
return candidateJobId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getJobByIdOrReplacement(jobId) {
|
||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return {
|
||||
requestedJobId: null,
|
||||
resolvedJobId: null,
|
||||
replaced: false,
|
||||
job: null
|
||||
};
|
||||
}
|
||||
|
||||
const directJob = await this.getJobById(normalizedJobId);
|
||||
if (directJob) {
|
||||
return {
|
||||
requestedJobId: normalizedJobId,
|
||||
resolvedJobId: normalizedJobId,
|
||||
replaced: false,
|
||||
job: directJob
|
||||
};
|
||||
}
|
||||
|
||||
const replacementJobId = await this.findLatestReplacementJobId(normalizedJobId);
|
||||
if (!replacementJobId) {
|
||||
return {
|
||||
requestedJobId: normalizedJobId,
|
||||
resolvedJobId: normalizedJobId,
|
||||
replaced: false,
|
||||
job: null
|
||||
};
|
||||
}
|
||||
|
||||
const replacementJob = await this.getJobById(replacementJobId);
|
||||
return {
|
||||
requestedJobId: normalizedJobId,
|
||||
resolvedJobId: replacementJobId,
|
||||
replaced: Boolean(replacementJob),
|
||||
job: replacementJob || null
|
||||
};
|
||||
}
|
||||
|
||||
async getRunningJobs() {
|
||||
const db = await getDb();
|
||||
const [rows, settings] = await Promise.all([
|
||||
@@ -4357,21 +4426,72 @@ class HistoryService {
|
||||
const rawRoot = normalizeComparablePath(effectiveRawDir);
|
||||
const stat = fs.lstatSync(rawPath);
|
||||
const isFile = stat.isFile();
|
||||
const plan = resolvedPaths.encodePlan || {};
|
||||
|
||||
// Für Converter-Jobs: Datei liegt in einem Unterordner → Ordner löschen
|
||||
let deletePath = rawPath;
|
||||
if (isFile && resolvedPaths.mediaType === 'converter') {
|
||||
const parentDir = normalizeComparablePath(path.dirname(rawPath));
|
||||
if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
|
||||
deletePath = parentDir;
|
||||
// Für Converter-Jobs: nur die zum Job gehörenden Dateien löschen.
|
||||
// Der übergeordnete Ordner wird nur entfernt, wenn er danach leer ist.
|
||||
// Ausnahme: Ordner-Jobs (isFolder=true) haben einen dedizierten Ordner → komplett löschen.
|
||||
const isConverterFolder = resolvedPaths.mediaType === 'converter' && Boolean(plan.isFolder);
|
||||
const isSharedAudio = resolvedPaths.mediaType === 'converter' && Boolean(plan.isSharedAudio);
|
||||
const isConverterFileJob = resolvedPaths.mediaType === 'converter' && !isConverterFolder;
|
||||
|
||||
if (isConverterFileJob) {
|
||||
// Bestimme die zu löschenden Dateien dieses Jobs
|
||||
let filesToDelete = null;
|
||||
if (isSharedAudio && Array.isArray(plan.inputPaths) && plan.inputPaths.length > 0) {
|
||||
// Shared-Audio-Job: nur die explizit gelisteten Einzeldateien entfernen
|
||||
filesToDelete = plan.inputPaths.map(normalizeComparablePath).filter(Boolean);
|
||||
} else if (isFile) {
|
||||
// Einzeldatei-Job: nur diese eine Datei entfernen
|
||||
filesToDelete = [rawPath];
|
||||
}
|
||||
}
|
||||
|
||||
const keepRoot = deletePath === rawRoot;
|
||||
const result = deleteFilesRecursively(deletePath, keepRoot);
|
||||
summary.raw.deleted = true;
|
||||
summary.raw.filesDeleted = result.filesDeleted;
|
||||
summary.raw.dirsRemoved = result.dirsRemoved;
|
||||
if (filesToDelete) {
|
||||
let filesDeleted = 0;
|
||||
const parentDirs = new Set();
|
||||
for (const filePath of filesToDelete) {
|
||||
if (!isPathInside(rawRoot, filePath)) continue;
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
const fStat = fs.lstatSync(filePath);
|
||||
if (!fStat.isFile()) continue;
|
||||
fs.unlinkSync(filePath);
|
||||
filesDeleted++;
|
||||
const parentDir = normalizeComparablePath(path.dirname(filePath));
|
||||
if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
|
||||
parentDirs.add(parentDir);
|
||||
}
|
||||
}
|
||||
// Übergeordneten Ordner löschen, wenn er nach dem Löschen leer ist
|
||||
let dirsRemoved = 0;
|
||||
for (const parentDir of parentDirs) {
|
||||
try {
|
||||
if (!fs.existsSync(parentDir)) continue;
|
||||
const remaining = fs.readdirSync(parentDir);
|
||||
if (remaining.length === 0) {
|
||||
fs.rmdirSync(parentDir);
|
||||
dirsRemoved++;
|
||||
}
|
||||
} catch (_err) { /* ignore */ }
|
||||
}
|
||||
summary.raw.deleted = true;
|
||||
summary.raw.filesDeleted = filesDeleted;
|
||||
summary.raw.dirsRemoved = dirsRemoved;
|
||||
} else {
|
||||
// Fallback: rawPath ist ein Verzeichnis ohne explizite Dateiliste
|
||||
const keepRoot = rawPath === rawRoot;
|
||||
const result = deleteFilesRecursively(rawPath, keepRoot);
|
||||
summary.raw.deleted = true;
|
||||
summary.raw.filesDeleted = result.filesDeleted;
|
||||
summary.raw.dirsRemoved = result.dirsRemoved;
|
||||
}
|
||||
} else {
|
||||
// Regulärer Job oder dedizierter Converter-Ordner-Job (isFolder=true): gesamten Pfad löschen
|
||||
const keepRoot = rawPath === rawRoot;
|
||||
const result = deleteFilesRecursively(rawPath, keepRoot);
|
||||
summary.raw.deleted = true;
|
||||
summary.raw.filesDeleted = result.filesDeleted;
|
||||
summary.raw.dirsRemoved = result.dirsRemoved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,93 @@
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('OMDB');
|
||||
|
||||
const OMDB_BASE_URL = 'https://www.omdbapi.com/';
|
||||
const OMDB_TIMEOUT_MS = 10000;
|
||||
const OMDB_MAX_ATTEMPTS = 2;
|
||||
const OMDB_RETRY_BASE_DELAY_MS = 300;
|
||||
|
||||
function normalizeOmdbTimeoutMs(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return OMDB_TIMEOUT_MS;
|
||||
}
|
||||
return Math.max(1000, Math.trunc(parsed));
|
||||
}
|
||||
|
||||
function isRetryableOmdbError(error, aborted = false) {
|
||||
if (aborted) {
|
||||
return true;
|
||||
}
|
||||
const code = String(error?.code || '').trim().toUpperCase();
|
||||
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') {
|
||||
return true;
|
||||
}
|
||||
const causeCode = String(error?.cause?.code || '').trim().toUpperCase();
|
||||
if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') {
|
||||
return true;
|
||||
}
|
||||
const message = String(error?.message || '').toLowerCase();
|
||||
return message.includes('timed out') || message.includes('timeout');
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
class OmdbService {
|
||||
async requestJson(url, meta = {}, options = {}) {
|
||||
const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs);
|
||||
const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS)));
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Ripster/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn('request:http-failed', {
|
||||
...meta,
|
||||
status: response.status,
|
||||
attempt,
|
||||
maxAttempts
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
const aborted = error?.name === 'AbortError';
|
||||
const retryable = isRetryableOmdbError(error, aborted);
|
||||
const willRetry = retryable && attempt < maxAttempts;
|
||||
logger[willRetry ? 'info' : 'warn']('request:failed', {
|
||||
...meta,
|
||||
timeoutMs,
|
||||
aborted,
|
||||
retryable,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
willRetry,
|
||||
message: error?.message || String(error)
|
||||
});
|
||||
if (willRetry) {
|
||||
await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt);
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
@@ -13,20 +99,18 @@ class OmdbService {
|
||||
if (!apiKey) {
|
||||
return [];
|
||||
}
|
||||
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
|
||||
|
||||
const type = settings.omdb_default_type || 'movie';
|
||||
const url = new URL('https://www.omdbapi.com/');
|
||||
const url = new URL(OMDB_BASE_URL);
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
url.searchParams.set('s', query.trim());
|
||||
url.searchParams.set('type', type);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
logger.error('search:http-failed', { query, status: response.status });
|
||||
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
||||
const data = await this.requestJson(url, { query, action: 'search' }, { timeoutMs });
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.Response === 'False' || !Array.isArray(data.Search)) {
|
||||
logger.warn('search:no-results', { query, response: data.Response, error: data.Error });
|
||||
return [];
|
||||
@@ -54,19 +138,17 @@ class OmdbService {
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
|
||||
|
||||
const url = new URL('https://www.omdbapi.com/');
|
||||
const url = new URL(OMDB_BASE_URL);
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
url.searchParams.set('i', normalizedId);
|
||||
url.searchParams.set('plot', 'full');
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status });
|
||||
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
||||
const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.Response === 'False') {
|
||||
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
|
||||
return null;
|
||||
|
||||
+2344
-196
File diff suppressed because it is too large
Load Diff
@@ -156,6 +156,40 @@ function normalizeTrackIds(rawList) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeTrackIdSequence(rawList, options = {}) {
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
const dedupe = options?.dedupe !== false;
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const item of list) {
|
||||
const value = Number(item);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalized = String(Math.trunc(value));
|
||||
if (dedupe && seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (dedupe) {
|
||||
seen.add(normalized);
|
||||
}
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizePositiveIndexes(rawList, maxValue = null) {
|
||||
const values = normalizeTrackIds(rawList)
|
||||
.map((item) => Number(item))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value));
|
||||
if (!Number.isFinite(maxValue) || maxValue <= 0) {
|
||||
return values;
|
||||
}
|
||||
const limit = Math.trunc(maxValue);
|
||||
return values.filter((value) => value <= limit);
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(rawValue) {
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
return null;
|
||||
@@ -1209,10 +1243,14 @@ class SettingsService {
|
||||
}
|
||||
|
||||
const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds);
|
||||
const subtitleTrackIds = normalizeTrackIds(rawSelection.subtitleTrackIds);
|
||||
const subtitleTrackIds = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false });
|
||||
const subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null;
|
||||
const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null;
|
||||
const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null;
|
||||
const subtitleForcedTrackIndexes = normalizePositiveIndexes(
|
||||
rawSelection.subtitleForcedTrackIndexes,
|
||||
subtitleTrackIds.length
|
||||
);
|
||||
const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly);
|
||||
const filteredExtra = removeSelectionArgs(extra);
|
||||
const overrideArgs = [
|
||||
@@ -1227,7 +1265,9 @@ class SettingsService {
|
||||
if (subtitleDefaultTrackId !== null) {
|
||||
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
|
||||
}
|
||||
if (subtitleForcedTrackId !== null) {
|
||||
if (subtitleForcedTrackIndexes.length > 0) {
|
||||
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackIndexes.join(',')}`);
|
||||
} else if (subtitleForcedTrackId !== null) {
|
||||
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
|
||||
} else if (subtitleForcedOnly) {
|
||||
overrideArgs.push('--subtitle-forced');
|
||||
@@ -1245,6 +1285,7 @@ class SettingsService {
|
||||
subtitleTrackIds,
|
||||
subtitleBurnTrackId,
|
||||
subtitleDefaultTrackId,
|
||||
subtitleForcedTrackIndexes,
|
||||
subtitleForcedTrackId,
|
||||
subtitleForcedOnly
|
||||
}
|
||||
@@ -1258,6 +1299,7 @@ class SettingsService {
|
||||
subtitleTrackIds,
|
||||
subtitleBurnTrackId,
|
||||
subtitleDefaultTrackId,
|
||||
subtitleForcedTrackIndexes,
|
||||
subtitleForcedTrackId,
|
||||
subtitleForcedOnly
|
||||
}
|
||||
|
||||
+519
-15
@@ -3,6 +3,16 @@ const { splitArgs } = require('./commandLine');
|
||||
|
||||
const DEFAULT_AUDIO_COPY_MASK = ['aac', 'ac3', 'eac3', 'truehd', 'dts', 'dtshd', 'mp3', 'flac'];
|
||||
const DEFAULT_AUDIO_FALLBACK = 'av_aac';
|
||||
const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3
|
||||
});
|
||||
const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35;
|
||||
const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35;
|
||||
const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220;
|
||||
const FORCED_SUBTITLE_MIN_EVENT_GAP = 12;
|
||||
const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024;
|
||||
const ISO2_TO_3_LANGUAGE = {
|
||||
de: 'deu',
|
||||
en: 'eng',
|
||||
@@ -271,7 +281,7 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
|
||||
channels: item?.Channels || item?.Channel_s_ || null
|
||||
}));
|
||||
|
||||
const subtitleTracks = tracks
|
||||
const subtitleTracksRaw = tracks
|
||||
.filter((item) => {
|
||||
const type = String(item?.['@type'] || '').toLowerCase();
|
||||
return type === 'text' || type === 'subtitle';
|
||||
@@ -282,8 +292,14 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) {
|
||||
language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'),
|
||||
languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und',
|
||||
title: item?.Title || null,
|
||||
format: item?.Format || null
|
||||
format: item?.Format || null,
|
||||
defaultFlag: parseBooleanFlag(item?.Default ?? item?.Default_String ?? item?.IsDefault ?? item?.isDefault),
|
||||
forcedFlag: parseBooleanFlagNullable(item?.Forced ?? item?.Forced_String ?? item?.IsForced ?? item?.isForced),
|
||||
sdhFlag: parseSubtitleSdhFlag(item),
|
||||
eventCount: parseSubtitleEventCount(item),
|
||||
streamSizeBytes: parseSubtitleStreamSizeBytes(item)
|
||||
}));
|
||||
const subtitleTracks = annotateSubtitleTracks(subtitleTracksRaw);
|
||||
|
||||
const videoTracks = tracks
|
||||
.filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video')
|
||||
@@ -350,6 +366,467 @@ function parseTrackIdList(raw) {
|
||||
.filter((item) => Number.isFinite(item));
|
||||
}
|
||||
|
||||
function parseBooleanFlag(value) {
|
||||
return parseBooleanFlagNullable(value) === true;
|
||||
}
|
||||
|
||||
function parseBooleanFlagNullable(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw === 'yes' || raw === 'true' || raw === '1') {
|
||||
return true;
|
||||
}
|
||||
if (raw === 'no' || raw === 'false' || raw === '0') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtitleForcedFlag(track) {
|
||||
if (!track || typeof track !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const candidates = [
|
||||
track?.forcedFlag,
|
||||
track?.forced,
|
||||
track?.Forced,
|
||||
track?.isForced,
|
||||
track?.IsForced,
|
||||
track?.forced_only,
|
||||
track?.forcedOnly,
|
||||
track?.Attributes?.Forced
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseBooleanFlagNullable(candidate);
|
||||
if (parsed === true || parsed === false) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtitleSdhFlag(track) {
|
||||
if (!track || typeof track !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const candidates = [
|
||||
track?.sdhFlag,
|
||||
track?.hearingImpaired,
|
||||
track?.HearingImpaired,
|
||||
track?.isHearingImpaired,
|
||||
track?.IsHearingImpaired,
|
||||
track?.Hearing_Impaired,
|
||||
track?.closedCaptions,
|
||||
track?.ClosedCaptions,
|
||||
track?.Attributes?.HearingImpaired,
|
||||
track?.Attributes?.ClosedCaptions
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseBooleanFlagNullable(candidate);
|
||||
if (parsed === true || parsed === false) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const serviceKind = String(track?.serviceKind || track?.ServiceKind || '').trim().toLowerCase();
|
||||
if (!serviceKind) {
|
||||
return null;
|
||||
}
|
||||
if (serviceKind.includes('sdh') || serviceKind.includes('cc') || serviceKind.includes('hearing')) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtitleEventCount(track) {
|
||||
const candidates = [
|
||||
track?.CountOfEvents,
|
||||
track?.countOfEvents,
|
||||
track?.EventCount,
|
||||
track?.eventCount,
|
||||
track?.ElementCount,
|
||||
track?.elementCount
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const numeric = Number(candidate);
|
||||
if (Number.isFinite(numeric) && numeric >= 0) {
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtitleStreamSizeBytes(track) {
|
||||
const numericCandidates = [
|
||||
track?.StreamSize,
|
||||
track?.streamSize,
|
||||
track?.StreamSize_Original,
|
||||
track?.streamSizeOriginal,
|
||||
track?.Bytes,
|
||||
track?.bytes
|
||||
];
|
||||
for (const candidate of numericCandidates) {
|
||||
const numeric = Number(candidate);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
}
|
||||
|
||||
const textCandidates = [
|
||||
track?.StreamSize_String,
|
||||
track?.streamSizeString,
|
||||
track?.StreamSize_Original_String,
|
||||
track?.streamSizeOriginalString,
|
||||
track?.Size_String,
|
||||
track?.sizeString
|
||||
];
|
||||
for (const candidate of textCandidates) {
|
||||
const text = String(candidate || '').trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const value = Number(String(match[1]).replace(',', '.'));
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
continue;
|
||||
}
|
||||
const unit = String(match[2] || 'b').toLowerCase();
|
||||
const factorByUnit = {
|
||||
b: 1,
|
||||
kb: 1024,
|
||||
mb: 1024 ** 2,
|
||||
gb: 1024 ** 3,
|
||||
tb: 1024 ** 4
|
||||
};
|
||||
const factor = factorByUnit[unit] || 1;
|
||||
return Math.max(0, Math.trunc(value * factor));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSubtitleConfidence(raw) {
|
||||
const value = String(raw || '').trim().toLowerCase();
|
||||
if (value === 'high' || value === 'medium' || value === 'low') {
|
||||
return value;
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function subtitleConfidenceScore(raw) {
|
||||
return SUBTITLE_CONFIDENCE_SCORES[normalizeSubtitleConfidence(raw)] || 0;
|
||||
}
|
||||
|
||||
function collectSubtitleText(track) {
|
||||
return [
|
||||
track?.title,
|
||||
track?.description,
|
||||
track?.name,
|
||||
track?.format,
|
||||
track?.label
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function isLikelyBitmapSubtitleFormat(track) {
|
||||
const text = [
|
||||
track?.format,
|
||||
track?.codec,
|
||||
track?.codecName,
|
||||
track?.title,
|
||||
track?.description,
|
||||
track?.name,
|
||||
track?.label
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/\bpgs\b/.test(text)
|
||||
|| /\bhdmv\b/.test(text)
|
||||
|| /\bsup\b/.test(text)
|
||||
|| /\bvobsub\b/.test(text)
|
||||
|| /\bdvd[-_\s]?sub/.test(text)
|
||||
|| /\bdvb[-_\s]?sub/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelySdhSubtitleTrack(track) {
|
||||
const text = collectSubtitleText(track);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/\bsdh\b/.test(text)
|
||||
|| /\bcc\b/.test(text)
|
||||
|| /\bhoh\b/.test(text)
|
||||
|| /\bcaptions?\b/.test(text)
|
||||
|| /hard[-\s]?of[-\s]?hearing/.test(text)
|
||||
|| /hearing\s+impaired/.test(text)
|
||||
|| /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isLikelyForcedSubtitleTrack(track) {
|
||||
const text = collectSubtitleText(track);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/\bforced(?:\s+only)?\b/.test(text)
|
||||
|| /nur\s+erzwungen/.test(text)
|
||||
|| /\berzwungen\b/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function compareSubtitleTracksByForcedHeuristic(a, b) {
|
||||
const aSdh = Number(Boolean(a?.sdhLikely));
|
||||
const bSdh = Number(Boolean(b?.sdhLikely));
|
||||
if (aSdh !== bSdh) {
|
||||
return aSdh - bSdh;
|
||||
}
|
||||
|
||||
const aDefault = Number(Boolean(a?.defaultFlag));
|
||||
const bDefault = Number(Boolean(b?.defaultFlag));
|
||||
if (aDefault !== bDefault) {
|
||||
return aDefault - bDefault;
|
||||
}
|
||||
|
||||
const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1;
|
||||
const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1;
|
||||
if (aEventKnown !== bEventKnown) {
|
||||
return aEventKnown - bEventKnown;
|
||||
}
|
||||
if (aEventKnown === 0 && a.eventCount !== b.eventCount) {
|
||||
return a.eventCount - b.eventCount;
|
||||
}
|
||||
|
||||
const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1;
|
||||
const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1;
|
||||
if (aSizeKnown !== bSizeKnown) {
|
||||
return aSizeKnown - bSizeKnown;
|
||||
}
|
||||
if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) {
|
||||
return a.streamSizeBytes - b.streamSizeBytes;
|
||||
}
|
||||
|
||||
const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
|
||||
const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
|
||||
if (aTrackId !== bTrackId) {
|
||||
return aTrackId - bTrackId;
|
||||
}
|
||||
return a.originalIndex - b.originalIndex;
|
||||
}
|
||||
|
||||
function compareSubtitleTracksForDedup(a, b) {
|
||||
const aSdh = Number(Boolean(a?.sdhLikely));
|
||||
const bSdh = Number(Boolean(b?.sdhLikely));
|
||||
if (aSdh !== bSdh) {
|
||||
return aSdh - bSdh;
|
||||
}
|
||||
|
||||
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
|
||||
if (defaultDiff !== 0) {
|
||||
return defaultDiff;
|
||||
}
|
||||
const confidenceDiff = subtitleConfidenceScore(b?.sourceConfidence) - subtitleConfidenceScore(a?.sourceConfidence);
|
||||
if (confidenceDiff !== 0) {
|
||||
return confidenceDiff;
|
||||
}
|
||||
const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
|
||||
const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
|
||||
if (aTrackId !== bTrackId) {
|
||||
return aTrackId - bTrackId;
|
||||
}
|
||||
return a.originalIndex - b.originalIndex;
|
||||
}
|
||||
|
||||
function isHeuristicForcedSubtitleCandidate(languageEntries, candidate) {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
if (!isLikelyBitmapSubtitleFormat(candidate)) {
|
||||
return false;
|
||||
}
|
||||
const comparable = (Array.isArray(languageEntries) ? languageEntries : [])
|
||||
.filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry));
|
||||
if (comparable.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidateEventCount = Number(candidate?.eventCount);
|
||||
const comparableEventCounts = comparable
|
||||
.map((entry) => Number(entry?.eventCount))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
const maxComparableEventCount = comparableEventCounts.length > 0
|
||||
? Math.max(...comparableEventCounts)
|
||||
: null;
|
||||
const hasEventSignal = Number.isFinite(candidateEventCount)
|
||||
&& candidateEventCount >= 0
|
||||
&& Number.isFinite(maxComparableEventCount)
|
||||
&& maxComparableEventCount > 0
|
||||
&& candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT
|
||||
&& (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD
|
||||
&& (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP;
|
||||
|
||||
const candidateStreamSize = Number(candidate?.streamSizeBytes);
|
||||
const comparableSizes = comparable
|
||||
.map((entry) => Number(entry?.streamSizeBytes))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
const maxComparableSize = comparableSizes.length > 0
|
||||
? Math.max(...comparableSizes)
|
||||
: null;
|
||||
const hasSizeSignal = Number.isFinite(candidateStreamSize)
|
||||
&& candidateStreamSize > 0
|
||||
&& Number.isFinite(maxComparableSize)
|
||||
&& maxComparableSize > 0
|
||||
&& (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD
|
||||
&& (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES;
|
||||
|
||||
const signalCount = Number(hasEventSignal) + Number(hasSizeSignal);
|
||||
if (signalCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.defaultFlag && signalCount < 2) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function annotateSubtitleTracks(subtitleTracks) {
|
||||
const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
|
||||
if (tracks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = tracks.map((track, index) => ({
|
||||
...track,
|
||||
language: normalizeLanguage(track?.language || track?.languageLabel || 'und'),
|
||||
defaultFlag: Boolean(track?.defaultFlag),
|
||||
forcedFlag: parseSubtitleForcedFlag(track),
|
||||
forcedTrack: false,
|
||||
sourceConfidence: null,
|
||||
confidenceSource: 'heuristic',
|
||||
duplicate: false,
|
||||
selected: false,
|
||||
subtitleType: 'full',
|
||||
forcedAvailable: false,
|
||||
forcedSourceTrackIds: [],
|
||||
sdhLikely: (parseSubtitleSdhFlag(track) === true) || Boolean(track?.sdhLikely) || isLikelySdhSubtitleTrack(track),
|
||||
originalIndex: index
|
||||
}));
|
||||
|
||||
const byLanguage = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!byLanguage.has(entry.language)) {
|
||||
byLanguage.set(entry.language, []);
|
||||
}
|
||||
byLanguage.get(entry.language).push(entry);
|
||||
}
|
||||
|
||||
for (const languageEntries of byLanguage.values()) {
|
||||
for (const entry of languageEntries) {
|
||||
const forcedByFlag = entry.forcedFlag === true;
|
||||
const forcedBlockedByFlag = entry.forcedFlag === false;
|
||||
const forcedByTitle = !entry.sdhLikely && !forcedBlockedByFlag && isLikelyForcedSubtitleTrack(entry);
|
||||
|
||||
if (forcedByFlag) {
|
||||
entry.forcedTrack = true;
|
||||
entry.sourceConfidence = 'high';
|
||||
entry.confidenceSource = 'explicit_flag';
|
||||
} else if (forcedByTitle) {
|
||||
entry.forcedTrack = true;
|
||||
entry.sourceConfidence = 'medium';
|
||||
entry.confidenceSource = 'title';
|
||||
}
|
||||
}
|
||||
|
||||
if (!languageEntries.some((entry) => entry.forcedTrack)) {
|
||||
const candidates = languageEntries
|
||||
.filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false)
|
||||
.sort(compareSubtitleTracksByForcedHeuristic);
|
||||
const forcedCandidate = candidates[0] || null;
|
||||
if (forcedCandidate && isHeuristicForcedSubtitleCandidate(languageEntries, forcedCandidate)) {
|
||||
forcedCandidate.forcedTrack = true;
|
||||
forcedCandidate.sourceConfidence = 'low';
|
||||
forcedCandidate.confidenceSource = 'heuristic';
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of languageEntries) {
|
||||
if (!entry.forcedTrack) {
|
||||
continue;
|
||||
}
|
||||
entry.sourceConfidence = normalizeSubtitleConfidence(entry.sourceConfidence || 'low');
|
||||
}
|
||||
|
||||
const forcedEntries = languageEntries.filter((entry) => entry.forcedTrack);
|
||||
const fullEntries = languageEntries.filter((entry) => !entry.forcedTrack && !entry.sdhLikely);
|
||||
const sdhEntries = languageEntries.filter((entry) => !entry.forcedTrack && entry.sdhLikely);
|
||||
const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null;
|
||||
const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null;
|
||||
const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null;
|
||||
const forcedSourceTrackIds = forcedEntries
|
||||
.map((entry) => Number(entry?.sourceTrackId ?? entry?.id))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value));
|
||||
const forcedAvailable = Boolean(forcedWinner);
|
||||
|
||||
for (const entry of languageEntries) {
|
||||
const typeWinner = entry.forcedTrack
|
||||
? forcedWinner
|
||||
: (entry.sdhLikely ? sdhWinner : fullWinner);
|
||||
entry.duplicate = Boolean(typeWinner && typeWinner !== entry);
|
||||
if (entry.forcedTrack) {
|
||||
entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
|
||||
} else if (entry.sdhLikely) {
|
||||
entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate);
|
||||
} else {
|
||||
entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
|
||||
}
|
||||
entry.subtitleType = entry.forcedTrack ? 'forced' : 'full';
|
||||
entry.forcedAvailable = forcedAvailable;
|
||||
entry.forcedSourceTrackIds = forcedSourceTrackIds;
|
||||
const fullHasForced = !entry.forcedTrack && Boolean(
|
||||
entry.fullHasForced
|
||||
?? entry.subtitleFullHasForced
|
||||
?? entry.hasForcedVariant
|
||||
);
|
||||
const explicitForcedOnly = entry.isForcedOnly ?? entry.forcedOnly ?? entry.subtitlePreviewForcedOnly;
|
||||
entry.isForcedOnly = typeof explicitForcedOnly === 'boolean'
|
||||
? explicitForcedOnly
|
||||
: (entry.forcedTrack && !fullHasForced);
|
||||
entry.fullHasForced = fullHasForced;
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
.sort((a, b) => a.originalIndex - b.originalIndex)
|
||||
.map((entry) => {
|
||||
const { originalIndex, ...rest } = entry;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
function parseEncoderList(raw) {
|
||||
return String(raw || '')
|
||||
.split(',')
|
||||
@@ -638,7 +1115,10 @@ function buildTrackSelectors(settings, presetProfile) {
|
||||
|
||||
function selectTrackIds(tracks, selector, trackType) {
|
||||
const available = Array.isArray(tracks) ? tracks : [];
|
||||
if (available.length === 0) {
|
||||
const selectable = trackType === 'subtitle'
|
||||
? available.filter((track) => !Boolean(track?.duplicate))
|
||||
: available;
|
||||
if (selectable.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -648,13 +1128,13 @@ function selectTrackIds(tracks, selector, trackType) {
|
||||
|
||||
if (selector.mode === 'all') {
|
||||
if (selector.firstOnly) {
|
||||
return [available[0].id];
|
||||
return [selectable[0].id];
|
||||
}
|
||||
return available.map((track) => track.id);
|
||||
return selectable.map((track) => track.id);
|
||||
}
|
||||
|
||||
if (selector.mode === 'explicit') {
|
||||
const explicit = available
|
||||
const explicit = selectable
|
||||
.filter((track) => selector.explicitIds.includes(track.id))
|
||||
.map((track) => track.id);
|
||||
if (selector.firstOnly) {
|
||||
@@ -664,7 +1144,7 @@ function selectTrackIds(tracks, selector, trackType) {
|
||||
}
|
||||
|
||||
if (selector.mode === 'language') {
|
||||
const matches = available.filter((track) => selector.languages.includes(track.language));
|
||||
const matches = selectable.filter((track) => selector.languages.includes(track.language));
|
||||
if (selector.firstOnly) {
|
||||
return matches.length > 0 ? [matches[0].id] : [];
|
||||
}
|
||||
@@ -672,11 +1152,11 @@ function selectTrackIds(tracks, selector, trackType) {
|
||||
}
|
||||
|
||||
if (selector.mode === 'first') {
|
||||
return [available[0].id];
|
||||
return [selectable[0].id];
|
||||
}
|
||||
|
||||
if (trackType === 'audio') {
|
||||
return [available[0].id];
|
||||
return [selectable[0].id];
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -916,21 +1396,45 @@ function buildMediainfoReview({
|
||||
const normalizedSubtitle = title.subtitleTracks.map((track) => {
|
||||
const selectedByRule = selectedSubtitleIds.includes(track.id);
|
||||
const subtitleFlags = computeSubtitleFlags(track.id, selectedSubtitleIds, trackSelectors.subtitle);
|
||||
const inferredForced = Boolean(
|
||||
track?.forcedTrack
|
||||
|| String(track?.subtitleType || '').trim().toLowerCase() === 'forced'
|
||||
);
|
||||
const inferredForcedOnly = Boolean(
|
||||
track?.isForcedOnly
|
||||
?? track?.forcedOnly
|
||||
?? track?.subtitlePreviewForcedOnly
|
||||
?? inferredForced
|
||||
);
|
||||
const inferredDefault = Boolean(track?.defaultFlag);
|
||||
const subtitlePreviewFlags = [];
|
||||
if (subtitleFlags.burned) {
|
||||
subtitlePreviewFlags.push('burned');
|
||||
}
|
||||
if (subtitleFlags.forced || inferredForced) {
|
||||
subtitlePreviewFlags.push('forced');
|
||||
}
|
||||
if (subtitleFlags.forcedOnly || inferredForcedOnly) {
|
||||
subtitlePreviewFlags.push('forced-only');
|
||||
}
|
||||
if (subtitleFlags.default || inferredDefault) {
|
||||
subtitlePreviewFlags.push('default');
|
||||
}
|
||||
const subtitlePreviewSummary = !selectedByRule
|
||||
? 'Nicht übernommen'
|
||||
: (subtitleFlags.flags.length > 0
|
||||
? `Übernehmen (${subtitleFlags.flags.join(', ')})`
|
||||
: (subtitlePreviewFlags.length > 0
|
||||
? `Übernehmen (${subtitlePreviewFlags.join(', ')})`
|
||||
: 'Übernehmen');
|
||||
|
||||
return {
|
||||
...track,
|
||||
selectedByRule,
|
||||
subtitlePreviewSummary,
|
||||
subtitlePreviewFlags: subtitleFlags.flags,
|
||||
subtitlePreviewFlags: selectedByRule ? subtitlePreviewFlags : [],
|
||||
subtitlePreviewBurnIn: subtitleFlags.burned,
|
||||
subtitlePreviewForced: subtitleFlags.forced,
|
||||
subtitlePreviewForcedOnly: subtitleFlags.forcedOnly,
|
||||
subtitlePreviewDefaultTrack: subtitleFlags.default
|
||||
subtitlePreviewForced: selectedByRule ? (subtitleFlags.forced || inferredForced) : false,
|
||||
subtitlePreviewForcedOnly: selectedByRule ? (subtitleFlags.forcedOnly || inferredForcedOnly) : false,
|
||||
subtitlePreviewDefaultTrack: selectedByRule ? (subtitleFlags.default || inferredDefault) : false
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -44,13 +44,26 @@ function sanitizeFileNameWithExtension(input) {
|
||||
}
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
||||
const val = values[key.trim()];
|
||||
const rawSource = String(template || '${title} (${year})');
|
||||
const resolveToken = (rawKey) => {
|
||||
const key = String(rawKey || '').trim();
|
||||
const val = values[key];
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
return String(val);
|
||||
});
|
||||
};
|
||||
|
||||
const source = (
|
||||
/[{}]/.test(rawSource)
|
||||
? rawSource
|
||||
: rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}')
|
||||
);
|
||||
|
||||
// Support both ${key} and legacy {key} placeholders (+ recovered bare tokens).
|
||||
return source
|
||||
.replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key))
|
||||
.replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key));
|
||||
}
|
||||
|
||||
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {
|
||||
|
||||
Reference in New Issue
Block a user