0.12.0-16 misc fixes

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