0.12.0-16 misc fixes

This commit is contained in:
michael committed 2026-03-30 14:37:23 +00:00
1 parent 76b020d5c0
commit 5284dd2a43
30 files changed
+6955 -896

No files matched your search

+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
}