0.12.0-16 misc fixes
This commit is contained in:
1 parent
76b020d5c0
commit
5284dd2a43
30 files changed
+6955
-896
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user