0.16.1-1 release snapshot
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('ActivationBytes');
|
||||
|
||||
const FIXED_KEY = Buffer.from([0x77, 0x21, 0x4d, 0x4b, 0x19, 0x6a, 0x87, 0xcd, 0x52, 0x00, 0x45, 0xfd, 0x20, 0xa5, 0x1d, 0x67]);
|
||||
const AAX_CHECKSUM_OFFSET = 653;
|
||||
const AAX_CHECKSUM_LENGTH = 20;
|
||||
|
||||
function sha1(data) {
|
||||
return crypto.createHash('sha1').update(data).digest();
|
||||
}
|
||||
|
||||
function verifyActivationBytes(activationBytesHex, expectedChecksumHex) {
|
||||
const bytes = Buffer.from(activationBytesHex, 'hex');
|
||||
const ik = sha1(Buffer.concat([FIXED_KEY, bytes]));
|
||||
const iv = sha1(Buffer.concat([FIXED_KEY, ik, bytes]));
|
||||
const checksum = sha1(Buffer.concat([ik.subarray(0, 16), iv.subarray(0, 16)]));
|
||||
return checksum.toString('hex') === expectedChecksumHex;
|
||||
}
|
||||
|
||||
function readAaxChecksum(filePath) {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(AAX_CHECKSUM_LENGTH);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, AAX_CHECKSUM_LENGTH, AAX_CHECKSUM_OFFSET);
|
||||
if (bytesRead !== AAX_CHECKSUM_LENGTH) {
|
||||
throw new Error(`Konnte Checksum nicht lesen (nur ${bytesRead} Bytes)`);
|
||||
}
|
||||
return buf.toString('hex');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupCached(checksum) {
|
||||
const db = await getDb();
|
||||
const row = await db.get('SELECT activation_bytes FROM aax_activation_bytes WHERE checksum = ?', checksum);
|
||||
return row ? row.activation_bytes : null;
|
||||
}
|
||||
|
||||
async function saveActivationBytes(checksum, activationBytesHex) {
|
||||
const normalized = String(activationBytesHex || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{8}$/.test(normalized)) {
|
||||
throw new Error('Activation Bytes müssen genau 8 Hex-Zeichen (4 Bytes) sein');
|
||||
}
|
||||
if (!verifyActivationBytes(normalized, checksum)) {
|
||||
throw new Error('Activation Bytes passen nicht zur Checksum – bitte nochmals prüfen');
|
||||
}
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
'INSERT OR REPLACE INTO aax_activation_bytes (checksum, activation_bytes) VALUES (?, ?)',
|
||||
checksum,
|
||||
normalized
|
||||
);
|
||||
logger.info({ checksum, activationBytes: normalized }, 'Activation Bytes manuell gespeichert');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveActivationBytes(filePath) {
|
||||
const checksum = readAaxChecksum(filePath);
|
||||
logger.info({ checksum }, 'AAX Checksum gelesen');
|
||||
|
||||
const cached = await lookupCached(checksum);
|
||||
if (cached) {
|
||||
logger.info({ checksum }, 'Activation Bytes aus lokalem Cache');
|
||||
return { checksum, activationBytes: cached };
|
||||
}
|
||||
|
||||
logger.info({ checksum }, 'Keine Activation Bytes im Cache – manuelle Eingabe erforderlich');
|
||||
return { checksum, activationBytes: null };
|
||||
}
|
||||
|
||||
async function listCachedEntries() {
|
||||
const db = await getDb();
|
||||
return db.all('SELECT checksum, activation_bytes, created_at FROM aax_activation_bytes ORDER BY created_at DESC');
|
||||
}
|
||||
|
||||
module.exports = { resolveActivationBytes, readAaxChecksum, saveActivationBytes, verifyActivationBytes, listCachedEntries };
|
||||
@@ -0,0 +1,819 @@
|
||||
const path = require('path');
|
||||
const { sanitizeFileName } = require('../utils/files');
|
||||
|
||||
const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']);
|
||||
const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']);
|
||||
const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})';
|
||||
const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})';
|
||||
const DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}';
|
||||
const AUDIOBOOK_FORMAT_DEFAULTS = {
|
||||
m4b: {},
|
||||
flac: {
|
||||
flacCompression: 5
|
||||
},
|
||||
mp3: {
|
||||
mp3Mode: 'cbr',
|
||||
mp3Bitrate: 192,
|
||||
mp3Quality: 4
|
||||
}
|
||||
};
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFC')
|
||||
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
|
||||
.replace(/\p{C}+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseOptionalYear(value) {
|
||||
const text = normalizeText(value);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const match = text.match(/\b(19|20)\d{2}\b/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return Number(match[0]);
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseTimebaseToSeconds(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (/^\d+\/\d+$/u.test(raw)) {
|
||||
const [num, den] = raw.split('/').map(Number);
|
||||
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
|
||||
return num / den;
|
||||
}
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function secondsToMs(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.round(parsed * 1000));
|
||||
}
|
||||
|
||||
function ticksToMs(value, timebase) {
|
||||
const ticks = Number(value);
|
||||
const factor = parseTimebaseToSeconds(timebase);
|
||||
if (!Number.isFinite(ticks) || ticks < 0 || !Number.isFinite(factor) || factor <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.round(ticks * factor * 1000));
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(value) {
|
||||
const format = String(value || '').trim().toLowerCase();
|
||||
return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3';
|
||||
}
|
||||
|
||||
function clonePlainObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||||
}
|
||||
|
||||
function clampInteger(value, min, max, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(min, Math.min(max, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function getDefaultFormatOptions(format) {
|
||||
const normalizedFormat = normalizeOutputFormat(format);
|
||||
return clonePlainObject(AUDIOBOOK_FORMAT_DEFAULTS[normalizedFormat]);
|
||||
}
|
||||
|
||||
function normalizeFormatOptions(format, formatOptions = {}) {
|
||||
const normalizedFormat = normalizeOutputFormat(format);
|
||||
const source = clonePlainObject(formatOptions);
|
||||
const defaults = getDefaultFormatOptions(normalizedFormat);
|
||||
|
||||
if (normalizedFormat === 'flac') {
|
||||
return {
|
||||
flacCompression: clampInteger(source.flacCompression, 0, 8, defaults.flacCompression)
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'mp3') {
|
||||
const mp3Mode = String(source.mp3Mode || defaults.mp3Mode || 'cbr').trim().toLowerCase() === 'vbr'
|
||||
? 'vbr'
|
||||
: 'cbr';
|
||||
const allowedBitrates = new Set([128, 160, 192, 256, 320]);
|
||||
const normalizedBitrate = clampInteger(source.mp3Bitrate, 96, 320, defaults.mp3Bitrate);
|
||||
return {
|
||||
mp3Mode,
|
||||
mp3Bitrate: allowedBitrates.has(normalizedBitrate) ? normalizedBitrate : defaults.mp3Bitrate,
|
||||
mp3Quality: clampInteger(source.mp3Quality, 0, 9, defaults.mp3Quality)
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeInputExtension(filePath) {
|
||||
return path.extname(String(filePath || '')).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isSupportedInputFile(filePath) {
|
||||
return SUPPORTED_INPUT_EXTENSIONS.has(normalizeInputExtension(filePath));
|
||||
}
|
||||
|
||||
function normalizeTagMap(tags = null) {
|
||||
const source = tags && typeof tags === 'object' ? tags : {};
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const normalizedKey = String(key || '').trim().toLowerCase();
|
||||
if (!normalizedKey) {
|
||||
continue;
|
||||
}
|
||||
const normalizedValue = normalizeText(value);
|
||||
if (!normalizedValue) {
|
||||
continue;
|
||||
}
|
||||
result[normalizedKey] = normalizedValue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickTag(tags, keys = []) {
|
||||
const normalized = normalizeTagMap(tags);
|
||||
for (const key of keys) {
|
||||
const value = normalized[String(key || '').trim().toLowerCase()];
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeTemplateValue(value, fallback = '') {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return sanitizeFileName(normalized);
|
||||
}
|
||||
|
||||
function normalizeChapterTitle(value, index) {
|
||||
const normalized = normalizeText(value);
|
||||
return normalized || `Kapitel ${index}`;
|
||||
}
|
||||
|
||||
function buildChapterList(probe = null) {
|
||||
const chapters = Array.isArray(probe?.chapters) ? probe.chapters : [];
|
||||
return chapters.map((chapter, index) => {
|
||||
const chapterIndex = index + 1;
|
||||
const tags = normalizeTagMap(chapter?.tags);
|
||||
const startSeconds = parseOptionalNumber(chapter?.start_time);
|
||||
const endSeconds = parseOptionalNumber(chapter?.end_time);
|
||||
const startMs = secondsToMs(startSeconds) ?? ticksToMs(chapter?.start, chapter?.time_base) ?? 0;
|
||||
const endMs = secondsToMs(endSeconds) ?? ticksToMs(chapter?.end, chapter?.time_base) ?? 0;
|
||||
const title = normalizeChapterTitle(tags.title || tags.chapter, chapterIndex);
|
||||
return {
|
||||
index: chapterIndex,
|
||||
title,
|
||||
startSeconds: Number((startMs / 1000).toFixed(3)),
|
||||
endSeconds: Number((endMs / 1000).toFixed(3)),
|
||||
startMs,
|
||||
endMs,
|
||||
timeBase: String(chapter?.time_base || '').trim() || null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeChapterList(chapters = [], options = {}) {
|
||||
const source = Array.isArray(chapters) ? chapters : [];
|
||||
const durationMs = Number(options?.durationMs || 0);
|
||||
const fallbackTitle = normalizeText(options?.fallbackTitle || '');
|
||||
const createFallback = options?.createFallback === true;
|
||||
|
||||
const normalized = source.map((chapter, index) => {
|
||||
const chapterIndex = Number(chapter?.index);
|
||||
const safeIndex = Number.isFinite(chapterIndex) && chapterIndex > 0
|
||||
? Math.trunc(chapterIndex)
|
||||
: index + 1;
|
||||
const rawStartMs = parseOptionalNumber(chapter?.startMs)
|
||||
?? secondsToMs(chapter?.startSeconds)
|
||||
?? ticksToMs(chapter?.start, chapter?.timeBase || chapter?.time_base)
|
||||
?? 0;
|
||||
const rawEndMs = parseOptionalNumber(chapter?.endMs)
|
||||
?? secondsToMs(chapter?.endSeconds)
|
||||
?? ticksToMs(chapter?.end, chapter?.timeBase || chapter?.time_base)
|
||||
?? 0;
|
||||
return {
|
||||
index: safeIndex,
|
||||
title: normalizeChapterTitle(chapter?.title, safeIndex),
|
||||
startMs: Math.max(0, rawStartMs),
|
||||
endMs: Math.max(0, rawEndMs)
|
||||
};
|
||||
});
|
||||
|
||||
const repaired = normalized.map((chapter, index) => {
|
||||
const nextStartMs = normalized[index + 1]?.startMs ?? null;
|
||||
let endMs = chapter.endMs;
|
||||
if (!(endMs > chapter.startMs)) {
|
||||
if (Number.isFinite(nextStartMs) && nextStartMs > chapter.startMs) {
|
||||
endMs = nextStartMs;
|
||||
} else if (durationMs > chapter.startMs) {
|
||||
endMs = durationMs;
|
||||
} else {
|
||||
endMs = chapter.startMs;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...chapter,
|
||||
endMs,
|
||||
startSeconds: Number((chapter.startMs / 1000).toFixed(3)),
|
||||
endSeconds: Number((endMs / 1000).toFixed(3)),
|
||||
durationMs: Math.max(0, endMs - chapter.startMs)
|
||||
};
|
||||
}).filter((chapter) => chapter.endMs > chapter.startMs || normalized.length === 1);
|
||||
|
||||
if (repaired.length > 0) {
|
||||
return repaired;
|
||||
}
|
||||
|
||||
if (createFallback && durationMs > 0) {
|
||||
return [{
|
||||
index: 1,
|
||||
title: fallbackTitle || 'Kapitel 1',
|
||||
startMs: 0,
|
||||
endMs: durationMs,
|
||||
startSeconds: 0,
|
||||
endSeconds: Number((durationMs / 1000).toFixed(3)),
|
||||
durationMs
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function looksLikeDescription(value) {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return normalized.length >= 120 || /[.!?]\s/u.test(normalized);
|
||||
}
|
||||
|
||||
function detectCoverStream(probe = null) {
|
||||
const streams = Array.isArray(probe?.streams) ? probe.streams : [];
|
||||
for (const stream of streams) {
|
||||
const codecType = String(stream?.codec_type || '').trim().toLowerCase();
|
||||
const codecName = String(stream?.codec_name || '').trim().toLowerCase();
|
||||
const dispositionAttachedPic = Number(stream?.disposition?.attached_pic || 0) === 1;
|
||||
const mimetype = String(stream?.tags?.mimetype || '').trim().toLowerCase();
|
||||
const looksLikeImageStream = codecType === 'video'
|
||||
&& (dispositionAttachedPic || mimetype.startsWith('image/') || ['jpeg', 'jpg', 'png', 'mjpeg'].includes(codecName));
|
||||
|
||||
if (!looksLikeImageStream) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const streamIndex = Number(stream?.index);
|
||||
return {
|
||||
streamIndex: Number.isFinite(streamIndex) ? Math.trunc(streamIndex) : 0,
|
||||
codecName: codecName || null,
|
||||
mimetype: mimetype || null,
|
||||
attachedPic: dispositionAttachedPic
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseProbeOutput(rawOutput) {
|
||||
if (!rawOutput) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(rawOutput);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMetadataFromProbe(probe = null, originalName = null) {
|
||||
const format = probe?.format && typeof probe.format === 'object' ? probe.format : {};
|
||||
const tags = normalizeTagMap(format.tags);
|
||||
const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || '')));
|
||||
const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook';
|
||||
const title = pickTag(tags, ['title', 'album']) || fallbackTitle;
|
||||
const author = pickTag(tags, ['author', 'artist', 'writer', 'album_artist', 'composer']) || 'Unknown Author';
|
||||
const description = pickTag(tags, [
|
||||
'description',
|
||||
'synopsis',
|
||||
'summary',
|
||||
'long_description',
|
||||
'longdescription',
|
||||
'publisher_summary',
|
||||
'publishersummary',
|
||||
'comment'
|
||||
]) || null;
|
||||
let narrator = pickTag(tags, ['narrator', 'performer', 'album_artist']) || null;
|
||||
if (narrator && (narrator === author || narrator === description || looksLikeDescription(narrator))) {
|
||||
narrator = null;
|
||||
}
|
||||
const series = pickTag(tags, ['series', 'grouping', 'series_title', 'show']) || null;
|
||||
const part = pickTag(tags, ['part', 'part_number', 'disc', 'discnumber', 'volume']) || null;
|
||||
const year = parseOptionalYear(pickTag(tags, ['date', 'year', 'creation_time']));
|
||||
const durationSeconds = Number(format.duration || 0);
|
||||
const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
? Math.round(durationSeconds * 1000)
|
||||
: 0;
|
||||
const chapters = normalizeChapterList(buildChapterList(probe), {
|
||||
durationMs,
|
||||
fallbackTitle: title,
|
||||
createFallback: false
|
||||
});
|
||||
const cover = detectCoverStream(probe);
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
narrator,
|
||||
description,
|
||||
series,
|
||||
part,
|
||||
year,
|
||||
album: title,
|
||||
artist: author,
|
||||
durationMs,
|
||||
chapters,
|
||||
cover,
|
||||
hasEmbeddedCover: Boolean(cover),
|
||||
tags
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateTokenKey(rawKey) {
|
||||
const key = String(rawKey || '').trim().toLowerCase();
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
if (key === 'artist') {
|
||||
return 'author';
|
||||
}
|
||||
if (key === 'chapternr' || key === 'chapternumberpadded' || key === 'chapternopadded') {
|
||||
return 'chapterNr';
|
||||
}
|
||||
if (key === 'chapterno' || key === 'chapternumber' || key === 'chapternum') {
|
||||
return 'chapterNo';
|
||||
}
|
||||
if (key === 'chaptertitle') {
|
||||
return 'chapterTitle';
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function cleanupRenderedTemplate(value) {
|
||||
return String(value || '')
|
||||
.replace(/\(\s*\)/g, '')
|
||||
.replace(/\[\s*]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
const source = String(template || DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE).trim()
|
||||
|| DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
|
||||
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
|
||||
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
|
||||
const rawValue = values?.[normalizedKey];
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
||||
return '';
|
||||
}
|
||||
return String(rawValue);
|
||||
});
|
||||
return cleanupRenderedTemplate(rendered);
|
||||
}
|
||||
|
||||
function buildTemplateValues(metadata = {}, format = null, chapter = null) {
|
||||
const chapterIndex = Number(chapter?.index || chapter?.chapterNo || 0);
|
||||
const safeChapterIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 ? Math.trunc(chapterIndex) : 1;
|
||||
const author = sanitizeTemplateValue(metadata.author || metadata.artist || 'Unknown Author', 'Unknown Author');
|
||||
const title = sanitizeTemplateValue(metadata.title || metadata.album || 'Unknown Audiobook', 'Unknown Audiobook');
|
||||
const narrator = sanitizeTemplateValue(metadata.narrator || '');
|
||||
const series = sanitizeTemplateValue(metadata.series || '');
|
||||
const part = sanitizeTemplateValue(metadata.part || '');
|
||||
const chapterTitle = sanitizeTemplateValue(chapter?.title || `Kapitel ${safeChapterIndex}`, `Kapitel ${safeChapterIndex}`);
|
||||
const year = metadata.year ? String(metadata.year) : '';
|
||||
return {
|
||||
author,
|
||||
title,
|
||||
narrator,
|
||||
series,
|
||||
part,
|
||||
year,
|
||||
format: format ? String(format).trim().toLowerCase() : '',
|
||||
chapterNr: String(safeChapterIndex).padStart(2, '0'),
|
||||
chapterNo: String(safeChapterIndex),
|
||||
chapterTitle
|
||||
};
|
||||
}
|
||||
|
||||
function splitRenderedPath(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.map((segment) => sanitizeFileName(segment))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveTemplatePathParts(template, values, fallbackBaseName) {
|
||||
const rendered = renderTemplate(template, values);
|
||||
const parts = splitRenderedPath(rendered);
|
||||
if (parts.length === 0) {
|
||||
return {
|
||||
folderParts: [],
|
||||
baseName: sanitizeFileName(fallbackBaseName || 'untitled')
|
||||
};
|
||||
}
|
||||
return {
|
||||
folderParts: parts.slice(0, -1),
|
||||
baseName: parts[parts.length - 1]
|
||||
};
|
||||
}
|
||||
|
||||
function buildRawStoragePaths(metadata, jobId, rawBaseDir, rawTemplate = DEFAULT_AUDIOBOOK_RAW_TEMPLATE, inputFileName = 'input.aax') {
|
||||
const ext = normalizeInputExtension(inputFileName) || '.aax';
|
||||
const values = buildTemplateValues(metadata);
|
||||
const fallbackBaseName = path.basename(String(inputFileName || 'input.aax'), ext);
|
||||
const { folderParts, baseName } = resolveTemplatePathParts(rawTemplate, values, fallbackBaseName);
|
||||
const rawDirName = `${baseName} - RAW - job-${jobId}`;
|
||||
const rawDir = path.join(String(rawBaseDir || ''), ...folderParts, rawDirName);
|
||||
const rawFilePath = path.join(rawDir, `${baseName}${ext}`);
|
||||
return {
|
||||
rawDir,
|
||||
rawFilePath,
|
||||
rawFileName: `${baseName}${ext}`,
|
||||
rawDirName
|
||||
};
|
||||
}
|
||||
|
||||
function buildOutputPath(metadata, movieBaseDir, outputTemplate = DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, outputFormat = 'mp3') {
|
||||
const normalizedFormat = normalizeOutputFormat(outputFormat);
|
||||
const values = buildTemplateValues(metadata, normalizedFormat);
|
||||
const fallbackBaseName = values.title || 'audiobook';
|
||||
const { folderParts, baseName } = resolveTemplatePathParts(outputTemplate, values, fallbackBaseName);
|
||||
return path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`);
|
||||
}
|
||||
|
||||
function findCommonDirectory(paths = []) {
|
||||
const segmentsList = (Array.isArray(paths) ? paths : [])
|
||||
.map((entry) => String(entry || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => path.resolve(entry).split(path.sep).filter((segment, index, list) => !(index === 0 && list[0] === '')));
|
||||
|
||||
if (segmentsList.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const common = [...segmentsList[0]];
|
||||
for (let index = 1; index < segmentsList.length; index += 1) {
|
||||
const next = segmentsList[index];
|
||||
let matchLength = 0;
|
||||
while (matchLength < common.length && matchLength < next.length && common[matchLength] === next[matchLength]) {
|
||||
matchLength += 1;
|
||||
}
|
||||
common.length = matchLength;
|
||||
if (common.length === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (common.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.join(path.sep, ...common);
|
||||
}
|
||||
|
||||
function buildChapterOutputPlan(
|
||||
metadata,
|
||||
chapters,
|
||||
movieBaseDir,
|
||||
chapterTemplate = DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
|
||||
outputFormat = 'mp3'
|
||||
) {
|
||||
const normalizedFormat = normalizeOutputFormat(outputFormat);
|
||||
const normalizedChapters = normalizeChapterList(chapters, {
|
||||
durationMs: metadata?.durationMs,
|
||||
fallbackTitle: metadata?.title || metadata?.album || 'Audiobook',
|
||||
createFallback: true
|
||||
});
|
||||
const outputFiles = normalizedChapters.map((chapter, index) => {
|
||||
const values = buildTemplateValues(metadata, normalizedFormat, chapter);
|
||||
const fallbackBaseName = `${values.chapterNr} ${values.chapterTitle}`.trim() || `Kapitel ${index + 1}`;
|
||||
const { folderParts, baseName } = resolveTemplatePathParts(chapterTemplate, values, fallbackBaseName);
|
||||
const outputPath = path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`);
|
||||
return {
|
||||
chapter,
|
||||
outputPath
|
||||
};
|
||||
});
|
||||
const outputDir = findCommonDirectory(outputFiles.map((entry) => path.dirname(entry.outputPath)))
|
||||
|| String(movieBaseDir || '').trim()
|
||||
|| '.';
|
||||
|
||||
return {
|
||||
outputDir,
|
||||
outputFiles,
|
||||
chapters: normalizedChapters,
|
||||
format: normalizedFormat
|
||||
};
|
||||
}
|
||||
|
||||
function buildProbeCommand(ffprobeCommand, inputPath) {
|
||||
const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe';
|
||||
return {
|
||||
cmd,
|
||||
args: [
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
'-show_chapters',
|
||||
inputPath
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function pushMetadataArg(args, key, value) {
|
||||
const normalizedKey = String(key || '').trim();
|
||||
const normalizedValue = normalizeText(value);
|
||||
if (!normalizedKey || !normalizedValue) {
|
||||
return;
|
||||
}
|
||||
args.push('-metadata', `${normalizedKey}=${normalizedValue}`);
|
||||
}
|
||||
|
||||
function buildMetadataArgs(metadata = {}, options = {}) {
|
||||
const source = metadata && typeof metadata === 'object' ? metadata : {};
|
||||
const titleOverride = normalizeText(options?.title || '');
|
||||
const albumOverride = normalizeText(options?.album || '');
|
||||
const trackNo = Number(options?.trackNo || 0);
|
||||
const trackTotal = Number(options?.trackTotal || 0);
|
||||
const args = [];
|
||||
const bookTitle = normalizeText(source.title || source.album || '');
|
||||
const author = normalizeText(source.author || source.artist || '');
|
||||
|
||||
pushMetadataArg(args, 'title', titleOverride || bookTitle);
|
||||
pushMetadataArg(args, 'album', albumOverride || bookTitle);
|
||||
pushMetadataArg(args, 'artist', author);
|
||||
pushMetadataArg(args, 'album_artist', author);
|
||||
pushMetadataArg(args, 'author', author);
|
||||
pushMetadataArg(args, 'narrator', source.narrator);
|
||||
pushMetadataArg(args, 'performer', source.narrator);
|
||||
pushMetadataArg(args, 'grouping', source.series);
|
||||
pushMetadataArg(args, 'series', source.series);
|
||||
pushMetadataArg(args, 'disc', source.part);
|
||||
pushMetadataArg(args, 'description', source.description);
|
||||
pushMetadataArg(args, 'comment', source.description);
|
||||
if (source.year) {
|
||||
pushMetadataArg(args, 'date', String(source.year));
|
||||
pushMetadataArg(args, 'year', String(source.year));
|
||||
}
|
||||
if (Number.isFinite(trackNo) && trackNo > 0) {
|
||||
const formattedTrack = Number.isFinite(trackTotal) && trackTotal > 0
|
||||
? `${Math.trunc(trackNo)}/${Math.trunc(trackTotal)}`
|
||||
: String(Math.trunc(trackNo));
|
||||
pushMetadataArg(args, 'track', formattedTrack);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildCodecArgs(format, normalizedOptions) {
|
||||
if (format === 'm4b') {
|
||||
return ['-c:a', 'copy'];
|
||||
}
|
||||
if (format === 'flac') {
|
||||
return ['-codec:a', 'flac', '-compression_level', String(normalizedOptions.flacCompression)];
|
||||
}
|
||||
if (normalizedOptions.mp3Mode === 'vbr') {
|
||||
return ['-codec:a', 'libmp3lame', '-q:a', String(normalizedOptions.mp3Quality)];
|
||||
}
|
||||
return ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`];
|
||||
}
|
||||
|
||||
function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}, options = {}) {
|
||||
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const format = normalizeOutputFormat(outputFormat);
|
||||
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
|
||||
const extra = options && typeof options === 'object' ? options : {};
|
||||
const commonArgs = [
|
||||
'-y',
|
||||
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
|
||||
'-i', inputPath
|
||||
];
|
||||
if (extra.chapterMetadataPath) {
|
||||
commonArgs.push('-f', 'ffmetadata', '-i', extra.chapterMetadataPath);
|
||||
}
|
||||
commonArgs.push(
|
||||
'-map', '0:a:0?',
|
||||
'-map_metadata', '0',
|
||||
'-map_chapters', extra.chapterMetadataPath ? '1' : '0',
|
||||
'-vn',
|
||||
'-sn',
|
||||
'-dn'
|
||||
);
|
||||
const metadataArgs = buildMetadataArgs(extra.metadata, extra.metadataOptions);
|
||||
const codecArgs = buildCodecArgs(format, normalizedOptions);
|
||||
return {
|
||||
cmd,
|
||||
args: [...commonArgs, ...codecArgs, ...metadataArgs, outputPath],
|
||||
metadataArgs,
|
||||
formatOptions: normalizedOptions
|
||||
};
|
||||
}
|
||||
|
||||
function formatSecondsArg(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return '0';
|
||||
}
|
||||
return parsed.toFixed(3).replace(/\.?0+$/u, '');
|
||||
}
|
||||
|
||||
function buildChapterEncodeCommand(
|
||||
ffmpegCommand,
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputFormat = 'mp3',
|
||||
formatOptions = {},
|
||||
metadata = {},
|
||||
chapter = {},
|
||||
chapterTotal = 1,
|
||||
options = {}
|
||||
) {
|
||||
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const format = normalizeOutputFormat(outputFormat);
|
||||
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
|
||||
const extra = options && typeof options === 'object' ? options : {};
|
||||
const safeChapter = normalizeChapterList([chapter], {
|
||||
durationMs: metadata?.durationMs,
|
||||
fallbackTitle: metadata?.title || 'Kapitel',
|
||||
createFallback: true
|
||||
})[0];
|
||||
const durationSeconds = Number(((safeChapter?.durationMs || 0) / 1000).toFixed(3));
|
||||
const metadataArgs = buildMetadataArgs(metadata, {
|
||||
title: safeChapter?.title,
|
||||
album: metadata?.title || metadata?.album || null,
|
||||
trackNo: safeChapter?.index || 1,
|
||||
trackTotal: chapterTotal
|
||||
});
|
||||
const codecArgs = buildCodecArgs(format, normalizedOptions);
|
||||
return {
|
||||
cmd,
|
||||
args: [
|
||||
'-y',
|
||||
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
|
||||
'-i', inputPath,
|
||||
'-ss', formatSecondsArg(safeChapter?.startSeconds),
|
||||
'-t', formatSecondsArg(durationSeconds),
|
||||
'-map', '0:a:0?',
|
||||
'-map_metadata', '-1',
|
||||
'-map_chapters', '-1',
|
||||
'-vn',
|
||||
'-sn',
|
||||
'-dn',
|
||||
...codecArgs,
|
||||
...metadataArgs,
|
||||
outputPath
|
||||
],
|
||||
metadataArgs,
|
||||
formatOptions: normalizedOptions
|
||||
};
|
||||
}
|
||||
|
||||
function escapeFfmetadataValue(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/=/g, '\\=')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/#/g, '\\#')
|
||||
.replace(/\r?\n/g, ' ');
|
||||
}
|
||||
|
||||
function buildChapterMetadataContent(chapters = [], metadata = {}) {
|
||||
const normalizedChapters = normalizeChapterList(chapters, {
|
||||
durationMs: metadata?.durationMs,
|
||||
fallbackTitle: metadata?.title || metadata?.album || 'Audiobook',
|
||||
createFallback: true
|
||||
});
|
||||
|
||||
const chapterBlocks = normalizedChapters.map((chapter) => {
|
||||
const startMs = Math.max(0, Math.round(chapter.startMs || 0));
|
||||
const endMs = Math.max(startMs, Math.round(chapter.endMs || startMs));
|
||||
return [
|
||||
'[CHAPTER]',
|
||||
'TIMEBASE=1/1000',
|
||||
`START=${startMs}`,
|
||||
`END=${endMs}`,
|
||||
`title=${escapeFfmetadataValue(chapter.title || `Kapitel ${chapter.index || 1}`)}`
|
||||
].join('\n');
|
||||
}).join('\n\n');
|
||||
|
||||
return `;FFMETADATA1\n\n${chapterBlocks}`;
|
||||
}
|
||||
|
||||
function buildCoverExtractionCommand(ffmpegCommand, inputPath, outputPath, cover = null) {
|
||||
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const streamIndex = Number(cover?.streamIndex);
|
||||
const streamSpecifier = Number.isFinite(streamIndex) && streamIndex >= 0
|
||||
? `0:${Math.trunc(streamIndex)}`
|
||||
: '0:v:0';
|
||||
return {
|
||||
cmd,
|
||||
args: [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-map', streamSpecifier,
|
||||
'-an',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-frames:v', '1',
|
||||
'-c:v', 'mjpeg',
|
||||
'-q:v', '2',
|
||||
outputPath
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function parseFfmpegTimestampToMs(rawValue) {
|
||||
const value = String(rawValue || '').trim();
|
||||
const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
const seconds = Number(match[3]);
|
||||
const fraction = match[4] ? Number(`0.${match[4]}`) : 0;
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) {
|
||||
return null;
|
||||
}
|
||||
return Math.round((((hours * 60) + minutes) * 60 + seconds + fraction) * 1000);
|
||||
}
|
||||
|
||||
function buildProgressParser(totalDurationMs) {
|
||||
const durationMs = Number(totalDurationMs || 0);
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (line) => {
|
||||
const match = String(line || '').match(/time=(\d+:\d{2}:\d{2}(?:\.\d+)?)/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const currentMs = parseFfmpegTimestampToMs(match[1]);
|
||||
if (!Number.isFinite(currentMs)) {
|
||||
return null;
|
||||
}
|
||||
const percent = Math.max(0, Math.min(100, Number(((currentMs / durationMs) * 100).toFixed(2))));
|
||||
return {
|
||||
percent,
|
||||
eta: null
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_INPUT_EXTENSIONS,
|
||||
SUPPORTED_OUTPUT_FORMATS,
|
||||
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
|
||||
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
||||
DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
|
||||
AUDIOBOOK_FORMAT_DEFAULTS,
|
||||
normalizeOutputFormat,
|
||||
getDefaultFormatOptions,
|
||||
normalizeFormatOptions,
|
||||
isSupportedInputFile,
|
||||
buildMetadataFromProbe,
|
||||
normalizeChapterList,
|
||||
buildRawStoragePaths,
|
||||
buildOutputPath,
|
||||
buildChapterOutputPlan,
|
||||
buildProbeCommand,
|
||||
parseProbeOutput,
|
||||
buildEncodeCommand,
|
||||
buildChapterEncodeCommand,
|
||||
buildChapterMetadataContent,
|
||||
buildCoverExtractionCommand,
|
||||
buildProgressParser
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
const fs = require('fs');
|
||||
const logger = require('./logger').child('AUDNEX');
|
||||
|
||||
const AUDNEX_BASE_URL = 'https://api.audnex.us';
|
||||
const AUDNEX_TIMEOUT_MS = 10000;
|
||||
const ASIN_PATTERN = /B0[0-9A-Z]{8}/u;
|
||||
|
||||
function normalizeAsin(value) {
|
||||
const raw = String(value || '').trim().toUpperCase();
|
||||
return ASIN_PATTERN.test(raw) ? raw : null;
|
||||
}
|
||||
|
||||
async function extractAsinFromAaxFile(filePath) {
|
||||
const sourcePath = String(filePath || '').trim();
|
||||
if (!sourcePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let printableWindow = '';
|
||||
let settled = false;
|
||||
const stream = fs.createReadStream(sourcePath, { highWaterMark: 64 * 1024 });
|
||||
|
||||
const finish = (value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const byte of chunk) {
|
||||
if (byte >= 32 && byte <= 126) {
|
||||
printableWindow = `${printableWindow}${String.fromCharCode(byte)}`.slice(-48);
|
||||
const match = printableWindow.match(/B0[0-9A-Z]{8}/u);
|
||||
if (match?.[0]) {
|
||||
const asin = normalizeAsin(match[0]);
|
||||
if (asin) {
|
||||
logger.info('asin:detected', { filePath: sourcePath, asin });
|
||||
stream.destroy();
|
||||
finish(asin);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
printableWindow = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
if (!settled) {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function audnexFetch(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), AUDNEX_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Ripster/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Audnex Anfrage fehlgeschlagen (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function extractChapterArray(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const candidates = [
|
||||
payload?.chapters,
|
||||
payload?.data?.chapters,
|
||||
payload?.content?.chapters,
|
||||
payload?.results?.chapters
|
||||
];
|
||||
return candidates.find((entry) => Array.isArray(entry)) || [];
|
||||
}
|
||||
|
||||
function normalizeAudnexChapter(entry, index) {
|
||||
const startOffsetMs = Number(
|
||||
entry?.startOffsetMs
|
||||
?? entry?.startMs
|
||||
?? entry?.offsetMs
|
||||
?? 0
|
||||
);
|
||||
const lengthMs = Number(
|
||||
entry?.lengthMs
|
||||
?? entry?.durationMs
|
||||
?? entry?.length
|
||||
?? 0
|
||||
);
|
||||
const title = String(entry?.title || entry?.chapterTitle || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`;
|
||||
const safeStartMs = Number.isFinite(startOffsetMs) && startOffsetMs >= 0 ? Math.round(startOffsetMs) : 0;
|
||||
const safeLengthMs = Number.isFinite(lengthMs) && lengthMs > 0 ? Math.round(lengthMs) : 0;
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
title,
|
||||
startMs: safeStartMs,
|
||||
endMs: safeStartMs + safeLengthMs,
|
||||
startSeconds: Math.round(safeStartMs / 1000),
|
||||
endSeconds: Math.round((safeStartMs + safeLengthMs) / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNameList(items) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const names = items
|
||||
.map((item) => String(item?.name || '').trim())
|
||||
.filter(Boolean);
|
||||
return names.length > 0 ? names.join(', ') : null;
|
||||
}
|
||||
|
||||
async function fetchBookByAsin(asin, region = 'de') {
|
||||
const normalizedAsin = normalizeAsin(asin);
|
||||
if (!normalizedAsin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}`);
|
||||
url.searchParams.set('region', String(region || 'de').trim() || 'de');
|
||||
logger.info('book:fetch:start', { asin: normalizedAsin, url: url.toString() });
|
||||
|
||||
const payload = await audnexFetch(url.toString());
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const narrator = normalizeNameList(payload.narrators);
|
||||
const author = normalizeNameList(payload.authors);
|
||||
const title = String(payload.title || '').trim() || null;
|
||||
const series = String(payload.seriesName || payload.series || '').trim() || null;
|
||||
const part = String(payload.seriesPart || payload.part || '').trim() || null;
|
||||
const description = String(payload.summary || payload.description || '').trim() || null;
|
||||
const year = payload.releaseDate
|
||||
? String(payload.releaseDate).slice(0, 4)
|
||||
: null;
|
||||
|
||||
logger.info('book:fetch:done', { asin: normalizedAsin, narrator, author, title });
|
||||
return { narrator, author, title, series, part, description, year };
|
||||
}
|
||||
|
||||
async function fetchChaptersByAsin(asin, region = 'de') {
|
||||
const normalizedAsin = normalizeAsin(asin);
|
||||
if (!normalizedAsin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}/chapters`);
|
||||
url.searchParams.set('region', String(region || 'de').trim() || 'de');
|
||||
logger.info('chapters:fetch:start', { asin: normalizedAsin, url: url.toString() });
|
||||
|
||||
const payload = await audnexFetch(url.toString());
|
||||
const chapters = extractChapterArray(payload)
|
||||
.map((entry, index) => normalizeAudnexChapter(entry, index))
|
||||
.filter((chapter) => chapter.endMs > chapter.startMs && chapter.title);
|
||||
|
||||
logger.info('chapters:fetch:done', { asin: normalizedAsin, count: chapters.length });
|
||||
return chapters;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractAsinFromAaxFile,
|
||||
fetchBookByAsin,
|
||||
fetchChaptersByAsin
|
||||
};
|
||||
@@ -0,0 +1,784 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const logger = require('./logger').child('CD_RIP');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir, transliterateForFilename } = require('../utils/files');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']);
|
||||
const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}';
|
||||
|
||||
/**
|
||||
* Parse cdparanoia -Q output to extract track information.
|
||||
* Supports both bracket styles shown by different builds:
|
||||
* track 1: 0 (00:00.00) 24218 (05:22.43)
|
||||
* track 1: 0 [00:00.00] 24218 [05:22.43]
|
||||
*/
|
||||
function parseToc(tocOutput) {
|
||||
const lines = String(tocOutput || '').split(/\r?\n/);
|
||||
const tracks = [];
|
||||
const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g;
|
||||
const tocTablePattern =
|
||||
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i;
|
||||
|
||||
for (const line of lines) {
|
||||
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
||||
if (trackMatch) {
|
||||
const position = Number(trackMatch[1]);
|
||||
const payloadWithoutTimes = String(trackMatch[2] || '')
|
||||
.replace(tocTimePattern, ' ');
|
||||
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
||||
if (sectorValues.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startSector = Number(sectorValues[0]);
|
||||
const lengthSector = Number(sectorValues[1]);
|
||||
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
|
||||
continue;
|
||||
}
|
||||
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// duration in seconds: sectors / 75
|
||||
const durationSec = Math.round(lengthSector / 75);
|
||||
tracks.push({
|
||||
position,
|
||||
startSector,
|
||||
lengthSector,
|
||||
durationSec,
|
||||
durationMs: durationSec * 1000
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alternative cdparanoia -Q table style:
|
||||
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
|
||||
// ^ length sectors ^ start sector
|
||||
const tableMatch = line.match(tocTablePattern);
|
||||
if (!tableMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = Number(tableMatch[1]);
|
||||
const lengthSector = Number(tableMatch[2]);
|
||||
const startSector = Number(tableMatch[3]);
|
||||
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
|
||||
continue;
|
||||
}
|
||||
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const durationSec = Math.round(lengthSector / 75);
|
||||
tracks.push({
|
||||
position,
|
||||
startSector,
|
||||
lengthSector,
|
||||
durationSec,
|
||||
durationMs: durationSec * 1000
|
||||
});
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
async function readToc(devicePath, cmd) {
|
||||
const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
|
||||
logger.info('toc:read', { devicePath, cmd: cdparanoia });
|
||||
try {
|
||||
// Depending on distro/build, TOC can appear on stderr and/or stdout.
|
||||
const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
|
||||
timeout: 15000
|
||||
});
|
||||
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||
logger.info('toc:done', { devicePath, trackCount: tracks.length });
|
||||
return tracks;
|
||||
} catch (error) {
|
||||
// cdparanoia -Q may exit non-zero even when TOC is readable.
|
||||
const stderr = String(error?.stderr || '');
|
||||
const stdout = String(error?.stdout || '');
|
||||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length });
|
||||
return tracks;
|
||||
}
|
||||
logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate);
|
||||
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
|
||||
return `${relativeBasePath}.${ext}`;
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value, fallback = 'unknown') {
|
||||
const raw = transliterateForFilename(String(value == null ? '' : value))
|
||||
.replace(/[\\/:*?"<>|]/g, '-')
|
||||
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
|
||||
.replace(/\p{C}+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!raw || raw === '.' || raw === '..') {
|
||||
return fallback;
|
||||
}
|
||||
return raw.slice(0, 180);
|
||||
}
|
||||
|
||||
function normalizeTemplateTokenKey(rawKey) {
|
||||
const key = String(rawKey || '').trim().toLowerCase();
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') {
|
||||
return 'trackNr';
|
||||
}
|
||||
if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') {
|
||||
return 'trackNo';
|
||||
}
|
||||
if (key === 'trackartist' || key === 'track_artist') {
|
||||
return 'trackArtist';
|
||||
}
|
||||
if (key === 'albumartist') {
|
||||
return 'albumArtist';
|
||||
}
|
||||
if (key === 'interpret') {
|
||||
return 'artist';
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function cleanupRenderedTemplate(value) {
|
||||
return String(value || '')
|
||||
.replace(/\(\s*\)/g, '')
|
||||
.replace(/\[\s*]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderOutputTemplate(template, values) {
|
||||
const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE;
|
||||
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
|
||||
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
|
||||
const rawValue = values[normalizedKey];
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return '';
|
||||
}
|
||||
return String(rawValue);
|
||||
});
|
||||
return cleanupRenderedTemplate(rendered);
|
||||
}
|
||||
|
||||
function buildTemplateValues(track, meta, format = null) {
|
||||
const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1;
|
||||
const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`);
|
||||
const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist');
|
||||
const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist');
|
||||
const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album');
|
||||
const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), '');
|
||||
return {
|
||||
artist: albumArtist,
|
||||
albumArtist,
|
||||
trackArtist,
|
||||
album,
|
||||
year,
|
||||
title: trackTitle,
|
||||
trackNr: String(trackNo).padStart(2, '0'),
|
||||
trackNo: String(trackNo),
|
||||
format: format ? String(format).trim().toLowerCase() : ''
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) {
|
||||
const values = buildTemplateValues(track, meta, format);
|
||||
const rendered = renderOutputTemplate(outputTemplate, values)
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
const parts = rendered
|
||||
.split('/')
|
||||
.map((part) => sanitizePathSegment(part, 'unknown'))
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return path.join(...parts);
|
||||
}
|
||||
|
||||
function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||
const sampleTrack = {
|
||||
position: 1,
|
||||
title: 'Track 1'
|
||||
};
|
||||
const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate);
|
||||
const relativeDir = path.dirname(relativeBasePath);
|
||||
if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) {
|
||||
return baseDir;
|
||||
}
|
||||
return path.join(baseDir, relativeDir);
|
||||
}
|
||||
|
||||
function splitPathSegments(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
|
||||
const outputSegments = splitPathSegments(outputBaseDir);
|
||||
const relativeSegments = splitPathSegments(relativeDir);
|
||||
if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) {
|
||||
return false;
|
||||
}
|
||||
const offset = outputSegments.length - relativeSegments.length;
|
||||
for (let i = 0; i < relativeSegments.length; i++) {
|
||||
const outputPart = outputSegments[offset + i];
|
||||
const relativePart = relativeSegments[i];
|
||||
if (outputPart === relativePart) {
|
||||
continue;
|
||||
}
|
||||
// Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2").
|
||||
// Treat this as containing the same relative directory to avoid nesting:
|
||||
// Album (2007)_2/Album (2007)/...
|
||||
const isLastRelativeSegment = i === relativeSegments.length - 1;
|
||||
const matchesNumberedSuffix = isLastRelativeSegment
|
||||
&& outputPart.startsWith(`${relativePart}_`)
|
||||
&& /^\d+$/.test(outputPart.slice(relativePart.length + 1));
|
||||
if (!matchesNumberedSuffix) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function stripLeadingRelativeDir(relativeFilePath, relativeDir) {
|
||||
const fileSegments = splitPathSegments(relativeFilePath);
|
||||
const dirSegments = splitPathSegments(relativeDir);
|
||||
if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) {
|
||||
return relativeFilePath;
|
||||
}
|
||||
for (let i = 0; i < dirSegments.length; i++) {
|
||||
if (fileSegments[i] !== dirSegments[i]) {
|
||||
return relativeFilePath;
|
||||
}
|
||||
}
|
||||
return path.join(...fileSegments.slice(dirSegments.length));
|
||||
}
|
||||
|
||||
function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format);
|
||||
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
|
||||
const relativeDir = path.dirname(relativeBasePath);
|
||||
let relativeFilePath = `${relativeBasePath}.${ext}`;
|
||||
if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) {
|
||||
if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) {
|
||||
relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir);
|
||||
}
|
||||
}
|
||||
const outFile = path.join(outputBaseDir, relativeFilePath);
|
||||
return {
|
||||
outFile,
|
||||
relativeFilePath,
|
||||
outFilename: path.basename(relativeFilePath)
|
||||
};
|
||||
}
|
||||
|
||||
function buildCancelledError() {
|
||||
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
error.statusCode = 409;
|
||||
return error;
|
||||
}
|
||||
|
||||
function assertNotCancelled(isCancelled) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
throw buildCancelledError();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExitCode(error) {
|
||||
const code = Number(error?.code);
|
||||
if (Number.isFinite(code)) {
|
||||
return Math.trunc(code);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function quoteShellArg(value) {
|
||||
const text = String(value == null ? '' : value);
|
||||
if (!text) {
|
||||
return "''";
|
||||
}
|
||||
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
return `'${text.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function formatCommandLine(cmd, args = []) {
|
||||
const normalizedArgs = Array.isArray(args) ? args : [];
|
||||
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
|
||||
}
|
||||
|
||||
function copyFilePreservingRaw(sourcePath, targetPath) {
|
||||
const rawSource = String(sourcePath || '').trim();
|
||||
const rawTarget = String(targetPath || '').trim();
|
||||
if (!rawSource || !rawTarget) {
|
||||
return;
|
||||
}
|
||||
const source = path.resolve(rawSource);
|
||||
const target = path.resolve(rawTarget);
|
||||
if (source === target) {
|
||||
return;
|
||||
}
|
||||
fs.copyFileSync(source, target);
|
||||
}
|
||||
|
||||
async function runProcessTracked({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
onStdoutLine,
|
||||
onStderrLine,
|
||||
context,
|
||||
onProcessHandle,
|
||||
isCancelled
|
||||
}) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
onStdoutLine,
|
||||
onStderrLine,
|
||||
context
|
||||
});
|
||||
if (typeof onProcessHandle === 'function') {
|
||||
onProcessHandle(handle);
|
||||
}
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
handle.cancel();
|
||||
}
|
||||
try {
|
||||
return await handle.promise;
|
||||
} catch (error) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
throw buildCancelledError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.jobId - Job ID for logging
|
||||
* @param {string} options.devicePath - e.g. /dev/sr0
|
||||
* @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia
|
||||
* @param {string} options.rawWavDir - temp dir for WAV files
|
||||
* @param {string} options.outputDir - final output dir
|
||||
* @param {string} options.format - wav|flac|mp3|opus|ogg
|
||||
* @param {object} options.formatOptions - encoder-specific options
|
||||
* @param {number[]} options.selectedTracks - track positions to rip (empty = all)
|
||||
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
||||
* @param {object} options.meta - album metadata {title, artist, year}
|
||||
* @param {string} options.outputTemplate - template for relative output path without extension
|
||||
* @param {boolean} options.skipEncode - true => rip only (no encode)
|
||||
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
||||
* @param {Function} options.onLog - (level, msg) => void
|
||||
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
|
||||
* @param {Function} options.isCancelled - returns true when user requested cancellation
|
||||
* @param {object} options.context - passed to spawnTrackedProcess
|
||||
*/
|
||||
async function ripAndEncode(options) {
|
||||
const {
|
||||
jobId,
|
||||
devicePath,
|
||||
cdparanoiaCmd = 'cdparanoia',
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
format = 'flac',
|
||||
formatOptions = {},
|
||||
selectedTracks = [],
|
||||
tracks = [],
|
||||
meta = {},
|
||||
outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||
onProgress,
|
||||
onLog,
|
||||
onProcessHandle,
|
||||
isCancelled,
|
||||
context,
|
||||
skipRip = false,
|
||||
skipEncode = false
|
||||
} = options;
|
||||
|
||||
if (!skipEncode && !SUPPORTED_FORMATS.has(format)) {
|
||||
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
||||
}
|
||||
|
||||
const tracksToRip = selectedTracks.length > 0
|
||||
? tracks.filter((t) => selectedTracks.includes(t.position))
|
||||
: tracks;
|
||||
|
||||
if (tracksToRip.length === 0) {
|
||||
throw new Error('Keine Tracks zum Rippen ausgewählt.');
|
||||
}
|
||||
|
||||
await ensureDir(rawWavDir);
|
||||
if (!skipEncode) {
|
||||
await ensureDir(outputDir);
|
||||
}
|
||||
|
||||
logger.info('rip:start', {
|
||||
jobId,
|
||||
devicePath,
|
||||
format,
|
||||
trackCount: tracksToRip.length
|
||||
});
|
||||
|
||||
const log = (level, msg) => {
|
||||
logger[level] && logger[level](msg, { jobId });
|
||||
onLog && onLog(level, msg);
|
||||
};
|
||||
const ripPercentSpan = skipEncode ? 100 : 50;
|
||||
const encodePercentStart = ripPercentSpan;
|
||||
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 = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 ripArgs = ['-d', devicePath, String(track.position), wavFile];
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackEvent: 'start',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: (i / tracksToRip.length) * ripPercentSpan
|
||||
});
|
||||
|
||||
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
||||
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
|
||||
|
||||
try {
|
||||
await runProcessTracked({
|
||||
cmd: cdparanoiaCmd,
|
||||
args: ripArgs,
|
||||
cwd: rawWavDir,
|
||||
onStderrLine(line) {
|
||||
const parsed = parseCdParanoiaProgress(line);
|
||||
if (parsed && parsed.percent !== null) {
|
||||
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * ripPercentSpan;
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackEvent: 'progress',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: parsed.percent,
|
||||
percent: overallPercent
|
||||
});
|
||||
}
|
||||
},
|
||||
context,
|
||||
onProcessHandle,
|
||||
isCancelled
|
||||
});
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackEvent: 'complete',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: ((i + 1) / tracksToRip.length) * ripPercentSpan
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} gerippt.`);
|
||||
}
|
||||
} // end if (!skipRip)
|
||||
|
||||
if (skipEncode) {
|
||||
return {
|
||||
outputDir: null,
|
||||
format: 'raw',
|
||||
trackCount: tracksToRip.length,
|
||||
encodeResults: []
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||
const encodeResults = [];
|
||||
|
||||
if (format === 'wav') {
|
||||
// Keep RAW WAVs in place and copy them to the final output structure.
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackEvent: 'start',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||
});
|
||||
ensureDir(path.dirname(outFile));
|
||||
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||||
copyFilePreservingRaw(wavFile, outFile);
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackEvent: 'complete',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||
});
|
||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
|
||||
}
|
||||
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
|
||||
}
|
||||
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
|
||||
}
|
||||
|
||||
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
|
||||
ensureDir(path.dirname(outFile));
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackEvent: 'start',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||
});
|
||||
|
||||
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
||||
|
||||
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
|
||||
log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`);
|
||||
|
||||
try {
|
||||
await runProcessTracked({
|
||||
cmd: encodeArgs.cmd,
|
||||
args: encodeArgs.args,
|
||||
cwd: rawWavDir,
|
||||
onStdoutLine() {},
|
||||
onStderrLine() {},
|
||||
context,
|
||||
onProcessHandle,
|
||||
isCancelled
|
||||
});
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||
throw error;
|
||||
}
|
||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false });
|
||||
throw new Error(
|
||||
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||||
);
|
||||
}
|
||||
|
||||
// Safety net: some encoders (e.g. older flac without --no-delete-input-file) may remove
|
||||
// the source WAV. Restore it from the encoded output so the RAW folder stays filled.
|
||||
if (!fs.existsSync(wavFile) && fs.existsSync(outFile)) {
|
||||
try {
|
||||
fs.copyFileSync(outFile, wavFile);
|
||||
log('info', `Track ${track.position}: WAV-Quelldatei vom Encoder gelöscht – aus Output wiederhergestellt.`);
|
||||
} catch (restoreErr) {
|
||||
log('warn', `Track ${track.position}: WAV-Wiederherstellung fehlgeschlagen: ${restoreErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackEvent: 'complete',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} encodiert.`);
|
||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: true });
|
||||
}
|
||||
|
||||
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
|
||||
}
|
||||
|
||||
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
|
||||
const artist = track?.artist || meta?.artist || '';
|
||||
const album = meta?.title || '';
|
||||
const year = meta?.year ? String(meta.year) : '';
|
||||
const trackTitle = track.title || `Track ${track.position}`;
|
||||
const trackNum = String(track.position);
|
||||
|
||||
if (format === 'flac') {
|
||||
const level = Number(opts.flacCompression ?? 5);
|
||||
const clampedLevel = Math.max(0, Math.min(8, level));
|
||||
return {
|
||||
cmd: 'flac',
|
||||
args: [
|
||||
`--compression-level-${clampedLevel}`,
|
||||
'--no-delete-input-file', // flac deletes input WAV by default; keep RAW folder filled
|
||||
'--tag', `TITLE=${trackTitle}`,
|
||||
'--tag', `ARTIST=${artist}`,
|
||||
'--tag', `ALBUM=${album}`,
|
||||
'--tag', `DATE=${year}`,
|
||||
'--tag', `TRACKNUMBER=${trackNum}`,
|
||||
wavFile,
|
||||
'-o', outFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (format === 'mp3') {
|
||||
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
|
||||
const args = ['--id3v2-only', '--noreplaygain'];
|
||||
if (mode === 'vbr') {
|
||||
const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)));
|
||||
args.push('-V', String(quality));
|
||||
} else {
|
||||
const bitrate = Number(opts.mp3Bitrate ?? 192);
|
||||
args.push('-b', String(bitrate));
|
||||
}
|
||||
args.push(
|
||||
'--tt', trackTitle,
|
||||
'--ta', artist,
|
||||
'--tl', album,
|
||||
'--ty', year,
|
||||
'--tn', trackNum,
|
||||
wavFile,
|
||||
outFile
|
||||
);
|
||||
return { cmd: 'lame', args };
|
||||
}
|
||||
|
||||
if (format === 'opus') {
|
||||
const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160)));
|
||||
const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10)));
|
||||
return {
|
||||
cmd: 'opusenc',
|
||||
args: [
|
||||
'--bitrate', String(bitrate),
|
||||
'--comp', String(complexity),
|
||||
'--title', trackTitle,
|
||||
'--artist', artist,
|
||||
'--album', album,
|
||||
'--date', year,
|
||||
'--tracknumber', trackNum,
|
||||
wavFile,
|
||||
outFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (format === 'ogg') {
|
||||
const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)));
|
||||
return {
|
||||
cmd: 'oggenc',
|
||||
args: [
|
||||
'-q', String(quality),
|
||||
'-t', trackTitle,
|
||||
'-a', artist,
|
||||
'-l', album,
|
||||
'-d', year,
|
||||
'-N', trackNum,
|
||||
'-o', outFile,
|
||||
wavFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unbekanntes Format: ${format}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseToc,
|
||||
readToc,
|
||||
ripAndEncode,
|
||||
buildOutputDir,
|
||||
buildOutputFilename,
|
||||
DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||
SUPPORTED_FORMATS
|
||||
};
|
||||
@@ -0,0 +1,697 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getDb } = require('../db/database');
|
||||
const settingsService = require('./settingsService');
|
||||
const wsService = require('./websocketService');
|
||||
const logger = require('./logger').child('CONVERTER_SCAN');
|
||||
const {
|
||||
defaultConverterRawDir
|
||||
} = require('../config');
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
|
||||
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
|
||||
const ISO_EXTENSIONS = new Set(['iso']);
|
||||
const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
|
||||
|
||||
let _pollingTimer = null;
|
||||
let _pollingEnabled = false;
|
||||
let _pollingInterval = 300;
|
||||
|
||||
function detectMediaType(fileName) {
|
||||
const ext = path.extname(String(fileName || '')).slice(1).toLowerCase();
|
||||
if (ISO_EXTENSIONS.has(ext)) return 'iso';
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
||||
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectFormat(fileName) {
|
||||
return path.extname(String(fileName || '')).slice(1).toLowerCase() || null;
|
||||
}
|
||||
|
||||
function getConfiguredExtensions(settings) {
|
||||
const raw = String(settings?.converter_scan_extensions || '').trim();
|
||||
if (!raw) {
|
||||
return new Set(SUPPORTED_SCAN_EXTENSIONS);
|
||||
}
|
||||
const configured = raw.split(',')
|
||||
.map((ext) => ext.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const filtered = configured.filter((ext) => SUPPORTED_SCAN_EXTENSIONS.has(ext));
|
||||
if (filtered.length === 0) {
|
||||
return new Set(SUPPORTED_SCAN_EXTENSIONS);
|
||||
}
|
||||
return new Set(filtered);
|
||||
}
|
||||
|
||||
function getFileSize(fullPath) {
|
||||
try {
|
||||
return fs.statSync(fullPath).size;
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SCAN_MAX_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* Rekursiv alle Dateien und direkten Unterordner im rawDir scannen.
|
||||
* Gibt ein flaches Array von { relPath, entryType, fileSize, detectedMediaType, detectedFormat } zurück.
|
||||
* Maximale Tiefe: SCAN_MAX_DEPTH (verhindert unendliche Rekursion bei geschachtelten Ordnern).
|
||||
*/
|
||||
function scanDirectory(rawDir, allowedExtensions, parentRelPath = '', depth = 0) {
|
||||
const entries = [];
|
||||
|
||||
if (depth >= SCAN_MAX_DEPTH) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
||||
} catch (_err) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
for (const dirent of dirEntries) {
|
||||
const relPath = parentRelPath ? `${parentRelPath}/${dirent.name}` : dirent.name;
|
||||
const fullPath = path.join(rawDir, relPath);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
entries.push({
|
||||
relPath,
|
||||
entryType: 'directory',
|
||||
fileSize: null,
|
||||
detectedMediaType: null,
|
||||
detectedFormat: null
|
||||
});
|
||||
// Rekursiv in Unterordner (mit Tiefenbegrenzung)
|
||||
const subEntries = scanDirectory(rawDir, allowedExtensions, relPath, depth + 1);
|
||||
entries.push(...subEntries);
|
||||
} else if (dirent.isFile()) {
|
||||
const ext = path.extname(dirent.name).slice(1).toLowerCase();
|
||||
if (!allowedExtensions.has(ext)) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
relPath,
|
||||
entryType: 'file',
|
||||
fileSize: getFileSize(fullPath),
|
||||
detectedMediaType: detectMediaType(dirent.name),
|
||||
detectedFormat: detectFormat(dirent.name)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan-Ergebnisse in die DB schreiben (INSERT OR REPLACE) und
|
||||
* nicht mehr vorhandene Einträge ohne Job entfernen.
|
||||
*/
|
||||
async function persistScanResults(rawDir, entries) {
|
||||
const db = await getDb();
|
||||
|
||||
if (entries.length > 0) {
|
||||
const stmt = await db.prepare(`
|
||||
INSERT INTO converter_scan_entries (rel_path, entry_type, file_size, detected_media_type, detected_format, 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,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`);
|
||||
|
||||
for (const entry of entries) {
|
||||
await stmt.run(
|
||||
entry.relPath,
|
||||
entry.entryType,
|
||||
entry.fileSize,
|
||||
entry.detectedMediaType,
|
||||
entry.detectedFormat
|
||||
);
|
||||
}
|
||||
await stmt.finalize();
|
||||
}
|
||||
|
||||
// Einträge ohne zugewiesenen Job entfernen, wenn Datei nicht mehr vorhanden
|
||||
const existingEntries = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE job_id IS NULL`
|
||||
);
|
||||
const currentRelPaths = new Set(entries.map((e) => e.relPath));
|
||||
|
||||
for (const row of existingEntries) {
|
||||
if (!currentRelPaths.has(row.rel_path)) {
|
||||
const fullPath = path.join(rawDir, row.rel_path);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
await db.run(
|
||||
`DELETE FROM converter_scan_entries WHERE rel_path = ? AND job_id IS NULL`,
|
||||
[row.rel_path]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hauptmethode: rawDir scannen, DB aktualisieren, WebSocket-Event senden.
|
||||
*/
|
||||
async function scan() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const rawDir = String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
||||
|
||||
if (!rawDir) {
|
||||
logger.warn('converter:scan:no-raw-dir');
|
||||
return { rawDir: null, entryCount: 0 };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(rawDir)) {
|
||||
logger.warn('converter:scan:dir-missing', { rawDir });
|
||||
return { rawDir, entryCount: 0 };
|
||||
}
|
||||
|
||||
const allowedExtensions = getConfiguredExtensions(settings);
|
||||
logger.info('converter:scan:start', { rawDir, allowedExtensions: [...allowedExtensions] });
|
||||
|
||||
const entries = scanDirectory(rawDir, allowedExtensions);
|
||||
await persistScanResults(rawDir, entries);
|
||||
|
||||
logger.info('converter:scan:done', { rawDir, entryCount: entries.length });
|
||||
|
||||
wsService.broadcast('CONVERTER_SCAN_UPDATE', { entryCount: entries.length });
|
||||
|
||||
return { rawDir, entryCount: entries.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Einträge für den File-Explorer zurückgeben.
|
||||
* Optionaler parentRelPath filtert auf Kinder eines bestimmten Verzeichnisses.
|
||||
*/
|
||||
async function getEntries(parentRelPath = null) {
|
||||
const db = await getDb();
|
||||
|
||||
let rows;
|
||||
if (!parentRelPath) {
|
||||
// Root-Ebene: nur Einträge ohne '/' im rel_path
|
||||
rows = await db.all(`
|
||||
SELECT e.*, j.status AS job_status, j.title AS job_title
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
ORDER BY e.entry_type DESC, e.rel_path ASC
|
||||
`);
|
||||
// Nur direkte Kinder (kein '/' im rel_path)
|
||||
rows = rows.filter((r) => !String(r.rel_path).includes('/'));
|
||||
} else {
|
||||
const prefix = parentRelPath.endsWith('/') ? parentRelPath : `${parentRelPath}/`;
|
||||
rows = await db.all(`
|
||||
SELECT e.*, j.status AS job_status, j.title AS job_title
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
ORDER BY e.entry_type DESC, e.rel_path ASC
|
||||
`);
|
||||
// Direkte Kinder des angegebenen Verzeichnisses
|
||||
rows = rows.filter((r) => {
|
||||
const rel = String(r.rel_path);
|
||||
if (!rel.startsWith(prefix)) return false;
|
||||
const remainder = rel.slice(prefix.length);
|
||||
return !remainder.includes('/');
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
relPath: r.rel_path,
|
||||
entryType: r.entry_type,
|
||||
fileSize: r.file_size,
|
||||
detectedMediaType: r.detected_media_type,
|
||||
detectedFormat: r.detected_format,
|
||||
jobId: r.job_id,
|
||||
jobStatus: r.job_status || null,
|
||||
jobTitle: r.job_title || null,
|
||||
lastSeenAt: r.last_seen_at
|
||||
}));
|
||||
}
|
||||
|
||||
async function getEntryById(id) {
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`SELECT * FROM converter_scan_entries WHERE id = ?`,
|
||||
[Number(id)]
|
||||
);
|
||||
return row || null;
|
||||
}
|
||||
|
||||
async function getEntryByRelPath(relPath) {
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`SELECT * FROM converter_scan_entries WHERE rel_path = ?`,
|
||||
[relPath]
|
||||
);
|
||||
return row || null;
|
||||
}
|
||||
|
||||
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 = 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling-Loop starten.
|
||||
*/
|
||||
async function startPolling() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
_pollingEnabled = String(settings?.converter_polling_enabled || 'false').toLowerCase() === 'true';
|
||||
_pollingInterval = Math.max(30, Number(settings?.converter_polling_interval || 300)) * 1000;
|
||||
|
||||
stopPolling();
|
||||
|
||||
if (!_pollingEnabled) {
|
||||
logger.info('converter:polling:disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('converter:polling:start', { intervalMs: _pollingInterval });
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
await scan();
|
||||
} catch (error) {
|
||||
logger.error('converter:polling:error', { error: error?.message });
|
||||
}
|
||||
if (_pollingEnabled) {
|
||||
_pollingTimer = setTimeout(tick, _pollingInterval);
|
||||
}
|
||||
};
|
||||
|
||||
_pollingTimer = setTimeout(tick, _pollingInterval);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (_pollingTimer) {
|
||||
clearTimeout(_pollingTimer);
|
||||
_pollingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function restartPolling() {
|
||||
await startPolling();
|
||||
}
|
||||
|
||||
// ── Datei-Operationen (Löschen, Umbenennen, Verschieben, Ordner erstellen) ──
|
||||
|
||||
/**
|
||||
* Relativen Pfad normalisieren und auf Path-Traversal prüfen.
|
||||
* Gibt null zurück wenn der Pfad ungültig ist.
|
||||
*/
|
||||
function normalizeRelPath(input) {
|
||||
if (input === null || input === undefined) return '';
|
||||
const raw = String(input).replace(/\\/g, '/').trim();
|
||||
if (!raw || raw === '.') return '';
|
||||
if (raw.startsWith('/')) return null;
|
||||
const normalized = path.posix.normalize(raw);
|
||||
if (normalized.startsWith('..')) return null;
|
||||
if (normalized === '.') return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function makeError(msg, code) {
|
||||
const err = new Error(msg);
|
||||
err.statusCode = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner löschen. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function deleteEntry(relPath) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger oder leerer Pfad (Root kann nicht gelöscht werden).', 400);
|
||||
|
||||
const absPath = path.join(rawDir, rel);
|
||||
// Traversal-Schutz
|
||||
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht gelöscht werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absPath)) throw makeError(`Pfad existiert nicht: ${rel}`, 404);
|
||||
|
||||
fs.rmSync(absPath, { recursive: true, force: true });
|
||||
|
||||
await db.run(
|
||||
`DELETE FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
|
||||
return { deleted: rel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner umbenennen. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function renameEntry(relPath, newName) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
|
||||
|
||||
const safeName = String(newName || '').trim();
|
||||
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
|
||||
throw makeError('Ungültiger Name.', 400);
|
||||
}
|
||||
|
||||
const parentRel = path.posix.dirname(rel);
|
||||
const newRel = (parentRel === '.' || parentRel === '') ? safeName : `${parentRel}/${safeName}`;
|
||||
|
||||
const absOld = path.join(rawDir, rel);
|
||||
const absNew = path.join(rawDir, newRel);
|
||||
|
||||
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (!absNew.startsWith(rawDir + path.sep)) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht umbenannt werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
|
||||
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
|
||||
|
||||
fs.renameSync(absOld, absNew);
|
||||
|
||||
const rows = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const updatedRel = newRel + row.rel_path.slice(rel.length);
|
||||
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
|
||||
}
|
||||
|
||||
return { oldRelPath: rel, newRelPath: newRel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner in ein anderes Verzeichnis verschieben. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function moveEntry(relPath, targetParentRelPath) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
|
||||
|
||||
const targetParentRel = normalizeRelPath(targetParentRelPath != null ? targetParentRelPath : '');
|
||||
if (targetParentRel === null) throw makeError('Ungültiger Zielpfad.', 400);
|
||||
|
||||
const name = path.posix.basename(rel);
|
||||
const newRel = targetParentRel === '' ? name : `${targetParentRel}/${name}`;
|
||||
|
||||
if (rel === newRel) throw makeError('Quelle und Ziel sind identisch.', 400);
|
||||
if (newRel.startsWith(`${rel}/`)) throw makeError('Kann nicht in eigenen Unterordner verschoben werden.', 400);
|
||||
|
||||
const absOld = path.join(rawDir, rel);
|
||||
const absNew = path.join(rawDir, newRel);
|
||||
|
||||
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (!absNew.startsWith(rawDir + path.sep) && absNew !== rawDir) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht verschoben werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
|
||||
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
|
||||
|
||||
fs.renameSync(absOld, absNew);
|
||||
|
||||
const rows = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const updatedRel = newRel + row.rel_path.slice(rel.length);
|
||||
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
|
||||
}
|
||||
|
||||
return { oldRelPath: rel, newRelPath: newRel, targetParentRelPath: targetParentRel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Ordner erstellen (kein DB-Eintrag — erscheint beim nächsten Scan).
|
||||
*/
|
||||
async function createFolder(parentRelPath, name) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const parentRel = normalizeRelPath(parentRelPath != null ? parentRelPath : '');
|
||||
if (parentRel === null) throw makeError('Ungültiger übergeordneter Pfad.', 400);
|
||||
|
||||
const safeName = String(name || '').trim();
|
||||
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
|
||||
throw makeError('Ungültiger Ordnername.', 400);
|
||||
}
|
||||
|
||||
const newRel = parentRel === '' ? safeName : `${parentRel}/${safeName}`;
|
||||
const absPath = path.join(rawDir, newRel);
|
||||
|
||||
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (fs.existsSync(absPath)) throw makeError('Ordner existiert bereits.', 409);
|
||||
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
|
||||
return { relPath: newRel };
|
||||
}
|
||||
|
||||
// ── Reines FS-Baum-Listing (keine DB) ─────────────────────────────────────
|
||||
|
||||
const TREE_MAX_DEPTH = 8;
|
||||
|
||||
function buildRawTree(rawDir, relPath, depth, assignments = new Map()) {
|
||||
if (depth >= TREE_MAX_DEPTH) return [];
|
||||
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
|
||||
let dirents;
|
||||
try {
|
||||
dirents = fs.readdirSync(absDir, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
||||
dirents.sort((a, b) => {
|
||||
const ad = a.isDirectory() ? 0 : 1;
|
||||
const bd = b.isDirectory() ? 0 : 1;
|
||||
if (ad !== bd) return ad - bd;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
const nodes = [];
|
||||
for (const dirent of dirents) {
|
||||
// Versteckte Einträge überspringen
|
||||
if (dirent.name.startsWith('.')) continue;
|
||||
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
|
||||
if (dirent.isDirectory()) {
|
||||
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),
|
||||
jobId: assignment?.jobId || null,
|
||||
jobTitle: assignment?.jobTitle || null,
|
||||
jobStatus: assignment?.jobStatus || null
|
||||
});
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
async function getTree() {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir || !fs.existsSync(rawDir)) {
|
||||
return { rawDir: rawDir || null, tree: null };
|
||||
}
|
||||
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,
|
||||
tree: { name: path.basename(rawDir) || 'raw', type: 'folder', path: '', size, children }
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scan,
|
||||
getEntries,
|
||||
getEntryById,
|
||||
getEntryByRelPath,
|
||||
markEntryAsJob,
|
||||
setEntryJobAssignment,
|
||||
clearEntryJobAssignment,
|
||||
clearAssignmentsForJob,
|
||||
assignEntriesToJob,
|
||||
getRawDir,
|
||||
normalizeRelPath,
|
||||
getTree,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
restartPolling,
|
||||
detectMediaType,
|
||||
detectFormat,
|
||||
deleteEntry,
|
||||
renameEntry,
|
||||
moveEntry,
|
||||
createFolder
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('COVERART_RECOVERY');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const thumbnailService = require('./thumbnailService');
|
||||
|
||||
const COVERART_RECOVERY_ENABLED_KEY = 'coverart_recovery_enabled';
|
||||
const COVERART_RECOVERY_INTERVAL_HOURS_KEY = 'coverart_recovery_interval_hours';
|
||||
const DEFAULT_INTERVAL_HOURS = 6;
|
||||
const MIN_INTERVAL_HOURS = 1;
|
||||
const MAX_INTERVAL_HOURS = 168;
|
||||
const RUNNING_JOB_STATUSES = new Set([
|
||||
'ANALYZING',
|
||||
'RIPPING',
|
||||
'MEDIAINFO_CHECK',
|
||||
'ENCODING',
|
||||
'CD_ANALYZING',
|
||||
'CD_RIPPING',
|
||||
'CD_ENCODING'
|
||||
]);
|
||||
|
||||
function parseJsonSafe(raw, fallback = null) {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toBoolean(value, fallback = false) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
||||
}
|
||||
|
||||
function normalizeIntervalHours(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_INTERVAL_HOURS;
|
||||
}
|
||||
return Math.max(MIN_INTERVAL_HOURS, Math.min(MAX_INTERVAL_HOURS, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function normalizeExternalUrl(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function deriveCoverArtArchiveUrl(mbId) {
|
||||
const normalized = String(mbId || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return `https://coverartarchive.org/release/${encodeURIComponent(normalized)}/front-250`;
|
||||
}
|
||||
|
||||
function isLikelyMusicBrainzId(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^tt\d{6,12}$/i.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-z0-9-]{8,}$/i.test(normalized);
|
||||
}
|
||||
|
||||
function collectCoverCandidates(row) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const push = (url, source) => {
|
||||
const normalized = normalizeExternalUrl(url);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const dedupeKey = normalized.toLowerCase();
|
||||
if (seen.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
candidates.push({
|
||||
url: normalized,
|
||||
source: String(source || '').trim() || null
|
||||
});
|
||||
};
|
||||
|
||||
push(row?.poster_url, 'job.poster_url');
|
||||
|
||||
const makemkvInfo = parseJsonSafe(row?.makemkv_info_json, {});
|
||||
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
|
||||
push(selectedMetadata?.coverUrl, 'selectedMetadata.coverUrl');
|
||||
push(selectedMetadata?.poster, 'selectedMetadata.poster');
|
||||
push(selectedMetadata?.posterUrl, 'selectedMetadata.posterUrl');
|
||||
|
||||
const mbId = String(
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.musicbrainz_id
|
||||
|| selectedMetadata?.music_brainz_id
|
||||
|| selectedMetadata?.musicbrainz
|
||||
|| selectedMetadata?.mbid
|
||||
|| row?.imdb_id
|
||||
|| ''
|
||||
).trim();
|
||||
if (isLikelyMusicBrainzId(mbId)) {
|
||||
push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive');
|
||||
}
|
||||
|
||||
const omdbInfo = parseJsonSafe(row?.omdb_json, {});
|
||||
const omdbPoster = String(omdbInfo?.Poster || '').trim();
|
||||
if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') {
|
||||
push(omdbPoster, 'omdb_json.Poster');
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
class CoverArtRecoveryService {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
this.inFlight = null;
|
||||
this.nextRunAt = null;
|
||||
this.schedulerEnabled = false;
|
||||
this.intervalHours = DEFAULT_INTERVAL_HOURS;
|
||||
this.lastRunSummary = null;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: this.schedulerEnabled,
|
||||
intervalHours: this.intervalHours,
|
||||
nextRunAt: this.nextRunAt,
|
||||
running: Boolean(this.inFlight),
|
||||
lastRunSummary: this.lastRunSummary
|
||||
};
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.nextRunAt = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.refreshSchedule({ runStartupCheck: true });
|
||||
}
|
||||
|
||||
async handleSettingsChanged(changedKeys = []) {
|
||||
const normalizedKeys = Array.isArray(changedKeys)
|
||||
? changedKeys.map((key) => String(key || '').trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
if (
|
||||
normalizedKeys.length > 0
|
||||
&& !normalizedKeys.includes(COVERART_RECOVERY_ENABLED_KEY)
|
||||
&& !normalizedKeys.includes(COVERART_RECOVERY_INTERVAL_HOURS_KEY)
|
||||
) {
|
||||
return this.getStatus();
|
||||
}
|
||||
return this.refreshSchedule({ runStartupCheck: false });
|
||||
}
|
||||
|
||||
async refreshSchedule(options = {}) {
|
||||
const runStartupCheck = options?.runStartupCheck !== false;
|
||||
this.stop();
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
this.schedulerEnabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
|
||||
this.intervalHours = normalizeIntervalHours(settings?.[COVERART_RECOVERY_INTERVAL_HOURS_KEY]);
|
||||
|
||||
logger.info('scheduler:refresh', {
|
||||
enabled: this.schedulerEnabled,
|
||||
intervalHours: this.intervalHours,
|
||||
runStartupCheck
|
||||
});
|
||||
|
||||
if (this.schedulerEnabled && runStartupCheck) {
|
||||
this.runNow({ trigger: 'startup' }).catch((error) => {
|
||||
logger.warn('scheduler:startup-run-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.schedulerEnabled) {
|
||||
this.scheduleNextAutoRun();
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
scheduleNextAutoRun() {
|
||||
this.stop();
|
||||
if (!this.schedulerEnabled) {
|
||||
return;
|
||||
}
|
||||
const delayMs = this.intervalHours * 60 * 60 * 1000;
|
||||
this.nextRunAt = new Date(Date.now() + delayMs).toISOString();
|
||||
this.timer = setTimeout(() => {
|
||||
this.runNow({ trigger: 'auto' })
|
||||
.catch((error) => {
|
||||
logger.warn('scheduler:auto-run-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.scheduleNextAutoRun();
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async runNow(options = {}) {
|
||||
if (this.inFlight) {
|
||||
return this.inFlight;
|
||||
}
|
||||
let promise = null;
|
||||
promise = this._runNowInternal(options)
|
||||
.finally(() => {
|
||||
if (this.inFlight === promise) {
|
||||
this.inFlight = null;
|
||||
}
|
||||
});
|
||||
this.inFlight = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
async _runNowInternal(options = {}) {
|
||||
const trigger = String(options?.trigger || 'manual').trim().toLowerCase() || 'manual';
|
||||
const force = Boolean(options?.force);
|
||||
const logFailures = options?.logFailures !== false;
|
||||
const startedMs = Date.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const enabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
|
||||
|
||||
if (!enabled && !force) {
|
||||
const skipped = {
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 0,
|
||||
skipped: true,
|
||||
reason: 'disabled'
|
||||
};
|
||||
this.lastRunSummary = skipped;
|
||||
return skipped;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
detected_title,
|
||||
status,
|
||||
poster_url,
|
||||
imdb_id,
|
||||
makemkv_info_json,
|
||||
omdb_json
|
||||
FROM jobs
|
||||
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
|
||||
`
|
||||
);
|
||||
|
||||
const summary = {
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt: null,
|
||||
durationMs: 0,
|
||||
scannedJobs: Array.isArray(rows) ? rows.length : 0,
|
||||
runningSkipped: 0,
|
||||
alreadyLocal: 0,
|
||||
noCandidate: 0,
|
||||
recovered: 0,
|
||||
failed: 0,
|
||||
failedJobs: []
|
||||
};
|
||||
|
||||
for (const row of rows || []) {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = String(row?.status || '').trim().toUpperCase();
|
||||
if (RUNNING_JOB_STATUSES.has(status)) {
|
||||
summary.runningSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPosterUrl = String(row?.poster_url || '').trim();
|
||||
if (
|
||||
currentPosterUrl
|
||||
&& thumbnailService.isLocalUrl(currentPosterUrl)
|
||||
&& thumbnailService.localThumbnailUrlExists(currentPosterUrl)
|
||||
) {
|
||||
summary.alreadyLocal += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidates = collectCoverCandidates(row);
|
||||
if (candidates.length === 0) {
|
||||
summary.noCandidate += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let recovered = false;
|
||||
let lastError = null;
|
||||
for (const candidate of candidates) {
|
||||
const result = await historyService.cacheAndPromoteExternalPoster(jobId, candidate.url, {
|
||||
source: 'Coverart',
|
||||
logFailures: false
|
||||
});
|
||||
if (result?.ok && result?.localUrl) {
|
||||
recovered = true;
|
||||
summary.recovered += 1;
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Coverart nachgeladen (${trigger}): ${candidate.url}${candidate?.source ? ` [${candidate.source}]` : ''}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
lastError = String(result?.error || result?.reason || 'download_failed');
|
||||
}
|
||||
|
||||
if (!recovered) {
|
||||
summary.failed += 1;
|
||||
const failedInfo = {
|
||||
jobId,
|
||||
title: String(row?.title || row?.detected_title || `Job #${jobId}`),
|
||||
attemptedUrls: candidates.map((item) => item.url),
|
||||
error: lastError
|
||||
};
|
||||
summary.failedJobs.push(failedInfo);
|
||||
if (logFailures && trigger !== 'auto') {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Coverart-Download fehlgeschlagen (${trigger}). Versuchte Links: ${failedInfo.attemptedUrls.join(', ')}${lastError ? ` | Fehler: ${lastError}` : ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const durationMs = Math.max(0, Date.now() - startedMs);
|
||||
const result = {
|
||||
...summary,
|
||||
finishedAt,
|
||||
durationMs
|
||||
};
|
||||
this.lastRunSummary = result;
|
||||
logger.info('recovery:done', {
|
||||
trigger: result.trigger,
|
||||
scannedJobs: result.scannedJobs,
|
||||
recovered: result.recovered,
|
||||
failed: result.failed,
|
||||
alreadyLocal: result.alreadyLocal,
|
||||
noCandidate: result.noCandidate,
|
||||
runningSkipped: result.runningSkipped,
|
||||
durationMs: result.durationMs
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CoverArtRecoveryService();
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* cronService.js
|
||||
* Verwaltet und führt Cronjobs aus (Skripte oder Skriptketten).
|
||||
* Kein externer Package nötig – eigener Cron-Expression-Parser.
|
||||
*/
|
||||
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('CRON');
|
||||
const notificationService = require('./notificationService');
|
||||
const settingsService = require('./settingsService');
|
||||
const wsService = require('./websocketService');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
// Maximale Zeilen pro Log-Eintrag (Output-Truncation)
|
||||
const MAX_OUTPUT_CHARS = 100000;
|
||||
// Maximale Log-Einträge pro Cron-Job (ältere werden gelöscht)
|
||||
const MAX_LOGS_PER_JOB = 50;
|
||||
|
||||
// ─── Cron-Expression-Parser ────────────────────────────────────────────────
|
||||
|
||||
// Parst ein einzelnes Cron-Feld (z.B. "* /5", "1,3,5", "1-5", "*") und gibt
|
||||
// alle erlaubten Werte als Set zurück.
|
||||
function parseCronField(field, min, max) {
|
||||
const values = new Set();
|
||||
|
||||
for (const part of field.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '*') {
|
||||
for (let i = min; i <= max; i++) values.add(i);
|
||||
} else if (trimmed.startsWith('*/')) {
|
||||
const step = parseInt(trimmed.slice(2), 10);
|
||||
if (!Number.isFinite(step) || step < 1) throw new Error(`Ungültiges Step: ${trimmed}`);
|
||||
for (let i = min; i <= max; i += step) values.add(i);
|
||||
} else if (trimmed.includes('-')) {
|
||||
const [startStr, endStr] = trimmed.split('-');
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = parseInt(endStr, 10);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Ungültiger Bereich: ${trimmed}`);
|
||||
for (let i = Math.max(min, start); i <= Math.min(max, end); i++) values.add(i);
|
||||
} else {
|
||||
const num = parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(num) || num < min || num > max) throw new Error(`Ungültiger Wert: ${trimmed}`);
|
||||
values.add(num);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert eine Cron-Expression (5 Felder: minute hour day month weekday).
|
||||
* Gibt { valid: true } oder { valid: false, error: string } zurück.
|
||||
*/
|
||||
function validateCronExpression(expr) {
|
||||
try {
|
||||
const parts = String(expr || '').trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return { valid: false, error: 'Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag).' };
|
||||
}
|
||||
parseCronField(parts[0], 0, 59); // minute
|
||||
parseCronField(parts[1], 0, 23); // hour
|
||||
parseCronField(parts[2], 1, 31); // day of month
|
||||
parseCronField(parts[3], 1, 12); // month
|
||||
parseCronField(parts[4], 0, 7); // weekday (0 und 7 = Sonntag)
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den nächsten Ausführungszeitpunkt nach einem Datum.
|
||||
* Gibt ein Date-Objekt zurück oder null bei Fehler.
|
||||
*/
|
||||
function getNextRunTime(expr, fromDate = new Date()) {
|
||||
try {
|
||||
const parts = String(expr || '').trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const minutes = parseCronField(parts[0], 0, 59);
|
||||
const hours = parseCronField(parts[1], 0, 23);
|
||||
const days = parseCronField(parts[2], 1, 31);
|
||||
const months = parseCronField(parts[3], 1, 12);
|
||||
const weekdays = parseCronField(parts[4], 0, 7);
|
||||
|
||||
// Normalisiere Wochentag: 7 → 0 (beide = Sonntag)
|
||||
if (weekdays.has(7)) weekdays.add(0);
|
||||
|
||||
// Suche ab der nächsten Minute
|
||||
const candidate = new Date(fromDate);
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
|
||||
// Maximal 2 Jahre in die Zukunft suchen
|
||||
const limit = new Date(fromDate);
|
||||
limit.setFullYear(limit.getFullYear() + 2);
|
||||
|
||||
while (candidate < limit) {
|
||||
const month = candidate.getMonth() + 1; // 1-12
|
||||
const day = candidate.getDate();
|
||||
const hour = candidate.getHours();
|
||||
const minute = candidate.getMinutes();
|
||||
const weekday = candidate.getDay(); // 0 = Sonntag
|
||||
|
||||
if (!months.has(month)) {
|
||||
candidate.setMonth(candidate.getMonth() + 1, 1);
|
||||
candidate.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
if (!days.has(day) || !weekdays.has(weekday)) {
|
||||
candidate.setDate(candidate.getDate() + 1);
|
||||
candidate.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
if (!hours.has(hour)) {
|
||||
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
if (!minutes.has(minute)) {
|
||||
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DB-Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function mapJobRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
cronExpression: String(row.cron_expression || ''),
|
||||
sourceType: String(row.source_type || ''),
|
||||
sourceId: Number(row.source_id),
|
||||
sourceName: row.source_name != null ? String(row.source_name) : null,
|
||||
enabled: Boolean(row.enabled),
|
||||
pushoverEnabled: Boolean(row.pushover_enabled),
|
||||
lastRunAt: row.last_run_at || null,
|
||||
lastRunStatus: row.last_run_status || null,
|
||||
nextRunAt: row.next_run_at || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function mapLogRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
cronJobId: Number(row.cron_job_id),
|
||||
startedAt: row.started_at,
|
||||
finishedAt: row.finished_at || null,
|
||||
status: String(row.status || ''),
|
||||
output: row.output || null,
|
||||
errorMessage: row.error_message || null
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJobWithSource(db, id) {
|
||||
return db.get(
|
||||
`
|
||||
SELECT
|
||||
c.*,
|
||||
CASE c.source_type
|
||||
WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id)
|
||||
WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id)
|
||||
ELSE NULL
|
||||
END AS source_name
|
||||
FROM cron_jobs c
|
||||
WHERE c.id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAllJobsWithSource(db) {
|
||||
return db.all(
|
||||
`
|
||||
SELECT
|
||||
c.*,
|
||||
CASE c.source_type
|
||||
WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id)
|
||||
WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id)
|
||||
ELSE NULL
|
||||
END AS source_name
|
||||
FROM cron_jobs c
|
||||
ORDER BY c.id ASC
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ausführungslogik ──────────────────────────────────────────────────────
|
||||
|
||||
async function runCronJob(job) {
|
||||
const db = await getDb();
|
||||
const startedAt = new Date().toISOString();
|
||||
const cronActivityId = runtimeActivityService.startActivity('cron', {
|
||||
name: job?.name || `Cron #${job?.id || '?'}`,
|
||||
source: 'cron',
|
||||
cronJobId: job?.id || null,
|
||||
currentStep: 'Starte Cronjob'
|
||||
});
|
||||
|
||||
logger.info('cron:run:start', { cronJobId: job.id, name: job.name, sourceType: job.sourceType, sourceId: job.sourceId });
|
||||
|
||||
// Log-Eintrag anlegen (status = 'running')
|
||||
const insertResult = await db.run(
|
||||
`INSERT INTO cron_run_logs (cron_job_id, started_at, status) VALUES (?, ?, 'running')`,
|
||||
[job.id, startedAt]
|
||||
);
|
||||
const logId = insertResult.lastID;
|
||||
|
||||
// Job als laufend markieren
|
||||
await db.run(
|
||||
`UPDATE cron_jobs SET last_run_at = ?, last_run_status = 'running', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[startedAt, job.id]
|
||||
);
|
||||
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: 'running', lastRunAt: startedAt });
|
||||
|
||||
let output = '';
|
||||
let errorMessage = null;
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
if (job.sourceType === 'script') {
|
||||
const scriptService = require('./scriptService');
|
||||
const script = await scriptService.getScriptById(job.sourceId);
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: 'script',
|
||||
currentStep: `Skript: ${script.name}`,
|
||||
currentScriptName: script.name,
|
||||
scriptId: script.id
|
||||
});
|
||||
const scriptActivityId = runtimeActivityService.startActivity('script', {
|
||||
name: script.name,
|
||||
source: 'cron',
|
||||
scriptId: script.id,
|
||||
cronJobId: job.id,
|
||||
parentActivityId: cronActivityId,
|
||||
currentStep: `Cronjob: ${job.name}`
|
||||
});
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
const processHandle = spawnTrackedProcess({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
context: { source: 'cron', cronJobId: job.id, scriptId: script.id },
|
||||
onStdoutLine: (line) => {
|
||||
const next = stdout.length <= MAX_OUTPUT_CHARS
|
||||
? `${stdout}${line}\n`
|
||||
: stdout;
|
||||
stdout = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
|
||||
stdoutTruncated = stdoutTruncated || next.length > MAX_OUTPUT_CHARS;
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
const next = stderr.length <= MAX_OUTPUT_CHARS
|
||||
? `${stderr}${line}\n`
|
||||
: stderr;
|
||||
stderr = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
|
||||
stderrTruncated = stderrTruncated || next.length > MAX_OUTPUT_CHARS;
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const result = await processHandle.promise;
|
||||
exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
|
||||
} catch (error) {
|
||||
exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
|
||||
if (exitCode === null) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
output = [stdout, stderr].filter(Boolean).join('\n');
|
||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||
success = exitCode === 0;
|
||||
if (!success) errorMessage = `Exit-Code ${exitCode}`;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode,
|
||||
message: success ? null : errorMessage,
|
||||
output: output || null,
|
||||
stdout: stdout || null,
|
||||
stderr: stderr || null,
|
||||
stdoutTruncated,
|
||||
stderrTruncated,
|
||||
errorMessage: success ? null : (errorMessage || null)
|
||||
});
|
||||
} catch (error) {
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: error?.message || 'Skriptfehler',
|
||||
errorMessage: error?.message || 'Skriptfehler'
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (prepared?.cleanup) {
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
} else if (job.sourceType === 'chain') {
|
||||
const scriptChainService = require('./scriptChainService');
|
||||
const logLines = [];
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: 'chain',
|
||||
currentStep: `Kette: ${job.sourceName || `#${job.sourceId}`}`,
|
||||
currentScriptName: null,
|
||||
chainId: job.sourceId
|
||||
});
|
||||
const result = await scriptChainService.executeChain(
|
||||
job.sourceId,
|
||||
{
|
||||
source: 'cron',
|
||||
cronJobId: job.id,
|
||||
runtimeParentActivityId: cronActivityId,
|
||||
onRuntimeStep: (payload = {}) => {
|
||||
const currentScriptName = payload?.stepType === 'script'
|
||||
? (payload?.scriptName || payload?.currentScriptName || null)
|
||||
: null;
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: payload?.stepType || 'chain',
|
||||
currentStep: payload?.currentStep || null,
|
||||
currentScriptName,
|
||||
scriptId: payload?.scriptId || null
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
appendLog: async (_source, line) => {
|
||||
logLines.push(line);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
output = logLines.join('\n');
|
||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||
success = result && typeof result === 'object'
|
||||
? !(Boolean(result.aborted) || Number(result.failed || 0) > 0)
|
||||
: Boolean(result);
|
||||
if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.';
|
||||
} else {
|
||||
throw new Error(`Unbekannter source_type: ${job.sourceType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
success = false;
|
||||
errorMessage = error.message || String(error);
|
||||
logger.error('cron:run:error', { cronJobId: job.id, error: errorToMeta(error) });
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const status = success ? 'success' : 'error';
|
||||
const nextRunAt = getNextRunTime(job.cronExpression)?.toISOString() || null;
|
||||
|
||||
// Log-Eintrag abschließen
|
||||
await db.run(
|
||||
`UPDATE cron_run_logs SET finished_at = ?, status = ?, output = ?, error_message = ? WHERE id = ?`,
|
||||
[finishedAt, status, output || null, errorMessage, logId]
|
||||
);
|
||||
|
||||
// Job-Status aktualisieren
|
||||
await db.run(
|
||||
`UPDATE cron_jobs SET last_run_status = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[status, nextRunAt, job.id]
|
||||
);
|
||||
|
||||
// Alte Logs trimmen
|
||||
await db.run(
|
||||
`
|
||||
DELETE FROM cron_run_logs
|
||||
WHERE cron_job_id = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?
|
||||
)
|
||||
`,
|
||||
[job.id, job.id, MAX_LOGS_PER_JOB]
|
||||
);
|
||||
|
||||
logger.info('cron:run:done', { cronJobId: job.id, status, durationMs: new Date(finishedAt) - new Date(startedAt) });
|
||||
runtimeActivityService.completeActivity(cronActivityId, {
|
||||
status,
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
finishedAt,
|
||||
currentStep: null,
|
||||
currentScriptName: null,
|
||||
message: success ? 'Cronjob abgeschlossen' : (errorMessage || 'Cronjob fehlgeschlagen'),
|
||||
output: output || null,
|
||||
errorMessage: success ? null : (errorMessage || null)
|
||||
});
|
||||
|
||||
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: status, lastRunAt: finishedAt, nextRunAt });
|
||||
|
||||
// Pushover-Benachrichtigung (nur wenn am Cron aktiviert UND global aktiviert)
|
||||
if (job.pushoverEnabled) {
|
||||
try {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const eventKey = success ? 'cron_success' : 'cron_error';
|
||||
const title = `Ripster Cron: ${job.name}`;
|
||||
const message = success
|
||||
? `Cronjob "${job.name}" erfolgreich ausgeführt.`
|
||||
: `Cronjob "${job.name}" fehlgeschlagen: ${errorMessage || 'Unbekannter Fehler'}`;
|
||||
|
||||
await notificationService.notifyWithSettings(settings, eventKey, { title, message });
|
||||
} catch (notifyError) {
|
||||
logger.warn('cron:run:notify-failed', { cronJobId: job.id, error: errorToMeta(notifyError) });
|
||||
}
|
||||
}
|
||||
|
||||
return { success, status, output, errorMessage, finishedAt, nextRunAt };
|
||||
}
|
||||
|
||||
// ─── Scheduler ─────────────────────────────────────────────────────────────
|
||||
|
||||
class CronService {
|
||||
constructor() {
|
||||
this._timer = null;
|
||||
this._running = new Set(); // IDs aktuell laufender Jobs
|
||||
}
|
||||
|
||||
async init() {
|
||||
logger.info('cron:scheduler:init');
|
||||
// Beim Start next_run_at für alle enabled Jobs neu berechnen
|
||||
await this._recalcNextRuns();
|
||||
this._scheduleNextTick();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
logger.info('cron:scheduler:stopped');
|
||||
}
|
||||
|
||||
_scheduleNextTick() {
|
||||
// Auf den Beginn der nächsten vollen Minute warten
|
||||
const now = new Date();
|
||||
const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 500;
|
||||
this._timer = setTimeout(() => this._tick(), msUntilNextMinute);
|
||||
}
|
||||
|
||||
async _tick() {
|
||||
try {
|
||||
await this._checkAndRunDueJobs();
|
||||
} catch (error) {
|
||||
logger.error('cron:scheduler:tick-error', { error: errorToMeta(error) });
|
||||
}
|
||||
this._scheduleNextTick();
|
||||
}
|
||||
|
||||
async _recalcNextRuns() {
|
||||
const db = await getDb();
|
||||
const jobs = await db.all(`SELECT id, cron_expression FROM cron_jobs WHERE enabled = 1`);
|
||||
for (const job of jobs) {
|
||||
const nextRunAt = getNextRunTime(job.cron_expression)?.toISOString() || null;
|
||||
await db.run(`UPDATE cron_jobs SET next_run_at = ? WHERE id = ?`, [nextRunAt, job.id]);
|
||||
}
|
||||
}
|
||||
|
||||
async _checkAndRunDueJobs() {
|
||||
const db = await getDb();
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
// Jobs, deren next_run_at <= jetzt ist und die nicht gerade laufen
|
||||
const dueJobs = await db.all(
|
||||
`SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?`,
|
||||
[nowIso]
|
||||
);
|
||||
|
||||
for (const jobRow of dueJobs) {
|
||||
const id = Number(jobRow.id);
|
||||
if (this._running.has(id)) {
|
||||
logger.warn('cron:scheduler:skip-still-running', { cronJobId: id });
|
||||
continue;
|
||||
}
|
||||
|
||||
const job = mapJobRow(jobRow);
|
||||
this._running.add(id);
|
||||
|
||||
// Asynchron ausführen, damit der Scheduler nicht blockiert
|
||||
runCronJob(job)
|
||||
.catch((error) => {
|
||||
logger.error('cron:run:unhandled-error', { cronJobId: id, error: errorToMeta(error) });
|
||||
})
|
||||
.finally(() => {
|
||||
this._running.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
async listJobs() {
|
||||
const db = await getDb();
|
||||
const rows = await fetchAllJobsWithSource(db);
|
||||
return rows.map(mapJobRow);
|
||||
}
|
||||
|
||||
async getJobById(id) {
|
||||
const db = await getDb();
|
||||
const row = await fetchJobWithSource(db, id);
|
||||
if (!row) {
|
||||
const error = new Error(`Cronjob #${id} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return mapJobRow(row);
|
||||
}
|
||||
|
||||
async createJob(payload) {
|
||||
const { name, cronExpression, sourceType, sourceId, enabled = true, pushoverEnabled = true } = payload || {};
|
||||
|
||||
const trimmedName = String(name || '').trim();
|
||||
const trimmedExpr = String(cronExpression || '').trim();
|
||||
|
||||
if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 });
|
||||
if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 });
|
||||
|
||||
const validation = validateCronExpression(trimmedExpr);
|
||||
if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 });
|
||||
|
||||
if (!['script', 'chain'].includes(sourceType)) {
|
||||
throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const normalizedSourceId = Number(sourceId);
|
||||
if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) {
|
||||
throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const nextRunAt = getNextRunTime(trimmedExpr)?.toISOString() || null;
|
||||
const db = await getDb();
|
||||
|
||||
const result = await db.run(
|
||||
`
|
||||
INSERT INTO cron_jobs (name, cron_expression, source_type, source_id, enabled, pushover_enabled, next_run_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[trimmedName, trimmedExpr, sourceType, normalizedSourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt]
|
||||
);
|
||||
|
||||
logger.info('cron:create', { cronJobId: result.lastID, name: trimmedName, cronExpression: trimmedExpr });
|
||||
return this.getJobById(result.lastID);
|
||||
}
|
||||
|
||||
async updateJob(id, payload) {
|
||||
const db = await getDb();
|
||||
const existing = await this.getJobById(id);
|
||||
|
||||
const trimmedName = Object.prototype.hasOwnProperty.call(payload, 'name')
|
||||
? String(payload.name || '').trim()
|
||||
: existing.name;
|
||||
const trimmedExpr = Object.prototype.hasOwnProperty.call(payload, 'cronExpression')
|
||||
? String(payload.cronExpression || '').trim()
|
||||
: existing.cronExpression;
|
||||
|
||||
if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 });
|
||||
if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 });
|
||||
|
||||
const validation = validateCronExpression(trimmedExpr);
|
||||
if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 });
|
||||
|
||||
const sourceType = Object.prototype.hasOwnProperty.call(payload, 'sourceType') ? payload.sourceType : existing.sourceType;
|
||||
const sourceId = Object.prototype.hasOwnProperty.call(payload, 'sourceId') ? Number(payload.sourceId) : existing.sourceId;
|
||||
const enabled = Object.prototype.hasOwnProperty.call(payload, 'enabled') ? Boolean(payload.enabled) : existing.enabled;
|
||||
const pushoverEnabled = Object.prototype.hasOwnProperty.call(payload, 'pushoverEnabled') ? Boolean(payload.pushoverEnabled) : existing.pushoverEnabled;
|
||||
|
||||
if (!['script', 'chain'].includes(sourceType)) {
|
||||
throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 });
|
||||
}
|
||||
if (!Number.isFinite(sourceId) || sourceId <= 0) {
|
||||
throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const nextRunAt = enabled ? (getNextRunTime(trimmedExpr)?.toISOString() || null) : null;
|
||||
|
||||
await db.run(
|
||||
`
|
||||
UPDATE cron_jobs
|
||||
SET name = ?, cron_expression = ?, source_type = ?, source_id = ?,
|
||||
enabled = ?, pushover_enabled = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[trimmedName, trimmedExpr, sourceType, sourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt, id]
|
||||
);
|
||||
|
||||
logger.info('cron:update', { cronJobId: id });
|
||||
return this.getJobById(id);
|
||||
}
|
||||
|
||||
async deleteJob(id) {
|
||||
const db = await getDb();
|
||||
const job = await this.getJobById(id);
|
||||
await db.run(`DELETE FROM cron_jobs WHERE id = ?`, [id]);
|
||||
logger.info('cron:delete', { cronJobId: id });
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJobLogs(id, limit = 20) {
|
||||
await this.getJobById(id); // Existenz prüfen
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`SELECT * FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?`,
|
||||
[id, Math.min(Number(limit) || 20, 100)]
|
||||
);
|
||||
return rows.map(mapLogRow);
|
||||
}
|
||||
|
||||
async triggerJobManually(id) {
|
||||
const job = await this.getJobById(id);
|
||||
if (this._running.has(id)) {
|
||||
throw Object.assign(new Error('Cronjob läuft bereits.'), { statusCode: 409 });
|
||||
}
|
||||
this._running.add(id);
|
||||
logger.info('cron:manual-trigger', { cronJobId: id });
|
||||
|
||||
// Asynchron starten
|
||||
runCronJob(job)
|
||||
.catch((error) => {
|
||||
logger.error('cron:manual-trigger:error', { cronJobId: id, error: errorToMeta(error) });
|
||||
})
|
||||
.finally(() => {
|
||||
this._running.delete(id);
|
||||
});
|
||||
|
||||
return { triggered: true, cronJobId: id };
|
||||
}
|
||||
|
||||
validateExpression(expr) {
|
||||
return validateCronExpression(expr);
|
||||
}
|
||||
|
||||
getNextRunTime(expr) {
|
||||
const next = getNextRunTime(expr);
|
||||
return next ? next.toISOString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CronService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,572 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
const archiver = require('archiver');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const wsService = require('./websocketService');
|
||||
const logger = require('./logger').child('DOWNLOADS');
|
||||
|
||||
function safeJsonParse(raw, fallback = null) {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDownloadId(value) {
|
||||
const raw = String(value || '').trim();
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (['queued', 'processing', 'ready', 'failed'].includes(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function normalizeTarget(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (raw === 'raw') {
|
||||
return 'raw';
|
||||
}
|
||||
if (raw === 'output') {
|
||||
return 'output';
|
||||
}
|
||||
return 'output';
|
||||
}
|
||||
|
||||
function normalizeDateString(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(raw);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
function normalizeNumber(value, fallback = null) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function compareCreatedDesc(a, b) {
|
||||
const left = String(a?.createdAt || '');
|
||||
const right = String(b?.createdAt || '');
|
||||
return right.localeCompare(left) || String(b?.id || '').localeCompare(String(a?.id || ''));
|
||||
}
|
||||
|
||||
function applyOwnerToPath(targetPath, ownerSpec) {
|
||||
const spec = String(ownerSpec || '').trim();
|
||||
if (!targetPath || !spec) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = spawnSync('chown', [spec, targetPath], { timeout: 15000 });
|
||||
if (result.status !== 0) {
|
||||
logger.warn('download:chown:failed', {
|
||||
targetPath,
|
||||
spec,
|
||||
stderr: String(result.stderr || '').trim() || null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('download:chown:error', {
|
||||
targetPath,
|
||||
spec,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadService {
|
||||
constructor() {
|
||||
this.items = new Map();
|
||||
this.activeTasks = new Map();
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this._init();
|
||||
}
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
async _init() {
|
||||
const settings = await settingsService.getEffectiveSettingsMap(null);
|
||||
const downloadDir = String(settings?.download_dir || '').trim();
|
||||
const owner = String(settings?.download_dir_owner || '').trim() || null;
|
||||
await fs.promises.mkdir(downloadDir, { recursive: true });
|
||||
applyOwnerToPath(downloadDir, owner);
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(downloadDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
logger.warn('download:init:readdir-failed', {
|
||||
downloadDir,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
entries = [];
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const pendingResumeIds = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
||||
continue;
|
||||
}
|
||||
const metaPath = path.join(downloadDir, entry.name);
|
||||
const parsed = safeJsonParse(await fs.promises.readFile(metaPath, 'utf-8').catch(() => null), null);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const item = this._normalizeLoadedItem(parsed, downloadDir);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (item.status === 'queued' || item.status === 'processing') {
|
||||
const archiveExists = await this._pathExists(item.archivePath);
|
||||
if (archiveExists) {
|
||||
const archiveStat = await fs.promises.stat(item.archivePath).catch(() => null);
|
||||
item.status = 'ready';
|
||||
item.errorMessage = null;
|
||||
item.finishedAt = item.finishedAt || nowIso;
|
||||
item.sizeBytes = Number.isFinite(Number(archiveStat?.size))
|
||||
? archiveStat.size
|
||||
: item.sizeBytes;
|
||||
changed = true;
|
||||
logger.warn('download:init:recovered-ready-archive', {
|
||||
id: item.id,
|
||||
archiveName: item.archiveName
|
||||
});
|
||||
} else {
|
||||
item.status = 'queued';
|
||||
item.startedAt = null;
|
||||
item.finishedAt = null;
|
||||
item.errorMessage = null;
|
||||
item.sizeBytes = null;
|
||||
changed = true;
|
||||
pendingResumeIds.push(item.id);
|
||||
await this._safeUnlink(item.partialPath);
|
||||
logger.warn('download:init:requeue-interrupted-job', {
|
||||
id: item.id,
|
||||
archiveName: item.archiveName
|
||||
});
|
||||
}
|
||||
} else if (item.status === 'ready') {
|
||||
const exists = await this._pathExists(item.archivePath);
|
||||
if (!exists) {
|
||||
item.status = 'failed';
|
||||
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
|
||||
item.finishedAt = nowIso;
|
||||
item.sizeBytes = null;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.items.set(item.id, item);
|
||||
if (changed) {
|
||||
await this._persistItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingResumeIds.length > 0) {
|
||||
logger.warn('download:init:resume-pending-jobs', {
|
||||
count: pendingResumeIds.length
|
||||
});
|
||||
setImmediate(() => {
|
||||
for (const id of pendingResumeIds) {
|
||||
void this._startArchiveJob(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeLoadedItem(rawItem, fallbackDir) {
|
||||
const id = normalizeDownloadId(rawItem?.id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const downloadDir = String(rawItem?.downloadDir || fallbackDir || '').trim();
|
||||
if (!downloadDir) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
kind: String(rawItem?.kind || 'history').trim() || 'history',
|
||||
jobId: normalizeNumber(rawItem?.jobId, null),
|
||||
target: normalizeTarget(rawItem?.target),
|
||||
label: String(rawItem?.label || (rawItem?.target === 'raw' ? 'RAW' : 'Encode')).trim() || 'Download',
|
||||
displayTitle: String(rawItem?.displayTitle || '').trim() || null,
|
||||
sourcePath: String(rawItem?.sourcePath || '').trim() || null,
|
||||
sourceType: String(rawItem?.sourceType || '').trim() === 'file' ? 'file' : 'directory',
|
||||
sourceMtimeMs: normalizeNumber(rawItem?.sourceMtimeMs, null),
|
||||
sourceModifiedAt: normalizeDateString(rawItem?.sourceModifiedAt),
|
||||
entryName: String(rawItem?.entryName || '').trim() || null,
|
||||
archiveName: String(rawItem?.archiveName || `${id}.zip`).trim() || `${id}.zip`,
|
||||
downloadDir,
|
||||
archivePath: String(rawItem?.archivePath || path.join(downloadDir, `${id}.zip`)).trim(),
|
||||
partialPath: String(rawItem?.partialPath || path.join(downloadDir, `${id}.partial.zip`)).trim(),
|
||||
metaPath: String(rawItem?.metaPath || path.join(downloadDir, `${id}.json`)).trim(),
|
||||
ownerSpec: String(rawItem?.ownerSpec || '').trim() || null,
|
||||
status: normalizeStatus(rawItem?.status),
|
||||
createdAt: normalizeDateString(rawItem?.createdAt) || new Date().toISOString(),
|
||||
startedAt: normalizeDateString(rawItem?.startedAt),
|
||||
finishedAt: normalizeDateString(rawItem?.finishedAt),
|
||||
errorMessage: String(rawItem?.errorMessage || '').trim() || null,
|
||||
sizeBytes: normalizeNumber(rawItem?.sizeBytes, null)
|
||||
};
|
||||
}
|
||||
|
||||
_serializeItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
jobId: item.jobId,
|
||||
target: item.target,
|
||||
label: item.label,
|
||||
displayTitle: item.displayTitle,
|
||||
sourcePath: item.sourcePath,
|
||||
sourceType: item.sourceType,
|
||||
archiveName: item.archiveName,
|
||||
downloadDir: item.downloadDir,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt,
|
||||
startedAt: item.startedAt,
|
||||
finishedAt: item.finishedAt,
|
||||
errorMessage: item.errorMessage,
|
||||
sizeBytes: item.sizeBytes,
|
||||
downloadUrl: item.status === 'ready' ? `/api/downloads/${encodeURIComponent(item.id)}/file` : null
|
||||
};
|
||||
}
|
||||
|
||||
getSummary() {
|
||||
const items = Array.from(this.items.values());
|
||||
const queuedCount = items.filter((item) => item.status === 'queued').length;
|
||||
const processingCount = items.filter((item) => item.status === 'processing').length;
|
||||
const readyCount = items.filter((item) => item.status === 'ready').length;
|
||||
const failedCount = items.filter((item) => item.status === 'failed').length;
|
||||
|
||||
return {
|
||||
totalCount: items.length,
|
||||
queuedCount,
|
||||
processingCount,
|
||||
activeCount: queuedCount + processingCount,
|
||||
readyCount,
|
||||
failedCount
|
||||
};
|
||||
}
|
||||
|
||||
_broadcastUpdate(reason, item = null) {
|
||||
wsService.broadcast('DOWNLOADS_UPDATED', {
|
||||
reason: String(reason || 'updated').trim() || 'updated',
|
||||
summary: this.getSummary(),
|
||||
item: item ? this._serializeItem(item) : null
|
||||
});
|
||||
}
|
||||
|
||||
async listItems() {
|
||||
await this.init();
|
||||
return Array.from(this.items.values())
|
||||
.sort(compareCreatedDesc)
|
||||
.map((item) => this._serializeItem(item));
|
||||
}
|
||||
|
||||
async getItem(id) {
|
||||
await this.init();
|
||||
const normalizedId = normalizeDownloadId(id);
|
||||
if (!normalizedId || !this.items.has(normalizedId)) {
|
||||
const error = new Error('Download nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return this.items.get(normalizedId);
|
||||
}
|
||||
|
||||
async enqueueHistoryJob(jobId, target, options = {}) {
|
||||
await this.init();
|
||||
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target, options);
|
||||
const settings = await settingsService.getEffectiveSettingsMap(null);
|
||||
const downloadDir = String(settings?.download_dir || '').trim();
|
||||
const ownerSpec = String(settings?.download_dir_owner || '').trim() || null;
|
||||
await fs.promises.mkdir(downloadDir, { recursive: true });
|
||||
applyOwnerToPath(downloadDir, ownerSpec);
|
||||
|
||||
const reusable = await this._findReusableHistoryItem(descriptor, downloadDir);
|
||||
if (reusable) {
|
||||
return {
|
||||
item: this._serializeItem(reusable),
|
||||
reused: true,
|
||||
created: false
|
||||
};
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const nowIso = new Date().toISOString();
|
||||
const item = {
|
||||
id,
|
||||
kind: 'history',
|
||||
jobId: descriptor.jobId,
|
||||
target: descriptor.target,
|
||||
label: descriptor.target === 'raw' ? 'RAW' : 'Encode',
|
||||
displayTitle: descriptor.displayTitle,
|
||||
sourcePath: descriptor.sourcePath,
|
||||
sourceType: descriptor.sourceType,
|
||||
sourceMtimeMs: descriptor.sourceMtimeMs,
|
||||
sourceModifiedAt: descriptor.sourceModifiedAt,
|
||||
entryName: descriptor.entryName,
|
||||
archiveName: descriptor.archiveName,
|
||||
downloadDir,
|
||||
archivePath: path.join(downloadDir, `${id}.zip`),
|
||||
partialPath: path.join(downloadDir, `${id}.partial.zip`),
|
||||
metaPath: path.join(downloadDir, `${id}.json`),
|
||||
ownerSpec,
|
||||
status: 'queued',
|
||||
createdAt: nowIso,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
errorMessage: null,
|
||||
sizeBytes: null
|
||||
};
|
||||
|
||||
this.items.set(id, item);
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('queued', item);
|
||||
|
||||
setImmediate(() => {
|
||||
void this._startArchiveJob(id);
|
||||
});
|
||||
|
||||
return {
|
||||
item: this._serializeItem(item),
|
||||
reused: false,
|
||||
created: true
|
||||
};
|
||||
}
|
||||
|
||||
async _findReusableHistoryItem(descriptor, downloadDir) {
|
||||
for (const item of this.items.values()) {
|
||||
if (item.kind !== 'history') {
|
||||
continue;
|
||||
}
|
||||
if (item.jobId !== descriptor.jobId || item.target !== descriptor.target) {
|
||||
continue;
|
||||
}
|
||||
if (item.sourcePath !== descriptor.sourcePath || item.sourceMtimeMs !== descriptor.sourceMtimeMs) {
|
||||
continue;
|
||||
}
|
||||
if (item.downloadDir !== downloadDir) {
|
||||
continue;
|
||||
}
|
||||
if (!['queued', 'processing', 'ready'].includes(item.status)) {
|
||||
continue;
|
||||
}
|
||||
if (item.status === 'ready' && !(await this._pathExists(item.archivePath))) {
|
||||
item.status = 'failed';
|
||||
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
|
||||
item.finishedAt = new Date().toISOString();
|
||||
item.sizeBytes = null;
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('failed', item);
|
||||
continue;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async _startArchiveJob(id) {
|
||||
const item = this.items.get(id);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (this.activeTasks.has(id)) {
|
||||
return this.activeTasks.get(id);
|
||||
}
|
||||
|
||||
const promise = this._runArchiveJob(item)
|
||||
.catch((error) => {
|
||||
logger.warn('download:job:failed', {
|
||||
id,
|
||||
archiveName: item.archiveName,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.activeTasks.delete(id);
|
||||
});
|
||||
|
||||
this.activeTasks.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async _runArchiveJob(item) {
|
||||
item.status = 'processing';
|
||||
item.startedAt = new Date().toISOString();
|
||||
item.finishedAt = null;
|
||||
item.errorMessage = null;
|
||||
item.sizeBytes = null;
|
||||
await this._safeUnlink(item.partialPath);
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('processing', item);
|
||||
|
||||
await fs.promises.mkdir(item.downloadDir, { recursive: true });
|
||||
applyOwnerToPath(item.downloadDir, item.ownerSpec);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const output = fs.createWriteStream(item.partialPath);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
const finishError = (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
output.destroy();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
output.on('close', () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve();
|
||||
});
|
||||
output.on('error', finishError);
|
||||
archive.on('warning', finishError);
|
||||
archive.on('error', finishError);
|
||||
|
||||
archive.pipe(output);
|
||||
if (item.sourceType === 'directory') {
|
||||
archive.directory(item.sourcePath, item.entryName);
|
||||
} else {
|
||||
archive.file(item.sourcePath, { name: item.entryName });
|
||||
}
|
||||
|
||||
try {
|
||||
const finalizeResult = archive.finalize();
|
||||
if (finalizeResult && typeof finalizeResult.catch === 'function') {
|
||||
finalizeResult.catch(finishError);
|
||||
}
|
||||
} catch (error) {
|
||||
finishError(error);
|
||||
}
|
||||
}).catch(async (error) => {
|
||||
await this._safeUnlink(item.partialPath);
|
||||
item.status = 'failed';
|
||||
item.finishedAt = new Date().toISOString();
|
||||
item.errorMessage = error?.message || 'ZIP-Erstellung fehlgeschlagen.';
|
||||
item.sizeBytes = null;
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('failed', item);
|
||||
throw error;
|
||||
});
|
||||
|
||||
await fs.promises.rename(item.partialPath, item.archivePath);
|
||||
applyOwnerToPath(item.archivePath, item.ownerSpec);
|
||||
|
||||
const stat = await fs.promises.stat(item.archivePath);
|
||||
item.status = 'ready';
|
||||
item.finishedAt = new Date().toISOString();
|
||||
item.errorMessage = null;
|
||||
item.sizeBytes = stat.size;
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('ready', item);
|
||||
}
|
||||
|
||||
async getDownloadDescriptor(id) {
|
||||
const item = await this.getItem(id);
|
||||
if (item.status !== 'ready') {
|
||||
const error = new Error('ZIP-Datei ist noch nicht fertig.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
const exists = await this._pathExists(item.archivePath);
|
||||
if (!exists) {
|
||||
item.status = 'failed';
|
||||
item.finishedAt = new Date().toISOString();
|
||||
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
|
||||
item.sizeBytes = null;
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('failed', item);
|
||||
const error = new Error('ZIP-Datei wurde nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
path: item.archivePath,
|
||||
archiveName: item.archiveName
|
||||
};
|
||||
}
|
||||
|
||||
async deleteItem(id) {
|
||||
const item = await this.getItem(id);
|
||||
if (item.status === 'queued' || item.status === 'processing' || this.activeTasks.has(item.id)) {
|
||||
const error = new Error('Laufende ZIP-Jobs können nicht gelöscht werden.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this._safeUnlink(item.archivePath);
|
||||
await this._safeUnlink(item.partialPath);
|
||||
await this._safeUnlink(item.metaPath);
|
||||
this.items.delete(item.id);
|
||||
this._broadcastUpdate('deleted', item);
|
||||
return {
|
||||
deleted: true,
|
||||
id: item.id
|
||||
};
|
||||
}
|
||||
|
||||
async _persistItem(item) {
|
||||
const next = {
|
||||
...item,
|
||||
metaPath: item.metaPath,
|
||||
archivePath: item.archivePath,
|
||||
partialPath: item.partialPath
|
||||
};
|
||||
const tmpMetaPath = `${item.metaPath}.tmp`;
|
||||
await fs.promises.writeFile(tmpMetaPath, JSON.stringify(next, null, 2), 'utf-8');
|
||||
await fs.promises.rename(tmpMetaPath, item.metaPath);
|
||||
applyOwnerToPath(item.metaPath, item.ownerSpec);
|
||||
}
|
||||
|
||||
async _safeUnlink(targetPath) {
|
||||
if (!targetPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.promises.rm(targetPath, { force: true });
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
async _pathExists(targetPath) {
|
||||
if (!targetPath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DownloadService();
|
||||
@@ -0,0 +1,528 @@
|
||||
'use strict';
|
||||
|
||||
const TITLE_KIND = Object.freeze({
|
||||
EPISODE_CANDIDATE: 'episode_candidate',
|
||||
PLAY_ALL: 'play_all',
|
||||
EXTRA: 'extra',
|
||||
DUPLICATE: 'duplicate',
|
||||
SHORT: 'short',
|
||||
UNKNOWN: 'unknown'
|
||||
});
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
minEpisodeMinutes: 18,
|
||||
maxEpisodeMinutes: 75,
|
||||
minChapterCount: 3,
|
||||
shortTitleMinutes: 5
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(rawValue) {
|
||||
const text = String(rawValue || '').trim();
|
||||
const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]);
|
||||
}
|
||||
|
||||
function roundToStep(value, step) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || step <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(num / step) * step;
|
||||
}
|
||||
|
||||
function median(values = []) {
|
||||
const nums = (Array.isArray(values) ? values : [])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.sort((left, right) => left - right);
|
||||
if (nums.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const middle = Math.floor(nums.length / 2);
|
||||
if (nums.length % 2 === 1) {
|
||||
return nums[middle];
|
||||
}
|
||||
return (nums[middle - 1] + nums[middle]) / 2;
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(rawCode, fallbackLabel = null) {
|
||||
const raw = String(rawCode || '').trim().toLowerCase();
|
||||
if (raw && raw.length === 3) {
|
||||
return raw;
|
||||
}
|
||||
const label = String(fallbackLabel || '').trim().toLowerCase();
|
||||
if (label.startsWith('de')) return 'deu';
|
||||
if (label.startsWith('en')) return 'eng';
|
||||
if (label.startsWith('fr')) return 'fra';
|
||||
if (label.startsWith('es')) return 'spa';
|
||||
if (label.startsWith('nl')) return 'nld';
|
||||
return raw || 'und';
|
||||
}
|
||||
|
||||
function normalizeSeriesLookupTitle(rawValue) {
|
||||
return String(rawValue || '')
|
||||
.replace(/[_./]+/g, ' ')
|
||||
.replace(/\bs\d{1,2}\s*d\d{1,2}\b/gi, ' ')
|
||||
.replace(/\bs(?:taffel|eason)?\s*\d{1,2}\s*(?:disc|dvd|cd|d)\s*\d{1,2}\b/gi, ' ')
|
||||
.replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b\d{1,2}x\b/gi, ' ')
|
||||
.replace(/\b(?:complete|collection|boxset)\b/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function deriveSeriesLookupHint(inputs = []) {
|
||||
const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs])
|
||||
.map((entry) => {
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
return {
|
||||
value: String(entry.value || '').trim(),
|
||||
source: String(entry.source || '').trim() || 'unknown'
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: String(entry || '').trim(),
|
||||
source: 'unknown'
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value);
|
||||
|
||||
for (const entry of normalizedInputs) {
|
||||
const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!compactValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const compactSeasonDiscMatch = compactValue.match(
|
||||
/(?:^|\s)s(?:taffel|eason)?\s*0?(\d{1,2})\s*(?:d|disc|dvd|cd)\s*0?(\d{1,2})(?=\s|$)/i
|
||||
);
|
||||
const seasonMatch = compactSeasonDiscMatch
|
||||
|| compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i)
|
||||
|| compactValue.match(/(?:^|\s)s\s*0?(\d{1,2})(?=\s|$)/i)
|
||||
|| compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i);
|
||||
if (!seasonMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonNumber = Number(seasonMatch[1] || 0);
|
||||
if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discMatch = compactSeasonDiscMatch
|
||||
|| compactValue.match(/(?:^|\s)(?:disc|dvd|cd|d)\s*0?(\d{1,2})(?=\s|$)/i);
|
||||
const compactDiscToken = compactSeasonDiscMatch ? compactSeasonDiscMatch[2] : null;
|
||||
const discNumber = discMatch
|
||||
? Number(compactDiscToken || discMatch[1] || 0) || null
|
||||
: null;
|
||||
|
||||
const baseTitle = normalizeSeriesLookupTitle(compactValue);
|
||||
if (!baseTitle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
query: baseTitle,
|
||||
seriesTitle: baseTitle,
|
||||
seasonNumber,
|
||||
discNumber,
|
||||
source: entry.source,
|
||||
sourceLabel: entry.value,
|
||||
confidence: discNumber ? 'high' : 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTitleRecord(title = {}) {
|
||||
const durationSeconds = Number(title.durationSeconds || 0);
|
||||
const chapterCount = Number(title.chapterCount || 0);
|
||||
const roundedDurationBucket = roundToStep(durationSeconds, 30);
|
||||
const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
index: Number(title.index || 0),
|
||||
durationSeconds,
|
||||
durationLabel: String(title.durationLabel || '').trim() || null,
|
||||
chapterCount,
|
||||
chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [],
|
||||
aspectRatio: String(title.aspectRatio || '').trim() || null,
|
||||
audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [],
|
||||
subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [],
|
||||
flags: Array.isArray(title.flags) ? title.flags : [],
|
||||
roundedDurationBucket,
|
||||
signature: JSON.stringify({
|
||||
duration: roundedDurationBucket,
|
||||
chapterCount,
|
||||
audioLanguages,
|
||||
subtitleLanguages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function buildBestEpisodeCluster(titles = [], options = {}) {
|
||||
const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60));
|
||||
const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60));
|
||||
const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount));
|
||||
const candidates = titles.filter((title) =>
|
||||
title.durationSeconds >= minEpisodeSeconds
|
||||
&& title.durationSeconds <= maxEpisodeSeconds
|
||||
&& title.chapterCount >= minChapterCount
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
titles: [],
|
||||
medianDurationSeconds: 0,
|
||||
medianChapterCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
let bestCluster = [];
|
||||
for (const anchor of candidates) {
|
||||
const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12));
|
||||
const cluster = candidates.filter((candidate) => {
|
||||
const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds);
|
||||
const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount);
|
||||
return durationGap <= durationTolerance && chapterGap <= 2;
|
||||
});
|
||||
|
||||
if (cluster.length > bestCluster.length) {
|
||||
bestCluster = cluster;
|
||||
continue;
|
||||
}
|
||||
if (cluster.length === bestCluster.length) {
|
||||
const clusterMedian = median(cluster.map((item) => item.durationSeconds));
|
||||
const bestMedian = median(bestCluster.map((item) => item.durationSeconds));
|
||||
if (clusterMedian > bestMedian) {
|
||||
bestCluster = cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
titles: bestCluster,
|
||||
medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)),
|
||||
medianChapterCount: median(bestCluster.map((item) => item.chapterCount))
|
||||
};
|
||||
}
|
||||
|
||||
function classifyTitles(titles = [], cluster = {}, options = {}) {
|
||||
const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60));
|
||||
const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index)));
|
||||
const duplicateFirstBySignature = new Map();
|
||||
|
||||
for (const title of titles) {
|
||||
if (!duplicateFirstBySignature.has(title.signature)) {
|
||||
duplicateFirstBySignature.set(title.signature, title.index);
|
||||
}
|
||||
}
|
||||
|
||||
const playAllCandidate = (() => {
|
||||
if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) {
|
||||
return null;
|
||||
}
|
||||
const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1);
|
||||
return titles
|
||||
.filter((title) =>
|
||||
!clusterTitleIds.has(Number(title.index))
|
||||
&& title.durationSeconds >= minPlayAllSeconds * 0.75
|
||||
&& title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6))
|
||||
)
|
||||
.sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null;
|
||||
})();
|
||||
|
||||
return titles.map((title) => {
|
||||
const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index;
|
||||
const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds;
|
||||
const isEpisodeCandidate = clusterTitleIds.has(Number(title.index));
|
||||
const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index);
|
||||
const kind = isPlayAll
|
||||
? TITLE_KIND.PLAY_ALL
|
||||
: isEpisodeCandidate
|
||||
? TITLE_KIND.EPISODE_CANDIDATE
|
||||
: !isFirstWithSignature
|
||||
? TITLE_KIND.DUPLICATE
|
||||
: isShort
|
||||
? TITLE_KIND.SHORT
|
||||
: (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN);
|
||||
|
||||
let confidence = 0.2;
|
||||
if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8;
|
||||
if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95;
|
||||
if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85;
|
||||
if (kind === TITLE_KIND.SHORT) confidence = 0.9;
|
||||
if (kind === TITLE_KIND.EXTRA) confidence = 0.55;
|
||||
|
||||
return {
|
||||
...title,
|
||||
kind,
|
||||
confidence,
|
||||
duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE
|
||||
? duplicateFirstBySignature.get(title.signature)
|
||||
: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiscSignature(parsedScan = {}) {
|
||||
return JSON.stringify({
|
||||
discTitle: String(parsedScan.discTitle || '').trim() || null,
|
||||
discSerial: String(parsedScan.discSerial || '').trim() || null,
|
||||
titleCount: Number(parsedScan.titleCount || 0),
|
||||
durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : [])
|
||||
.map((title) => ({
|
||||
index: Number(title.index || 0),
|
||||
durationBucket: roundToStep(title.durationSeconds || 0, 30),
|
||||
chapterCount: Number(title.chapterCount || 0)
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) {
|
||||
const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length;
|
||||
const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length;
|
||||
const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length;
|
||||
const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length;
|
||||
|
||||
const reasons = [];
|
||||
let confidence = 'low';
|
||||
let seriesLike = false;
|
||||
|
||||
if (episodeCount >= 3) {
|
||||
seriesLike = true;
|
||||
confidence = playAllCount > 0 ? 'high' : 'medium';
|
||||
reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`);
|
||||
} else if (episodeCount === 2) {
|
||||
seriesLike = true;
|
||||
confidence = 'medium';
|
||||
reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.');
|
||||
}
|
||||
|
||||
if (playAllCount > 0) {
|
||||
reasons.push('Ein langer Play-All-Titel wurde erkannt.');
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`);
|
||||
}
|
||||
if (cluster.medianDurationSeconds > 0) {
|
||||
reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`);
|
||||
}
|
||||
if (extrasCount > 0) {
|
||||
reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`);
|
||||
}
|
||||
|
||||
return {
|
||||
seriesLike,
|
||||
confidence,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
function parseHandBrakeScanText(rawOutput) {
|
||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||
const parsed = {
|
||||
source: 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: null,
|
||||
discSerial: null,
|
||||
titleCount: 0,
|
||||
rawLineCount: lines.length,
|
||||
titles: []
|
||||
};
|
||||
|
||||
let currentTitle = null;
|
||||
const ensureCurrentTitle = (index) => {
|
||||
const normalizedIndex = Number(index);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (currentTitle && Number(currentTitle.index) === normalizedIndex) {
|
||||
return currentTitle;
|
||||
}
|
||||
const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex);
|
||||
if (existing) {
|
||||
currentTitle = existing;
|
||||
return currentTitle;
|
||||
}
|
||||
currentTitle = {
|
||||
index: normalizedIndex,
|
||||
durationLabel: null,
|
||||
durationSeconds: 0,
|
||||
chapterCount: 0,
|
||||
chapterDurationsMs: [],
|
||||
audioTracks: [],
|
||||
subtitleTracks: [],
|
||||
aspectRatio: null,
|
||||
flags: []
|
||||
};
|
||||
parsed.titles.push(currentTitle);
|
||||
return currentTitle;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discTitle = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discSerial = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i);
|
||||
if (match) {
|
||||
parsed.titleCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/scan:\s+(?:BD|Blu-?ray)\s+has\s+(\d+)\s+title/i);
|
||||
if (match) {
|
||||
parsed.titleCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+scanning title\s+(\d+)/i);
|
||||
if (match) {
|
||||
ensureCurrentTitle(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+title\s+(\d+):/i);
|
||||
if (match) {
|
||||
ensureCurrentTitle(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.durationLabel = match[1];
|
||||
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+duration:\s+(\d{1,2}:\d{2}:\d{2})/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.durationLabel = match[1];
|
||||
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i);
|
||||
if (match) {
|
||||
const title = ensureCurrentTitle(match[1]);
|
||||
if (title) {
|
||||
title.chapterCount = Number(match[2] || 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+chapters:\s+(\d+)/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.chapterCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.chapterDurationsMs.push(Number(match[1] || 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i);
|
||||
if (match && currentTitle) {
|
||||
const track = {
|
||||
languageLabel: String(match[1] || '').trim() || null,
|
||||
languageCode: normalizeLanguageCode(match[2], match[1])
|
||||
};
|
||||
if (/\[VOBSUB\]/i.test(line)) {
|
||||
currentTitle.subtitleTracks.push(track);
|
||||
} else {
|
||||
currentTitle.audioTracks.push(track);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.aspectRatio = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+ignoring title \(too short\)/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.flags.push('ignored_too_short');
|
||||
}
|
||||
}
|
||||
|
||||
parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0));
|
||||
if (!parsed.titleCount) {
|
||||
parsed.titleCount = parsed.titles.length;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function analyzeParsedScan(parsedScan = {}, options = {}) {
|
||||
const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord);
|
||||
const cluster = buildBestEpisodeCluster(titles, options);
|
||||
const classifiedTitles = classifyTitles(titles, cluster, options);
|
||||
const summary = summarizeSeriesLikelihood(classifiedTitles, cluster);
|
||||
|
||||
return {
|
||||
source: parsedScan.source || 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: parsedScan.discTitle || null,
|
||||
discSerial: parsedScan.discSerial || null,
|
||||
titleCount: Number(parsedScan.titleCount || titles.length || 0),
|
||||
discSignature: buildDiscSignature({
|
||||
discTitle: parsedScan.discTitle,
|
||||
discSerial: parsedScan.discSerial,
|
||||
titleCount: parsedScan.titleCount,
|
||||
titles
|
||||
}),
|
||||
summary: {
|
||||
...summary,
|
||||
titleCount: classifiedTitles.length,
|
||||
episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length,
|
||||
playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length,
|
||||
duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length,
|
||||
extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length,
|
||||
typicalEpisodeMinutes: cluster.medianDurationSeconds
|
||||
? Number((cluster.medianDurationSeconds / 60).toFixed(2))
|
||||
: 0
|
||||
},
|
||||
titles: classifiedTitles
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeHandBrakeScan(rawOutput, options = {}) {
|
||||
const parsed = parseHandBrakeScanText(rawOutput);
|
||||
const analysis = analyzeParsedScan(parsed, options);
|
||||
return {
|
||||
parsed,
|
||||
analysis
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TITLE_KIND,
|
||||
parseHandBrakeScanText,
|
||||
analyzeParsedScan,
|
||||
analyzeHandBrakeScan,
|
||||
deriveSeriesLookupHint
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
const path = require('path');
|
||||
const { logDir: fallbackLogDir } = require('../config');
|
||||
|
||||
function normalizeDir(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
|
||||
}
|
||||
|
||||
function getFallbackLogRootDir() {
|
||||
return path.resolve(fallbackLogDir);
|
||||
}
|
||||
|
||||
function resolveLogRootDir(value) {
|
||||
return normalizeDir(value) || getFallbackLogRootDir();
|
||||
}
|
||||
|
||||
let runtimeLogRootDir = getFallbackLogRootDir();
|
||||
|
||||
function setLogRootDir(value) {
|
||||
runtimeLogRootDir = resolveLogRootDir(value);
|
||||
return runtimeLogRootDir;
|
||||
}
|
||||
|
||||
function getLogRootDir() {
|
||||
return runtimeLogRootDir || getFallbackLogRootDir();
|
||||
}
|
||||
|
||||
function getBackendLogDir() {
|
||||
return path.join(getLogRootDir(), 'backend');
|
||||
}
|
||||
|
||||
function getJobLogDir() {
|
||||
return getLogRootDir();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFallbackLogRootDir,
|
||||
resolveLogRootDir,
|
||||
setLogRootDir,
|
||||
getLogRootDir,
|
||||
getBackendLogDir,
|
||||
getJobLogDir
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { logLevel } = require('../config');
|
||||
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
|
||||
|
||||
const LEVELS = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40
|
||||
};
|
||||
|
||||
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
|
||||
let consoleConfig = {
|
||||
enabled: true,
|
||||
levels: {
|
||||
debug: true,
|
||||
info: true,
|
||||
warn: true,
|
||||
error: true
|
||||
},
|
||||
http: true
|
||||
};
|
||||
|
||||
function normalizeBoolean(value, fallback = true) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function ensureLogDir(logDirPath) {
|
||||
try {
|
||||
fs.mkdirSync(logDirPath, { recursive: true });
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWritableBackendLogDir() {
|
||||
const preferred = getBackendLogDir();
|
||||
if (ensureLogDir(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const fallback = path.join(getFallbackLogRootDir(), 'backend');
|
||||
if (fallback !== preferred && ensureLogDir(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDailyFileName() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `backend-${y}-${m}-${day}.log`;
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (error) {
|
||||
return JSON.stringify({ serializationError: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
function truncateString(value, maxLen = 3000) {
|
||||
const str = String(value);
|
||||
if (str.length <= maxLen) {
|
||||
return str;
|
||||
}
|
||||
return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`;
|
||||
}
|
||||
|
||||
function sanitizeMeta(meta) {
|
||||
if (!meta || typeof meta !== 'object') {
|
||||
return meta;
|
||||
}
|
||||
|
||||
const out = Array.isArray(meta) ? [] : {};
|
||||
|
||||
for (const [key, val] of Object.entries(meta)) {
|
||||
if (val instanceof Error) {
|
||||
out[key] = {
|
||||
name: val.name,
|
||||
message: val.message,
|
||||
stack: val.stack
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof val === 'string') {
|
||||
out[key] = truncateString(val, 5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
out[key] = val;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeLine(line) {
|
||||
const backendLogDir = resolveWritableBackendLogDir();
|
||||
if (!backendLogDir) {
|
||||
return;
|
||||
}
|
||||
const daily = path.join(backendLogDir, getDailyFileName());
|
||||
const latest = path.join(backendLogDir, 'backend-latest.log');
|
||||
|
||||
fs.appendFile(daily, `${line}\n`, (_error) => null);
|
||||
fs.appendFile(latest, `${line}\n`, (_error) => null);
|
||||
}
|
||||
|
||||
function emit(level, scope, message, meta = null) {
|
||||
const normLevel = String(level || 'info').toLowerCase();
|
||||
const lvl = LEVELS[normLevel] || LEVELS.info;
|
||||
if (lvl < ACTIVE_LEVEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const payload = {
|
||||
timestamp,
|
||||
level: normLevel,
|
||||
scope,
|
||||
message,
|
||||
meta: sanitizeMeta(meta)
|
||||
};
|
||||
|
||||
const line = safeJson(payload);
|
||||
writeLine(line);
|
||||
|
||||
if (!shouldEmitToConsole(normLevel, scope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
|
||||
if (normLevel === 'error') {
|
||||
console.error(print, payload.meta ? payload.meta : '');
|
||||
} else if (normLevel === 'warn') {
|
||||
console.warn(print, payload.meta ? payload.meta : '');
|
||||
} else {
|
||||
console.log(print, payload.meta ? payload.meta : '');
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEmitToConsole(level, scope) {
|
||||
if (!consoleConfig.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedLevel = String(level || 'info').toLowerCase();
|
||||
if (consoleConfig.levels[normalizedLevel] === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedScope = String(scope || '').trim().toUpperCase();
|
||||
if (normalizedScope === 'HTTP' && !consoleConfig.http) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function child(scope) {
|
||||
return {
|
||||
debug(message, meta) {
|
||||
emit('debug', scope, message, meta);
|
||||
},
|
||||
info(message, meta) {
|
||||
emit('info', scope, message, meta);
|
||||
},
|
||||
warn(message, meta) {
|
||||
emit('warn', scope, message, meta);
|
||||
},
|
||||
error(message, meta) {
|
||||
emit('error', scope, message, meta);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setConsoleOutputEnabled(enabled) {
|
||||
consoleConfig.enabled = Boolean(enabled);
|
||||
return consoleConfig.enabled;
|
||||
}
|
||||
|
||||
function isConsoleOutputEnabled() {
|
||||
return consoleConfig.enabled;
|
||||
}
|
||||
|
||||
function configureConsoleOutput(options = {}) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
if (Object.prototype.hasOwnProperty.call(opts, 'enabled')) {
|
||||
consoleConfig.enabled = normalizeBoolean(opts.enabled, true);
|
||||
}
|
||||
|
||||
if (opts.levels && typeof opts.levels === 'object') {
|
||||
const sourceLevels = opts.levels;
|
||||
for (const level of Object.keys(LEVELS)) {
|
||||
if (Object.prototype.hasOwnProperty.call(sourceLevels, level)) {
|
||||
consoleConfig.levels[level] = normalizeBoolean(sourceLevels[level], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(opts, 'http')) {
|
||||
consoleConfig.http = normalizeBoolean(opts.http, true);
|
||||
}
|
||||
|
||||
return getConsoleOutputConfig();
|
||||
}
|
||||
|
||||
function getConsoleOutputConfig() {
|
||||
return {
|
||||
enabled: Boolean(consoleConfig.enabled),
|
||||
levels: {
|
||||
debug: Boolean(consoleConfig.levels.debug),
|
||||
info: Boolean(consoleConfig.levels.info),
|
||||
warn: Boolean(consoleConfig.levels.warn),
|
||||
error: Boolean(consoleConfig.levels.error)
|
||||
},
|
||||
http: Boolean(consoleConfig.http)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
child,
|
||||
emit,
|
||||
setConsoleOutputEnabled,
|
||||
isConsoleOutputEnabled,
|
||||
configureConsoleOutput,
|
||||
getConsoleOutputConfig
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const logger = require('./logger').child('MAKEMKV_KEY');
|
||||
|
||||
const MAKEMKV_BETA_KEY_API_URL = 'https://cable.ayra.ch/makemkv/api.php?json';
|
||||
const MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT
|
||||
|| 'Ripster/1.0 (MakeMKV beta key sync)';
|
||||
const MAKEMKV_BETA_KEY_API_FROM = process.env.MAKEMKV_BETA_KEY_API_FROM
|
||||
|| 'admin@example.invalid';
|
||||
const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max(
|
||||
1000,
|
||||
Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000)
|
||||
);
|
||||
|
||||
function normalizeRegistrationKey(rawValue) {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
|
||||
function getMakeMKVConfigDir(homeDir = os.homedir()) {
|
||||
return path.join(String(homeDir || '').trim() || os.homedir(), '.MakeMKV');
|
||||
}
|
||||
|
||||
function getMakeMKVSettingsFilePath(homeDir = os.homedir()) {
|
||||
return path.join(getMakeMKVConfigDir(homeDir), 'settings.conf');
|
||||
}
|
||||
|
||||
function escapeKeyForSettingsFile(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function buildUpdatedSettingsContent(currentContent, registrationKey) {
|
||||
const existingLines = String(currentContent || '')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !/^\s*app_Key\s*=/.test(line));
|
||||
const normalizedKey = normalizeRegistrationKey(registrationKey);
|
||||
|
||||
while (existingLines.length > 0 && existingLines[existingLines.length - 1] === '') {
|
||||
existingLines.pop();
|
||||
}
|
||||
|
||||
if (normalizedKey) {
|
||||
existingLines.push(`app_Key = "${escapeKeyForSettingsFile(normalizedKey)}"`);
|
||||
}
|
||||
|
||||
return existingLines.length > 0 ? `${existingLines.join('\n')}\n` : '';
|
||||
}
|
||||
|
||||
async function syncRegistrationKeyToConfig(rawValue, options = {}) {
|
||||
const normalizedKey = normalizeRegistrationKey(rawValue);
|
||||
const homeDir = String(options?.homeDir || '').trim() || os.homedir();
|
||||
const configDir = getMakeMKVConfigDir(homeDir);
|
||||
const settingsFilePath = getMakeMKVSettingsFilePath(homeDir);
|
||||
const fileExists = fs.existsSync(settingsFilePath);
|
||||
const currentContent = fileExists
|
||||
? await fs.promises.readFile(settingsFilePath, 'utf8')
|
||||
: '';
|
||||
const nextContent = buildUpdatedSettingsContent(currentContent, normalizedKey);
|
||||
|
||||
if (!normalizedKey && !fileExists) {
|
||||
return {
|
||||
changed: false,
|
||||
path: settingsFilePath,
|
||||
hasKey: false
|
||||
};
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(configDir, { recursive: true });
|
||||
|
||||
if (nextContent) {
|
||||
await fs.promises.writeFile(settingsFilePath, nextContent, 'utf8');
|
||||
await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {});
|
||||
logger.info('settings-conf:key-synced', {
|
||||
path: settingsFilePath,
|
||||
hasKey: true
|
||||
});
|
||||
} else {
|
||||
await fs.promises.writeFile(settingsFilePath, '', 'utf8');
|
||||
await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {});
|
||||
logger.info('settings-conf:key-cleared', {
|
||||
path: settingsFilePath,
|
||||
hasKey: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
changed: currentContent !== nextContent,
|
||||
path: settingsFilePath,
|
||||
hasKey: Boolean(normalizedKey)
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCurrentBetaKey() {
|
||||
const errors = [];
|
||||
|
||||
try {
|
||||
return await fetchCurrentBetaKeyViaCurl();
|
||||
} catch (error) {
|
||||
errors.push(error?.message || String(error));
|
||||
logger.warn('beta-key:curl-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchCurrentBetaKeyViaFetch();
|
||||
} catch (error) {
|
||||
errors.push(error?.message || String(error));
|
||||
logger.warn('beta-key:fetch-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
|
||||
const error = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`);
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function parseBetaKeyResponse(rawBody) {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(String(rawBody || '{}'));
|
||||
} catch (_error) {
|
||||
const error = new Error('Betakey-Antwort ist kein gültiges JSON.');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const betaKey = String(payload?.key || '').trim();
|
||||
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9-]{15,}$/.test(betaKey)) {
|
||||
const error = new Error('Betakey-Antwort ist ungültig.');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
key: betaKey,
|
||||
sourceUrl: MAKEMKV_BETA_KEY_API_URL
|
||||
};
|
||||
}
|
||||
|
||||
function fetchCurrentBetaKeyViaCurl() {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'curl',
|
||||
[
|
||||
'-fsSL',
|
||||
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
|
||||
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
|
||||
'-H', `From: ${MAKEMKV_BETA_KEY_API_FROM}`,
|
||||
'-H', 'Accept: text/plain, text/html;q=0.9, */*;q=0.8',
|
||||
'-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
|
||||
'-H', 'Cache-Control: no-cache',
|
||||
'-H', 'Pragma: no-cache',
|
||||
MAKEMKV_BETA_KEY_API_URL
|
||||
],
|
||||
{
|
||||
timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const resolvedError = new Error(
|
||||
`curl fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
|
||||
);
|
||||
resolvedError.statusCode = 502;
|
||||
reject(resolvedError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseBetaKeyResponse(stdout));
|
||||
} catch (parseError) {
|
||||
reject(parseError);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchCurrentBetaKeyViaFetch() {
|
||||
const response = await fetch(MAKEMKV_BETA_KEY_API_URL, {
|
||||
headers: {
|
||||
'User-Agent': MAKEMKV_BETA_KEY_API_USER_AGENT,
|
||||
'From': MAKEMKV_BETA_KEY_API_FROM,
|
||||
'Accept': 'text/plain, text/html;q=0.9, */*;q=0.8',
|
||||
'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache'
|
||||
},
|
||||
signal: AbortSignal.timeout(MAKEMKV_BETA_KEY_API_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}`);
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
return parseBetaKeyResponse(rawBody);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAKEMKV_BETA_KEY_API_URL,
|
||||
fetchCurrentBetaKey,
|
||||
getMakeMKVConfigDir,
|
||||
getMakeMKVSettingsFilePath,
|
||||
normalizeRegistrationKey,
|
||||
syncRegistrationKeyToConfig
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('MUSICBRAINZ');
|
||||
|
||||
const MB_BASE = 'https://musicbrainz.org/ws/2';
|
||||
const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)';
|
||||
const MB_TIMEOUT_MS = 10000;
|
||||
|
||||
async function mbFetch(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': MB_USER_AGENT
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) {
|
||||
throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelease(release) {
|
||||
if (!release) {
|
||||
return null;
|
||||
}
|
||||
const artistCredit = Array.isArray(release['artist-credit'])
|
||||
? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ')
|
||||
: null;
|
||||
const date = String(release.date || '').trim();
|
||||
const yearMatch = date.match(/\b(\d{4})\b/);
|
||||
const year = yearMatch ? Number(yearMatch[1]) : null;
|
||||
|
||||
const media = Array.isArray(release.media) ? release.media : [];
|
||||
const normalizedTracks = media.flatMap((medium, mediumIdx) => {
|
||||
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
|
||||
return mediumTracks.map((track, trackIdx) => {
|
||||
const rawPosition = String(track.position || track.number || '').trim();
|
||||
const parsedPosition = Number.parseInt(rawPosition, 10);
|
||||
const fallbackPosition = mediumIdx * 100 + trackIdx + 1;
|
||||
const position = Number.isFinite(parsedPosition) && parsedPosition > 0
|
||||
? parsedPosition
|
||||
: fallbackPosition;
|
||||
return {
|
||||
position,
|
||||
number: String(track.number || track.position || ''),
|
||||
title: String(track.title || ''),
|
||||
durationMs: Number(track.length || 0) || null,
|
||||
rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [],
|
||||
rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : []
|
||||
};
|
||||
});
|
||||
}).map((track) => {
|
||||
const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit)
|
||||
? track.rawTrackArtistCredit
|
||||
: [];
|
||||
const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit)
|
||||
? track.rawRecordingArtistCredit
|
||||
: [];
|
||||
const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
|
||||
const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
|
||||
return {
|
||||
position: track.position,
|
||||
number: track.number,
|
||||
title: track.title,
|
||||
durationMs: track.durationMs,
|
||||
artist: artistFromTrack || artistFromRecording || artistCredit || null
|
||||
};
|
||||
});
|
||||
|
||||
// Always generate the CAA URL when an id is present; the browser/onError
|
||||
// handles 404s for releases that have no front cover.
|
||||
const coverArtUrl = release.id
|
||||
? `https://coverartarchive.org/release/${release.id}/front-250`
|
||||
: null;
|
||||
|
||||
return {
|
||||
mbId: String(release.id || ''),
|
||||
title: String(release.title || ''),
|
||||
artist: artistCredit || null,
|
||||
year,
|
||||
date,
|
||||
country: String(release.country || '').trim() || null,
|
||||
label: Array.isArray(release['label-info'])
|
||||
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
|
||||
: null,
|
||||
coverArtUrl,
|
||||
tracks: normalizedTracks
|
||||
};
|
||||
}
|
||||
|
||||
class MusicBrainzService {
|
||||
async isEnabled() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return settings.musicbrainz_enabled !== 'false';
|
||||
}
|
||||
|
||||
async searchByTitle(query) {
|
||||
const q = String(query || '').trim();
|
||||
if (!q) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
if (!enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info('search:start', { query: q });
|
||||
|
||||
const url = new URL(`${MB_BASE}/release`);
|
||||
url.searchParams.set('query', q);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
url.searchParams.set('limit', '10');
|
||||
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
|
||||
|
||||
try {
|
||||
const data = await mbFetch(url.toString());
|
||||
const releases = Array.isArray(data.releases) ? data.releases : [];
|
||||
const results = releases.map(normalizeRelease).filter(Boolean);
|
||||
logger.info('search:done', { query: q, count: results.length });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async searchByDiscLabel(discLabel) {
|
||||
return this.searchByTitle(discLabel);
|
||||
}
|
||||
|
||||
async getReleaseById(mbId) {
|
||||
const id = String(mbId || '').trim();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('getById:start', { mbId: id });
|
||||
|
||||
const url = new URL(`${MB_BASE}/release/${id}`);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
|
||||
|
||||
try {
|
||||
const data = await mbFetch(url.toString());
|
||||
const result = normalizeRelease(data);
|
||||
logger.info('getById:done', { mbId: id, title: result?.title });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MusicBrainzService();
|
||||
@@ -0,0 +1,165 @@
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('PUSHOVER');
|
||||
const { toBoolean } = require('../utils/validators');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const PUSHOVER_API_URL = 'https://api.pushover.net/1/messages.json';
|
||||
|
||||
const EVENT_TOGGLE_KEYS = {
|
||||
metadata_ready: 'pushover_notify_metadata_ready',
|
||||
rip_started: 'pushover_notify_rip_started',
|
||||
encoding_started: 'pushover_notify_encoding_started',
|
||||
job_finished: 'pushover_notify_job_finished',
|
||||
job_error: 'pushover_notify_job_error',
|
||||
job_cancelled: 'pushover_notify_job_cancelled',
|
||||
reencode_started: 'pushover_notify_reencode_started',
|
||||
reencode_finished: 'pushover_notify_reencode_finished'
|
||||
};
|
||||
|
||||
function truncate(value, maxLen = 1024) {
|
||||
const text = String(value || '').trim();
|
||||
if (text.length <= maxLen) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, maxLen - 20)}...[truncated]`;
|
||||
}
|
||||
|
||||
function normalizePriority(raw) {
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) {
|
||||
return 0;
|
||||
}
|
||||
if (n < -2) {
|
||||
return -2;
|
||||
}
|
||||
if (n > 2) {
|
||||
return 2;
|
||||
}
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
async notify(eventKey, payload = {}) {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return this.notifyWithSettings(settings, eventKey, payload);
|
||||
}
|
||||
|
||||
async sendTest({ title, message } = {}) {
|
||||
return this.notify('test', {
|
||||
title: title || 'Ripster Test',
|
||||
message: message || 'PushOver Testnachricht von Ripster.'
|
||||
});
|
||||
}
|
||||
|
||||
async notifyWithSettings(settings, eventKey, payload = {}) {
|
||||
const enabled = toBoolean(settings.pushover_enabled);
|
||||
if (!enabled) {
|
||||
logger.debug('notify:skip:disabled', { eventKey });
|
||||
return { sent: false, reason: 'disabled', eventKey };
|
||||
}
|
||||
|
||||
const toggleKey = EVENT_TOGGLE_KEYS[eventKey];
|
||||
if (toggleKey && !toBoolean(settings[toggleKey])) {
|
||||
logger.debug('notify:skip:event-disabled', { eventKey, toggleKey });
|
||||
return { sent: false, reason: 'event-disabled', eventKey };
|
||||
}
|
||||
|
||||
const token = String(settings.pushover_token || '').trim();
|
||||
const user = String(settings.pushover_user || '').trim();
|
||||
if (!token || !user) {
|
||||
logger.warn('notify:skip:missing-credentials', {
|
||||
eventKey,
|
||||
hasToken: Boolean(token),
|
||||
hasUser: Boolean(user)
|
||||
});
|
||||
return { sent: false, reason: 'missing-credentials', eventKey };
|
||||
}
|
||||
|
||||
const prefix = String(settings.pushover_title_prefix || 'Ripster').trim();
|
||||
const title = truncate(payload.title || `${prefix} - ${eventKey}`, 120);
|
||||
const message = truncate(payload.message || eventKey, 1024);
|
||||
const priority = normalizePriority(
|
||||
payload.priority !== undefined ? payload.priority : settings.pushover_priority
|
||||
);
|
||||
const timeoutMs = Math.max(1000, Number(settings.pushover_timeout_ms || 7000));
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set('token', token);
|
||||
form.set('user', user);
|
||||
form.set('title', title);
|
||||
form.set('message', message);
|
||||
form.set('priority', String(priority));
|
||||
|
||||
const device = String(settings.pushover_device || '').trim();
|
||||
if (device) {
|
||||
form.set('device', device);
|
||||
}
|
||||
|
||||
if (payload.url) {
|
||||
form.set('url', String(payload.url));
|
||||
}
|
||||
if (payload.urlTitle) {
|
||||
form.set('url_title', String(payload.urlTitle));
|
||||
}
|
||||
if (payload.sound) {
|
||||
form.set('sound', String(payload.sound));
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(PUSHOVER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: form.toString(),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = rawText ? JSON.parse(rawText) : null;
|
||||
} catch (error) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const messageText = data?.errors?.join(', ') || data?.error || rawText || `HTTP ${response.status}`;
|
||||
const error = new Error(`PushOver HTTP ${response.status}: ${messageText}`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data && data.status !== 1) {
|
||||
const messageText = data.errors?.join(', ') || data.error || 'Unbekannte PushOver Antwort.';
|
||||
throw new Error(`PushOver Fehler: ${messageText}`);
|
||||
}
|
||||
|
||||
logger.info('notify:sent', {
|
||||
eventKey,
|
||||
title,
|
||||
priority,
|
||||
requestId: data?.request || null
|
||||
});
|
||||
return {
|
||||
sent: true,
|
||||
eventKey,
|
||||
requestId: data?.request || null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('notify:failed', {
|
||||
eventKey,
|
||||
title,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NotificationService();
|
||||
@@ -0,0 +1,225 @@
|
||||
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 {
|
||||
mapSearchResults(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
if (data.Response === 'False' || !Array.isArray(data.Search)) {
|
||||
return [];
|
||||
}
|
||||
return data.Search.map((item) => ({
|
||||
title: item.Title,
|
||||
year: item.Year,
|
||||
imdbId: item.imdbID,
|
||||
type: item.Type,
|
||||
poster: item.Poster
|
||||
}));
|
||||
}
|
||||
|
||||
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) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
logger.info('search:start', { query: normalizedQuery });
|
||||
|
||||
// Allow direct IMDb-ID lookups in the search field (useful for History remapping).
|
||||
const imdbLike = normalizedQuery.toLowerCase();
|
||||
if (/^tt\d{6,12}$/.test(imdbLike)) {
|
||||
const byId = await this.fetchByImdbId(imdbLike);
|
||||
return byId
|
||||
? [{
|
||||
title: byId.title,
|
||||
year: byId.year != null ? String(byId.year) : null,
|
||||
imdbId: byId.imdbId,
|
||||
type: byId.type,
|
||||
poster: byId.poster
|
||||
}]
|
||||
: [];
|
||||
}
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const apiKey = settings.omdb_api_key;
|
||||
if (!apiKey) {
|
||||
return [];
|
||||
}
|
||||
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
|
||||
|
||||
const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie';
|
||||
const requestSearch = async (type = null) => {
|
||||
const url = new URL(OMDB_BASE_URL);
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
url.searchParams.set('s', normalizedQuery);
|
||||
if (type) {
|
||||
url.searchParams.set('type', type);
|
||||
}
|
||||
const data = await this.requestJson(
|
||||
url,
|
||||
{ query: normalizedQuery, action: 'search', type: type || null },
|
||||
{ timeoutMs }
|
||||
);
|
||||
return {
|
||||
data,
|
||||
results: this.mapSearchResults(data)
|
||||
};
|
||||
};
|
||||
|
||||
let { data, results } = await requestSearch(configuredType);
|
||||
if (results.length === 0 && configuredType) {
|
||||
logger.info('search:fallback-no-type', {
|
||||
query: normalizedQuery,
|
||||
configuredType,
|
||||
response: data?.Response || null,
|
||||
error: data?.Error || null
|
||||
});
|
||||
({ data, results } = await requestSearch(null));
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
logger.warn('search:no-results', {
|
||||
query: normalizedQuery,
|
||||
response: data?.Response || null,
|
||||
error: data?.Error || null,
|
||||
configuredType
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType });
|
||||
return results;
|
||||
}
|
||||
|
||||
async fetchByImdbId(imdbId) {
|
||||
const normalizedId = String(imdbId || '').trim().toLowerCase();
|
||||
if (!/^tt\d{6,12}$/.test(normalizedId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('fetchByImdbId:start', { imdbId: normalizedId });
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const apiKey = settings.omdb_api_key;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
|
||||
|
||||
const url = new URL(OMDB_BASE_URL);
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
url.searchParams.set('i', normalizedId);
|
||||
url.searchParams.set('plot', 'full');
|
||||
|
||||
const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (data.Response === 'False') {
|
||||
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
|
||||
return null;
|
||||
}
|
||||
|
||||
const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/);
|
||||
const year = yearMatch ? Number(yearMatch[0]) : null;
|
||||
const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null;
|
||||
|
||||
const result = {
|
||||
title: data.Title || null,
|
||||
year: Number.isFinite(year) ? year : null,
|
||||
imdbId: String(data.imdbID || normalizedId),
|
||||
type: data.Type || null,
|
||||
poster,
|
||||
raw: data
|
||||
};
|
||||
logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OmdbService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
const { spawn } = require('child_process');
|
||||
const logger = require('./logger').child('PROCESS');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
function streamLines(stream, onLine) {
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const parts = buffer.split(/\r\n|\n|\r/);
|
||||
buffer = parts.pop() ?? '';
|
||||
|
||||
for (const line of parts) {
|
||||
if (line.length > 0) {
|
||||
onLine(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (buffer.length > 0) {
|
||||
onLine(buffer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
onStdoutLine,
|
||||
onStderrLine,
|
||||
onStart,
|
||||
context = {}
|
||||
}) {
|
||||
logger.info('spawn:start', { cmd, args, cwd, context });
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true
|
||||
});
|
||||
|
||||
if (onStart) {
|
||||
onStart(child);
|
||||
}
|
||||
|
||||
if (child.stdout && onStdoutLine) {
|
||||
streamLines(child.stdout, onStdoutLine);
|
||||
}
|
||||
|
||||
if (child.stderr && onStderrLine) {
|
||||
streamLines(child.stderr, onStderrLine);
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
child.on('error', (error) => {
|
||||
logger.error('spawn:error', { cmd, args, context, error: errorToMeta(error) });
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
logger.info('spawn:close', { cmd, args, code, signal, context });
|
||||
if (code === 0) {
|
||||
resolve({ code, signal });
|
||||
} else {
|
||||
const error = new Error(`Prozess ${cmd} beendet mit Code ${code ?? 'null'} (Signal ${signal ?? 'none'}).`);
|
||||
error.code = code;
|
||||
error.signal = signal;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let cancelCalled = false;
|
||||
const killProcessTree = (signal) => {
|
||||
const pid = Number(child.pid);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
// fallback below
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const cancel = () => {
|
||||
if (cancelCalled) {
|
||||
return;
|
||||
}
|
||||
cancelCalled = true;
|
||||
|
||||
logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid });
|
||||
// Instant cancel by user request.
|
||||
killProcessTree('SIGKILL');
|
||||
};
|
||||
|
||||
return {
|
||||
child,
|
||||
promise,
|
||||
cancel
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
spawnTrackedProcess,
|
||||
streamLines
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
const wsService = require('./websocketService');
|
||||
|
||||
const MAX_RECENT_ACTIVITIES = 120;
|
||||
const MAX_ACTIVITY_OUTPUT_CHARS = 12000;
|
||||
const MAX_ACTIVITY_TEXT_CHARS = 2000;
|
||||
const OUTPUT_BROADCAST_THROTTLE_MS = 180;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS } = {}) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
let text = String(value);
|
||||
if (trim) {
|
||||
text = text.trim();
|
||||
}
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
if (text.length > maxChars) {
|
||||
if (trim) {
|
||||
const suffix = ' ...[gekürzt]';
|
||||
text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`;
|
||||
} else {
|
||||
const prefix = '...[gekürzt]\n';
|
||||
text = `${prefix}${text.slice(-Math.max(0, maxChars - prefix.length))}`;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeOutputChunk(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
const normalized = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
|
||||
}
|
||||
|
||||
function appendOutputTail(currentValue, chunk, maxChars = MAX_ACTIVITY_OUTPUT_CHARS) {
|
||||
const normalizedChunk = normalizeOutputChunk(chunk);
|
||||
const currentText = currentValue == null ? '' : String(currentValue);
|
||||
if (!normalizedChunk) {
|
||||
return {
|
||||
value: currentText || null,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
|
||||
const combined = `${currentText}${normalizedChunk}`;
|
||||
if (combined.length <= maxChars) {
|
||||
return {
|
||||
value: combined,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: combined.slice(-maxChars),
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeActivity(input = {}) {
|
||||
const source = input && typeof input === 'object' ? input : {};
|
||||
const normalizedOutcome = normalizeText(source.outcome, { trim: true, maxChars: 40 });
|
||||
return {
|
||||
id: normalizeNumber(source.id),
|
||||
type: String(source.type || '').trim().toLowerCase() || 'task',
|
||||
name: String(source.name || '').trim() || null,
|
||||
status: String(source.status || '').trim().toLowerCase() || 'running',
|
||||
source: String(source.source || '').trim() || null,
|
||||
message: String(source.message || '').trim() || null,
|
||||
currentStep: String(source.currentStep || '').trim() || null,
|
||||
currentStepType: String(source.currentStepType || '').trim() || null,
|
||||
currentScriptName: String(source.currentScriptName || '').trim() || null,
|
||||
stepIndex: normalizeNumber(source.stepIndex),
|
||||
stepTotal: normalizeNumber(source.stepTotal),
|
||||
parentActivityId: normalizeNumber(source.parentActivityId),
|
||||
jobId: normalizeNumber(source.jobId),
|
||||
cronJobId: normalizeNumber(source.cronJobId),
|
||||
chainId: normalizeNumber(source.chainId),
|
||||
scriptId: normalizeNumber(source.scriptId),
|
||||
canCancel: Boolean(source.canCancel),
|
||||
canNextStep: Boolean(source.canNextStep),
|
||||
outcome: normalizedOutcome ? String(normalizedOutcome).toLowerCase() : null,
|
||||
errorMessage: normalizeText(source.errorMessage, { trim: true, maxChars: MAX_ACTIVITY_TEXT_CHARS }),
|
||||
output: normalizeText(source.output, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
stdout: normalizeText(source.stdout, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
stderr: normalizeText(source.stderr, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
outputTruncated: Boolean(source.outputTruncated),
|
||||
stdoutTruncated: Boolean(source.stdoutTruncated),
|
||||
stderrTruncated: Boolean(source.stderrTruncated),
|
||||
startedAt: source.startedAt || nowIso(),
|
||||
finishedAt: source.finishedAt || null,
|
||||
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
|
||||
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
|
||||
success: source.success === null || source.success === undefined ? null : Boolean(source.success)
|
||||
};
|
||||
}
|
||||
|
||||
class RuntimeActivityService {
|
||||
constructor() {
|
||||
this.nextId = 1;
|
||||
this.active = new Map();
|
||||
this.recent = [];
|
||||
this.controls = new Map();
|
||||
this.outputBroadcastTimer = null;
|
||||
}
|
||||
|
||||
buildSnapshot() {
|
||||
const active = Array.from(this.active.values())
|
||||
.sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
|
||||
const recent = [...this.recent]
|
||||
.sort((a, b) => String(b.finishedAt || b.startedAt || '').localeCompare(String(a.finishedAt || a.startedAt || '')));
|
||||
return {
|
||||
active,
|
||||
recent,
|
||||
updatedAt: nowIso()
|
||||
};
|
||||
}
|
||||
|
||||
broadcastSnapshot() {
|
||||
if (this.outputBroadcastTimer) {
|
||||
clearTimeout(this.outputBroadcastTimer);
|
||||
this.outputBroadcastTimer = null;
|
||||
}
|
||||
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
|
||||
}
|
||||
|
||||
scheduleOutputBroadcast() {
|
||||
if (this.outputBroadcastTimer) {
|
||||
return;
|
||||
}
|
||||
this.outputBroadcastTimer = setTimeout(() => {
|
||||
this.outputBroadcastTimer = null;
|
||||
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
|
||||
}, OUTPUT_BROADCAST_THROTTLE_MS);
|
||||
}
|
||||
|
||||
startActivity(type, payload = {}) {
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
const activity = sanitizeActivity({
|
||||
...payload,
|
||||
id,
|
||||
type,
|
||||
status: 'running',
|
||||
outcome: 'running',
|
||||
startedAt: payload?.startedAt || nowIso(),
|
||||
finishedAt: null,
|
||||
durationMs: null,
|
||||
canCancel: Boolean(payload?.canCancel),
|
||||
canNextStep: Boolean(payload?.canNextStep)
|
||||
});
|
||||
this.active.set(id, activity);
|
||||
this.broadcastSnapshot();
|
||||
return id;
|
||||
}
|
||||
|
||||
updateActivity(activityId, patch = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const current = this.active.get(id);
|
||||
const next = sanitizeActivity({
|
||||
...current,
|
||||
...patch,
|
||||
id: current.id,
|
||||
type: current.type,
|
||||
status: current.status === 'running' ? (patch?.status || current.status) : current.status,
|
||||
startedAt: current.startedAt
|
||||
});
|
||||
this.active.set(id, next);
|
||||
this.broadcastSnapshot();
|
||||
return next;
|
||||
}
|
||||
|
||||
appendActivityOutput(activityId, patch = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = this.active.get(id);
|
||||
const nextOutput = appendOutputTail(current.output, patch?.output, MAX_ACTIVITY_OUTPUT_CHARS);
|
||||
const nextStdout = appendOutputTail(current.stdout, patch?.stdout, MAX_ACTIVITY_OUTPUT_CHARS);
|
||||
const nextStderr = appendOutputTail(current.stderr, patch?.stderr, MAX_ACTIVITY_OUTPUT_CHARS);
|
||||
const next = sanitizeActivity({
|
||||
...current,
|
||||
...patch,
|
||||
id: current.id,
|
||||
type: current.type,
|
||||
status: current.status,
|
||||
startedAt: current.startedAt,
|
||||
output: nextOutput.value,
|
||||
stdout: nextStdout.value,
|
||||
stderr: nextStderr.value,
|
||||
outputTruncated: Boolean(current.outputTruncated || patch?.outputTruncated || nextOutput.truncated),
|
||||
stdoutTruncated: Boolean(current.stdoutTruncated || patch?.stdoutTruncated || nextStdout.truncated),
|
||||
stderrTruncated: Boolean(current.stderrTruncated || patch?.stderrTruncated || nextStderr.truncated)
|
||||
});
|
||||
this.active.set(id, next);
|
||||
this.scheduleOutputBroadcast();
|
||||
return next;
|
||||
}
|
||||
|
||||
completeActivity(activityId, payload = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const current = this.active.get(id);
|
||||
const finishedAt = payload?.finishedAt || nowIso();
|
||||
const startedAtDate = new Date(current.startedAt);
|
||||
const finishedAtDate = new Date(finishedAt);
|
||||
const durationMs = Number.isFinite(startedAtDate.getTime()) && Number.isFinite(finishedAtDate.getTime())
|
||||
? Math.max(0, finishedAtDate.getTime() - startedAtDate.getTime())
|
||||
: null;
|
||||
const status = String(payload?.status || '').trim().toLowerCase() || (payload?.success === false ? 'error' : 'success');
|
||||
let outcome = String(payload?.outcome || '').trim().toLowerCase();
|
||||
if (!outcome) {
|
||||
if (Boolean(payload?.cancelled)) {
|
||||
outcome = 'cancelled';
|
||||
} else if (Boolean(payload?.skipped)) {
|
||||
outcome = 'skipped';
|
||||
} else {
|
||||
outcome = status === 'success' ? 'success' : 'error';
|
||||
}
|
||||
}
|
||||
const finalized = sanitizeActivity({
|
||||
...current,
|
||||
...payload,
|
||||
id: current.id,
|
||||
type: current.type,
|
||||
status,
|
||||
outcome,
|
||||
canCancel: false,
|
||||
canNextStep: false,
|
||||
finishedAt,
|
||||
durationMs
|
||||
});
|
||||
this.active.delete(id);
|
||||
this.controls.delete(id);
|
||||
this.recent.unshift(finalized);
|
||||
if (this.recent.length > MAX_RECENT_ACTIVITIES) {
|
||||
this.recent = this.recent.slice(0, MAX_RECENT_ACTIVITIES);
|
||||
}
|
||||
this.broadcastSnapshot();
|
||||
return finalized;
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
clearRecent() {
|
||||
const removed = this.recent.length;
|
||||
if (removed === 0) {
|
||||
return { removed: 0, snapshot: this.buildSnapshot() };
|
||||
}
|
||||
this.recent = [];
|
||||
this.broadcastSnapshot();
|
||||
return {
|
||||
removed,
|
||||
snapshot: this.buildSnapshot()
|
||||
};
|
||||
}
|
||||
|
||||
setControls(activityId, handlers = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const safeHandlers = {
|
||||
cancel: typeof handlers?.cancel === 'function' ? handlers.cancel : null,
|
||||
nextStep: typeof handlers?.nextStep === 'function' ? handlers.nextStep : null
|
||||
};
|
||||
this.controls.set(id, safeHandlers);
|
||||
return this.updateActivity(id, {
|
||||
canCancel: Boolean(safeHandlers.cancel),
|
||||
canNextStep: Boolean(safeHandlers.nextStep)
|
||||
});
|
||||
}
|
||||
|
||||
async invokeControl(activityId, control, payload = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Aktivität nicht gefunden oder bereits abgeschlossen.'
|
||||
};
|
||||
}
|
||||
const handlers = this.controls.get(id) || {};
|
||||
const key = control === 'nextStep' ? 'nextStep' : 'cancel';
|
||||
const fn = handlers[key];
|
||||
if (typeof fn !== 'function') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'UNSUPPORTED',
|
||||
message: key === 'nextStep'
|
||||
? 'Nächster-Schritt ist für diese Aktivität nicht verfügbar.'
|
||||
: 'Abbrechen ist für diese Aktivität nicht verfügbar.'
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await fn(payload);
|
||||
return {
|
||||
ok: true,
|
||||
code: 'OK',
|
||||
result: result && typeof result === 'object' ? result : null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'FAILED',
|
||||
message: error?.message || 'Aktion fehlgeschlagen.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async requestCancel(activityId, payload = {}) {
|
||||
return this.invokeControl(activityId, 'cancel', payload);
|
||||
}
|
||||
|
||||
async requestNextStep(activityId, payload = {}) {
|
||||
return this.invokeControl(activityId, 'nextStep', payload);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RuntimeActivityService();
|
||||
@@ -0,0 +1,927 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SCRIPT_CHAINS');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const CHAIN_NAME_MAX_LENGTH = 120;
|
||||
const STEP_TYPE_SCRIPT = 'script';
|
||||
const STEP_TYPE_WAIT = 'wait';
|
||||
const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]);
|
||||
|
||||
function normalizeChainId(rawValue) {
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
function createValidationError(message, details = null) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
if (details) {
|
||||
error.details = details;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function mapChainRow(row, steps = []) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
orderIndex: Number(row.order_index || 0),
|
||||
steps: steps.map(mapStepRow),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function mapStepRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: Number(row.id),
|
||||
position: Number(row.position),
|
||||
stepType: String(row.step_type || ''),
|
||||
scriptId: row.script_id != null ? Number(row.script_id) : null,
|
||||
scriptName: row.script_name != null ? String(row.script_name) : null,
|
||||
waitSeconds: row.wait_seconds != null ? Number(row.wait_seconds) : null
|
||||
};
|
||||
}
|
||||
|
||||
function terminateChildProcess(child, { immediate = false } = {}) {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
const signal = immediate ? 'SIGKILL' : 'SIGTERM';
|
||||
const pid = Number(child.pid);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
// For detached children this targets the full process group.
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch (_error) {
|
||||
// Fall through to direct child signal.
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function appendTailText(currentValue, nextChunk, maxChars = 12000) {
|
||||
const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
if (!chunk) {
|
||||
return {
|
||||
value: currentValue || '',
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`;
|
||||
const combined = `${String(currentValue || '')}${normalizedChunk}`;
|
||||
if (combined.length <= maxChars) {
|
||||
return {
|
||||
value: combined,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: combined.slice(-maxChars),
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function validateSteps(rawSteps) {
|
||||
const steps = Array.isArray(rawSteps) ? rawSteps : [];
|
||||
const errors = [];
|
||||
const normalized = [];
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i] && typeof steps[i] === 'object' ? steps[i] : {};
|
||||
const stepType = String(step.stepType || step.step_type || '').trim();
|
||||
|
||||
if (!VALID_STEP_TYPES.has(stepType)) {
|
||||
errors.push({ field: `steps[${i}].stepType`, message: `Ungültiger Schritt-Typ: '${stepType}'. Erlaubt: script, wait.` });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stepType === STEP_TYPE_SCRIPT) {
|
||||
const scriptId = Number(step.scriptId ?? step.script_id);
|
||||
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||||
errors.push({ field: `steps[${i}].scriptId`, message: 'scriptId fehlt oder ist ungültig.' });
|
||||
continue;
|
||||
}
|
||||
normalized.push({ stepType, scriptId: Math.trunc(scriptId), waitSeconds: null });
|
||||
} else if (stepType === STEP_TYPE_WAIT) {
|
||||
const waitSeconds = Number(step.waitSeconds ?? step.wait_seconds);
|
||||
if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) {
|
||||
errors.push({ field: `steps[${i}].waitSeconds`, message: 'waitSeconds muss zwischen 1 und 3600 liegen.' });
|
||||
continue;
|
||||
}
|
||||
normalized.push({ stepType, scriptId: null, waitSeconds: Math.round(waitSeconds) });
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw createValidationError('Ungültige Schritte in der Skriptkette.', errors);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function getStepsForChain(db, chainId) {
|
||||
return db.all(
|
||||
`
|
||||
SELECT
|
||||
s.id,
|
||||
s.chain_id,
|
||||
s.position,
|
||||
s.step_type,
|
||||
s.script_id,
|
||||
s.wait_seconds,
|
||||
sc.name AS script_name
|
||||
FROM script_chain_steps s
|
||||
LEFT JOIN scripts sc ON sc.id = s.script_id
|
||||
WHERE s.chain_id = ?
|
||||
ORDER BY s.position ASC, s.id ASC
|
||||
`,
|
||||
[chainId]
|
||||
);
|
||||
}
|
||||
|
||||
class ScriptChainService {
|
||||
async listChains() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, order_index, created_at, updated_at
|
||||
FROM script_chains
|
||||
ORDER BY order_index ASC, id ASC
|
||||
`
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chainIds = rows.map((row) => Number(row.id));
|
||||
const placeholders = chainIds.map(() => '?').join(', ');
|
||||
const stepRows = await db.all(
|
||||
`
|
||||
SELECT
|
||||
s.id,
|
||||
s.chain_id,
|
||||
s.position,
|
||||
s.step_type,
|
||||
s.script_id,
|
||||
s.wait_seconds,
|
||||
sc.name AS script_name
|
||||
FROM script_chain_steps s
|
||||
LEFT JOIN scripts sc ON sc.id = s.script_id
|
||||
WHERE s.chain_id IN (${placeholders})
|
||||
ORDER BY s.chain_id ASC, s.position ASC, s.id ASC
|
||||
`,
|
||||
chainIds
|
||||
);
|
||||
|
||||
const stepsByChain = new Map();
|
||||
for (const step of stepRows) {
|
||||
const cid = Number(step.chain_id);
|
||||
if (!stepsByChain.has(cid)) {
|
||||
stepsByChain.set(cid, []);
|
||||
}
|
||||
stepsByChain.get(cid).push(step);
|
||||
}
|
||||
|
||||
return rows.map((row) => mapChainRow(row, stepsByChain.get(Number(row.id)) || []));
|
||||
}
|
||||
|
||||
async getChainById(chainId) {
|
||||
const normalizedId = normalizeChainId(chainId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige chainId.');
|
||||
}
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`,
|
||||
[normalizedId]
|
||||
);
|
||||
if (!row) {
|
||||
const error = new Error(`Skriptkette #${normalizedId} wurde nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const steps = await getStepsForChain(db, normalizedId);
|
||||
return mapChainRow(row, steps);
|
||||
}
|
||||
|
||||
async getChainsByIds(rawIds = []) {
|
||||
const ids = Array.isArray(rawIds)
|
||||
? rawIds.map(normalizeChainId).filter(Boolean)
|
||||
: [];
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const db = await getDb();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`,
|
||||
ids
|
||||
);
|
||||
const stepRows = await db.all(
|
||||
`
|
||||
SELECT
|
||||
s.id, s.chain_id, s.position, s.step_type, s.script_id, s.wait_seconds,
|
||||
sc.name AS script_name
|
||||
FROM script_chain_steps s
|
||||
LEFT JOIN scripts sc ON sc.id = s.script_id
|
||||
WHERE s.chain_id IN (${placeholders})
|
||||
ORDER BY s.chain_id ASC, s.position ASC, s.id ASC
|
||||
`,
|
||||
ids
|
||||
);
|
||||
const stepsByChain = new Map();
|
||||
for (const step of stepRows) {
|
||||
const cid = Number(step.chain_id);
|
||||
if (!stepsByChain.has(cid)) {
|
||||
stepsByChain.set(cid, []);
|
||||
}
|
||||
stepsByChain.get(cid).push(step);
|
||||
}
|
||||
const byId = new Map(rows.map((row) => [
|
||||
Number(row.id),
|
||||
mapChainRow(row, stepsByChain.get(Number(row.id)) || [])
|
||||
]));
|
||||
return ids.map((id) => byId.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
async createChain(payload = {}) {
|
||||
const body = payload && typeof payload === 'object' ? payload : {};
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) {
|
||||
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
||||
}
|
||||
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
||||
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
|
||||
}
|
||||
const steps = validateSteps(body.steps);
|
||||
|
||||
const db = await getDb();
|
||||
try {
|
||||
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||
const result = await db.run(
|
||||
`
|
||||
INSERT INTO script_chains (name, order_index, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
[name, nextOrderIndex]
|
||||
);
|
||||
const chainId = result.lastID;
|
||||
await this._saveSteps(db, chainId, steps);
|
||||
return this.getChainById(chainId);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||
throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateChain(chainId, payload = {}) {
|
||||
const normalizedId = normalizeChainId(chainId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige chainId.');
|
||||
}
|
||||
const body = payload && typeof payload === 'object' ? payload : {};
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) {
|
||||
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
||||
}
|
||||
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
||||
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
|
||||
}
|
||||
const steps = validateSteps(body.steps);
|
||||
|
||||
await this.getChainById(normalizedId);
|
||||
|
||||
const db = await getDb();
|
||||
try {
|
||||
await db.run(
|
||||
`UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[name, normalizedId]
|
||||
);
|
||||
await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]);
|
||||
await this._saveSteps(db, normalizedId, steps);
|
||||
return this.getChainById(normalizedId);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||
throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChain(chainId) {
|
||||
const normalizedId = normalizeChainId(chainId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige chainId.');
|
||||
}
|
||||
const existing = await this.getChainById(normalizedId);
|
||||
const db = await getDb();
|
||||
await db.run(`DELETE FROM script_chains WHERE id = ?`, [normalizedId]);
|
||||
return existing;
|
||||
}
|
||||
|
||||
async reorderChains(orderedIds = []) {
|
||||
const providedIds = Array.isArray(orderedIds)
|
||||
? orderedIds.map(normalizeChainId).filter(Boolean)
|
||||
: [];
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id
|
||||
FROM script_chains
|
||||
ORDER BY order_index ASC, id ASC
|
||||
`
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0);
|
||||
const existingSet = new Set(existingIds);
|
||||
const used = new Set();
|
||||
const nextOrder = [];
|
||||
|
||||
for (const id of providedIds) {
|
||||
if (!existingSet.has(id) || used.has(id)) {
|
||||
continue;
|
||||
}
|
||||
used.add(id);
|
||||
nextOrder.push(id);
|
||||
}
|
||||
|
||||
for (const id of existingIds) {
|
||||
if (used.has(id)) {
|
||||
continue;
|
||||
}
|
||||
used.add(id);
|
||||
nextOrder.push(id);
|
||||
}
|
||||
|
||||
await db.exec('BEGIN');
|
||||
try {
|
||||
for (let i = 0; i < nextOrder.length; i += 1) {
|
||||
await db.run(
|
||||
`
|
||||
UPDATE script_chains
|
||||
SET order_index = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[i + 1, nextOrder[i]]
|
||||
);
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.listChains();
|
||||
}
|
||||
|
||||
async _getNextOrderIndex(db) {
|
||||
const row = await db.get(
|
||||
`
|
||||
SELECT COALESCE(MAX(order_index), 0) AS max_order_index
|
||||
FROM script_chains
|
||||
`
|
||||
);
|
||||
const maxOrder = Number(row?.max_order_index || 0);
|
||||
if (!Number.isFinite(maxOrder) || maxOrder < 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.trunc(maxOrder) + 1;
|
||||
}
|
||||
|
||||
async _saveSteps(db, chainId, steps) {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO script_chain_steps (chain_id, position, step_type, script_id, wait_seconds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
[chainId, i + 1, step.stepType, step.scriptId ?? null, step.waitSeconds ?? null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async executeChain(chainId, context = {}, { appendLog = null } = {}) {
|
||||
const chain = await this.getChainById(chainId);
|
||||
logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length });
|
||||
const totalSteps = chain.steps.length;
|
||||
const activityId = runtimeActivityService.startActivity('chain', {
|
||||
name: chain.name,
|
||||
source: context?.source || 'chain',
|
||||
chainId: chain.id,
|
||||
jobId: context?.jobId || null,
|
||||
cronJobId: context?.cronJobId || null,
|
||||
parentActivityId: context?.runtimeParentActivityId || null,
|
||||
currentStep: totalSteps > 0 ? `Schritt 1/${totalSteps}` : 'Keine Schritte'
|
||||
});
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
cancelReason: null,
|
||||
currentStepType: null,
|
||||
activeWaitResolve: null,
|
||||
activeChild: null,
|
||||
activeChildTermination: null
|
||||
};
|
||||
const emitRuntimeStep = (payload = {}) => {
|
||||
if (typeof context?.onRuntimeStep !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.onRuntimeStep({
|
||||
chainId: chain.id,
|
||||
chainName: chain.name,
|
||||
...payload
|
||||
});
|
||||
} catch (_error) {
|
||||
// ignore runtime callback errors
|
||||
}
|
||||
};
|
||||
const requestCancel = async (payload = {}) => {
|
||||
if (controlState.cancelRequested) {
|
||||
return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' };
|
||||
}
|
||||
controlState.cancelRequested = true;
|
||||
controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen';
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Abbruch angefordert',
|
||||
currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures for control actions
|
||||
}
|
||||
}
|
||||
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
|
||||
controlState.activeWaitResolve('cancel');
|
||||
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
|
||||
controlState.activeChildTermination = 'cancel';
|
||||
terminateChildProcess(controlState.activeChild, { immediate: true });
|
||||
}
|
||||
return { accepted: true, message: 'Abbruch angefordert.' };
|
||||
};
|
||||
const requestNextStep = async () => {
|
||||
if (controlState.cancelRequested) {
|
||||
return { accepted: false, message: 'Kette wird bereits abgebrochen.' };
|
||||
}
|
||||
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
|
||||
controlState.activeWaitResolve('skip');
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Nächster Schritt angefordert (Wait übersprungen)'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Wait-Schritt manuell übersprungen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures for control actions
|
||||
}
|
||||
}
|
||||
return { accepted: true, message: 'Wait-Schritt übersprungen.' };
|
||||
}
|
||||
if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
|
||||
controlState.activeChildTermination = 'skip';
|
||||
terminateChildProcess(controlState.activeChild, { immediate: true });
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript-Schritt manuell übersprungen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures for control actions
|
||||
}
|
||||
}
|
||||
return { accepted: true, message: 'Skript-Schritt wird übersprungen.' };
|
||||
}
|
||||
return { accepted: false, message: 'Kein aktiver Schritt zum Überspringen.' };
|
||||
};
|
||||
runtimeActivityService.setControls(activityId, {
|
||||
cancel: requestCancel,
|
||||
nextStep: requestNextStep
|
||||
});
|
||||
|
||||
const results = [];
|
||||
let completionPayload = null;
|
||||
let abortedByUser = false;
|
||||
try {
|
||||
for (let index = 0; index < chain.steps.length; index += 1) {
|
||||
if (controlState.cancelRequested) {
|
||||
abortedByUser = true;
|
||||
break;
|
||||
}
|
||||
const step = chain.steps[index];
|
||||
const stepIndex = index + 1;
|
||||
if (step.stepType === STEP_TYPE_WAIT) {
|
||||
const seconds = Math.max(1, Number(step.waitSeconds || 1));
|
||||
const waitLabel = `Warte ${seconds} Sekunde(n)`;
|
||||
controlState.currentStepType = STEP_TYPE_WAIT;
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
currentStepType: 'wait',
|
||||
currentStep: waitLabel,
|
||||
currentScriptName: null,
|
||||
stepIndex,
|
||||
stepTotal: totalSteps
|
||||
});
|
||||
emitRuntimeStep({
|
||||
stepType: 'wait',
|
||||
stepIndex,
|
||||
stepTotal: totalSteps,
|
||||
currentStep: waitLabel
|
||||
});
|
||||
logger.info('chain:step:wait', { chainId, seconds });
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`);
|
||||
}
|
||||
const waitOutcome = await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
controlState.activeWaitResolve = null;
|
||||
resolve('done');
|
||||
}, seconds * 1000);
|
||||
controlState.activeWaitResolve = (mode = 'done') => {
|
||||
clearTimeout(timer);
|
||||
controlState.activeWaitResolve = null;
|
||||
resolve(mode);
|
||||
};
|
||||
});
|
||||
controlState.currentStepType = null;
|
||||
if (waitOutcome === 'skip') {
|
||||
results.push({ stepType: 'wait', waitSeconds: seconds, success: true, skipped: true, reason: 'skipped_by_user' });
|
||||
continue;
|
||||
}
|
||||
if (waitOutcome === 'cancel' || controlState.cancelRequested) {
|
||||
abortedByUser = true;
|
||||
results.push({ stepType: 'wait', waitSeconds: seconds, success: false, aborted: true, reason: 'cancelled_by_user' });
|
||||
break;
|
||||
}
|
||||
results.push({ stepType: 'wait', waitSeconds: seconds, success: true });
|
||||
} else if (step.stepType === STEP_TYPE_SCRIPT) {
|
||||
if (!step.scriptId) {
|
||||
logger.warn('chain:step:script-missing', { chainId, stepId: step.id });
|
||||
results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const scriptService = require('./scriptService');
|
||||
let script;
|
||||
try {
|
||||
script = await scriptService.getScriptById(step.scriptId);
|
||||
} catch (error) {
|
||||
logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
||||
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' });
|
||||
continue;
|
||||
}
|
||||
|
||||
controlState.currentStepType = STEP_TYPE_SCRIPT;
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
currentStepType: 'script',
|
||||
currentStep: `Skript: ${script.name}`,
|
||||
currentScriptName: script.name,
|
||||
stepIndex,
|
||||
stepTotal: totalSteps,
|
||||
scriptId: script.id
|
||||
});
|
||||
emitRuntimeStep({
|
||||
stepType: 'script',
|
||||
stepIndex,
|
||||
stepTotal: totalSteps,
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
currentScriptName: script.name,
|
||||
currentStep: `Skript: ${script.name}`
|
||||
});
|
||||
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`);
|
||||
}
|
||||
|
||||
const scriptActivityId = runtimeActivityService.startActivity('script', {
|
||||
name: script.name,
|
||||
source: context?.source || 'chain',
|
||||
scriptId: script.id,
|
||||
chainId: chain.id,
|
||||
jobId: context?.jobId || null,
|
||||
cronJobId: context?.cronJobId || null,
|
||||
parentActivityId: activityId,
|
||||
currentStep: `Kette: ${chain.name}`
|
||||
});
|
||||
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, {
|
||||
...context,
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
source: context?.source || 'chain'
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
const processHandle = spawnTrackedProcess({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
context: { source: context?.source || 'chain', chainId: chain.id, scriptId: script.id },
|
||||
onStart: (child) => {
|
||||
controlState.activeChild = child;
|
||||
controlState.activeChildTermination = null;
|
||||
},
|
||||
onStdoutLine: (line) => {
|
||||
const next = appendTailText(stdout, line);
|
||||
stdout = next.value;
|
||||
stdoutTruncated = stdoutTruncated || next.truncated;
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
const next = appendTailText(stderr, line);
|
||||
stderr = next.value;
|
||||
stderrTruncated = stderrTruncated || next.truncated;
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
let runError = null;
|
||||
let exitCode = 0;
|
||||
let signal = null;
|
||||
try {
|
||||
const result = await processHandle.promise;
|
||||
exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
|
||||
signal = result?.signal || null;
|
||||
} catch (error) {
|
||||
runError = error;
|
||||
exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
|
||||
signal = error?.signal || null;
|
||||
}
|
||||
const termination = controlState.activeChildTermination;
|
||||
controlState.activeChild = null;
|
||||
controlState.activeChildTermination = null;
|
||||
if (runError && exitCode === null && !termination) {
|
||||
throw runError;
|
||||
}
|
||||
const run = {
|
||||
code: exitCode,
|
||||
signal,
|
||||
stdout,
|
||||
stderr,
|
||||
stdoutTruncated,
|
||||
stderrTruncated,
|
||||
termination
|
||||
};
|
||||
controlState.currentStepType = null;
|
||||
|
||||
if (run.termination === 'skip') {
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'success',
|
||||
success: true,
|
||||
outcome: 'skipped',
|
||||
skipped: true,
|
||||
currentStep: null,
|
||||
message: 'Schritt übersprungen',
|
||||
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
|
||||
stdout: run.stdout || null,
|
||||
stderr: run.stderr || null,
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated)
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" übersprungen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures on skip path
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
stepType: 'script',
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'skipped_by_user'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (run.termination === 'cancel' || controlState.cancelRequested) {
|
||||
abortedByUser = true;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'cancelled',
|
||||
cancelled: true,
|
||||
currentStep: null,
|
||||
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
|
||||
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
|
||||
stdout: run.stdout || null,
|
||||
stderr: run.stderr || null,
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated),
|
||||
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures on cancel path
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
stepType: 'script',
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success: false,
|
||||
aborted: true,
|
||||
reason: 'cancelled_by_user'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const success = run.code === 0;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode: run.code,
|
||||
currentStep: null,
|
||||
message: success ? null : `Fehler (Exit ${run.code})`,
|
||||
output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
|
||||
stderr: success ? null : (run.stderr || null),
|
||||
stdout: success ? null : (run.stdout || null),
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated),
|
||||
errorMessage: success ? null : `Fehler (Exit ${run.code})`
|
||||
});
|
||||
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog(
|
||||
success ? 'SYSTEM' : 'ERROR',
|
||||
`Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}`
|
||||
);
|
||||
}
|
||||
results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' });
|
||||
|
||||
if (!success) {
|
||||
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
controlState.currentStepType = null;
|
||||
if (controlState.cancelRequested) {
|
||||
abortedByUser = true;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'cancelled',
|
||||
cancelled: true,
|
||||
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
|
||||
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures on cancel path
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
stepType: 'script',
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success: false,
|
||||
aborted: true,
|
||||
reason: 'cancelled_by_user'
|
||||
});
|
||||
break;
|
||||
}
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: error?.message || 'unknown',
|
||||
errorMessage: error?.message || 'unknown'
|
||||
});
|
||||
logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`);
|
||||
}
|
||||
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message });
|
||||
break;
|
||||
} finally {
|
||||
controlState.activeChild = null;
|
||||
controlState.activeChildTermination = null;
|
||||
if (prepared?.cleanup) {
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const skipped = results.filter((r) => r.skipped).length;
|
||||
const failed = results.filter((r) => !r.success && !r.skipped && !r.aborted).length;
|
||||
logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed, skipped, abortedByUser });
|
||||
if (abortedByUser) {
|
||||
completionPayload = {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'cancelled',
|
||||
cancelled: true,
|
||||
currentStep: null,
|
||||
currentScriptName: null,
|
||||
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
|
||||
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
|
||||
};
|
||||
emitRuntimeStep({
|
||||
finished: true,
|
||||
success: false,
|
||||
aborted: true,
|
||||
failed,
|
||||
succeeded
|
||||
});
|
||||
return {
|
||||
chainId,
|
||||
chainName: chain.name,
|
||||
steps: results.length,
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
aborted: true,
|
||||
abortedByUser: true,
|
||||
results
|
||||
};
|
||||
}
|
||||
completionPayload = {
|
||||
status: failed > 0 ? 'error' : 'success',
|
||||
success: failed === 0,
|
||||
outcome: failed > 0 ? 'error' : (skipped > 0 ? 'skipped' : 'success'),
|
||||
skipped: skipped > 0,
|
||||
currentStep: null,
|
||||
currentScriptName: null,
|
||||
message: failed > 0
|
||||
? `${failed} Schritt(e) fehlgeschlagen`
|
||||
: (skipped > 0
|
||||
? `${succeeded} Schritt(e) erfolgreich, ${skipped} übersprungen`
|
||||
: `${succeeded} Schritt(e) erfolgreich`)
|
||||
};
|
||||
emitRuntimeStep({
|
||||
finished: true,
|
||||
success: failed === 0,
|
||||
failed,
|
||||
succeeded
|
||||
});
|
||||
|
||||
return {
|
||||
chainId,
|
||||
chainName: chain.name,
|
||||
steps: results.length,
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
aborted: failed > 0,
|
||||
results
|
||||
};
|
||||
} catch (error) {
|
||||
completionPayload = {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: error?.message || 'unknown',
|
||||
errorMessage: error?.message || 'unknown',
|
||||
currentStep: null
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
runtimeActivityService.completeActivity(activityId, completionPayload || {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: 'Kette unerwartet beendet',
|
||||
errorMessage: 'Kette unerwartet beendet',
|
||||
currentStep: null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ScriptChainService();
|
||||
@@ -0,0 +1,688 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SCRIPTS');
|
||||
const settingsService = require('./settingsService');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { streamLines } = require('./processRunner');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const SCRIPT_NAME_MAX_LENGTH = 120;
|
||||
const SCRIPT_BODY_MAX_LENGTH = 200000;
|
||||
const SCRIPT_TEST_TIMEOUT_SETTING_KEY = 'script_test_timeout_ms';
|
||||
const DEFAULT_SCRIPT_TEST_TIMEOUT_MS = 0;
|
||||
const SCRIPT_TEST_TIMEOUT_MS = (() => {
|
||||
const parsed = Number(process.env.RIPSTER_SCRIPT_TEST_TIMEOUT_MS);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.max(0, Math.trunc(parsed));
|
||||
}
|
||||
return DEFAULT_SCRIPT_TEST_TIMEOUT_MS;
|
||||
})();
|
||||
const SCRIPT_OUTPUT_MAX_CHARS = 150000;
|
||||
|
||||
function normalizeScriptTestTimeoutMs(rawValue, fallbackMs = SCRIPT_TEST_TIMEOUT_MS) {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.max(0, Math.trunc(parsed));
|
||||
}
|
||||
if (fallbackMs === null || fallbackMs === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsedFallback = Number(fallbackMs);
|
||||
if (Number.isFinite(parsedFallback)) {
|
||||
return Math.max(0, Math.trunc(parsedFallback));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeScriptId(rawValue) {
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
function normalizeScriptIdList(rawList) {
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const item of list) {
|
||||
const normalized = normalizeScriptId(item);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeScriptName(rawValue) {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
|
||||
function normalizeScriptBody(rawValue) {
|
||||
return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
}
|
||||
|
||||
function createValidationError(message, details = null) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
if (details) {
|
||||
error.details = details;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function validateScriptPayload(payload, { partial = false } = {}) {
|
||||
const body = payload && typeof payload === 'object' ? payload : {};
|
||||
const hasName = Object.prototype.hasOwnProperty.call(body, 'name');
|
||||
const hasScriptBody = Object.prototype.hasOwnProperty.call(body, 'scriptBody');
|
||||
const normalized = {};
|
||||
const errors = [];
|
||||
|
||||
if (!partial || hasName) {
|
||||
const name = normalizeScriptName(body.name);
|
||||
if (!name) {
|
||||
errors.push({ field: 'name', message: 'Name darf nicht leer sein.' });
|
||||
} else if (name.length > SCRIPT_NAME_MAX_LENGTH) {
|
||||
errors.push({ field: 'name', message: `Name darf maximal ${SCRIPT_NAME_MAX_LENGTH} Zeichen enthalten.` });
|
||||
} else {
|
||||
normalized.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!partial || hasScriptBody) {
|
||||
const scriptBody = normalizeScriptBody(body.scriptBody);
|
||||
if (!scriptBody.trim()) {
|
||||
errors.push({ field: 'scriptBody', message: 'Skript darf nicht leer sein.' });
|
||||
} else if (scriptBody.length > SCRIPT_BODY_MAX_LENGTH) {
|
||||
errors.push({ field: 'scriptBody', message: `Skript darf maximal ${SCRIPT_BODY_MAX_LENGTH} Zeichen enthalten.` });
|
||||
} else {
|
||||
normalized.scriptBody = scriptBody;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw createValidationError('Skript ist ungültig.', errors);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapScriptRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
scriptBody: String(row.script_body || ''),
|
||||
orderIndex: Number(row.order_index || 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function quoteForBashSingle(value) {
|
||||
return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function buildScriptEnvironment(context = {}) {
|
||||
const now = new Date().toISOString();
|
||||
const entries = {
|
||||
RIPSTER_SCRIPT_RUN_AT: now,
|
||||
RIPSTER_JOB_ID: context?.jobId ?? '',
|
||||
RIPSTER_JOB_TITLE: context?.jobTitle ?? '',
|
||||
RIPSTER_MODE: context?.mode ?? '',
|
||||
RIPSTER_INPUT_PATH: context?.inputPath ?? '',
|
||||
RIPSTER_OUTPUT_PATH: context?.outputPath ?? '',
|
||||
RIPSTER_RAW_PATH: context?.rawPath ?? '',
|
||||
RIPSTER_SCRIPT_ID: context?.scriptId ?? '',
|
||||
RIPSTER_SCRIPT_NAME: context?.scriptName ?? '',
|
||||
RIPSTER_SCRIPT_SOURCE: context?.source ?? ''
|
||||
};
|
||||
|
||||
const output = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
output[key] = String(value ?? '');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildScriptWrapper(scriptBody, context = {}) {
|
||||
const envVars = buildScriptEnvironment(context);
|
||||
const exportLines = Object.entries(envVars)
|
||||
.map(([key, value]) => `export ${key}=${quoteForBashSingle(value)}`)
|
||||
.join('\n');
|
||||
// Wait for potential background jobs started by the script before returning.
|
||||
return `${exportLines}\n\n${String(scriptBody || '')}\n\nwait\n`;
|
||||
}
|
||||
|
||||
function appendWithCap(current, chunk, maxChars) {
|
||||
const value = String(chunk || '');
|
||||
if (!value) {
|
||||
return { value: current, truncated: false };
|
||||
}
|
||||
const currentText = String(current || '');
|
||||
if (currentText.length >= maxChars) {
|
||||
return { value: currentText, truncated: true };
|
||||
}
|
||||
const available = maxChars - currentText.length;
|
||||
if (value.length <= available) {
|
||||
return { value: `${currentText}${value}`, truncated: false };
|
||||
}
|
||||
return {
|
||||
value: `${currentText}${value.slice(0, available)}`,
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function killChildProcessTree(child, signal = 'SIGTERM') {
|
||||
if (!child) {
|
||||
return false;
|
||||
}
|
||||
const pid = Number(child.pid);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
// If spawned as detached=true this targets the full process group.
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
// Fallback below.
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runProcessCapture({
|
||||
cmd,
|
||||
args,
|
||||
timeoutMs = SCRIPT_TEST_TIMEOUT_MS,
|
||||
cwd = process.cwd(),
|
||||
onChild = null,
|
||||
onStdoutLine = null,
|
||||
onStderrLine = null
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const effectiveTimeoutMs = normalizeScriptTestTimeoutMs(timeoutMs, SCRIPT_TEST_TIMEOUT_MS);
|
||||
const startedAt = Date.now();
|
||||
let ended = false;
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true
|
||||
});
|
||||
if (typeof onChild === 'function') {
|
||||
try {
|
||||
onChild(child);
|
||||
} catch (_error) {
|
||||
// ignore observer errors
|
||||
}
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
let timedOut = false;
|
||||
|
||||
let timeout = null;
|
||||
if (effectiveTimeoutMs > 0) {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killChildProcessTree(child, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!ended) {
|
||||
killChildProcessTree(child, 'SIGKILL');
|
||||
}
|
||||
}, 2000);
|
||||
}, effectiveTimeoutMs);
|
||||
}
|
||||
|
||||
const onData = (streamName, chunk) => {
|
||||
if (streamName === 'stdout') {
|
||||
const next = appendWithCap(stdout, chunk, SCRIPT_OUTPUT_MAX_CHARS);
|
||||
stdout = next.value;
|
||||
stdoutTruncated = stdoutTruncated || next.truncated;
|
||||
} else {
|
||||
const next = appendWithCap(stderr, chunk, SCRIPT_OUTPUT_MAX_CHARS);
|
||||
stderr = next.value;
|
||||
stderrTruncated = stderrTruncated || next.truncated;
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (chunk) => onData('stdout', chunk));
|
||||
child.stderr?.on('data', (chunk) => onData('stderr', chunk));
|
||||
|
||||
if (child.stdout && typeof onStdoutLine === 'function') {
|
||||
streamLines(child.stdout, onStdoutLine);
|
||||
}
|
||||
if (child.stderr && typeof onStderrLine === 'function') {
|
||||
streamLines(child.stderr, onStderrLine);
|
||||
}
|
||||
|
||||
child.on('error', (error) => {
|
||||
ended = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
ended = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const endedAt = Date.now();
|
||||
resolve({
|
||||
code: Number.isFinite(Number(code)) ? Number(code) : null,
|
||||
signal: signal || null,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
stdoutTruncated,
|
||||
stderrTruncated
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveScriptTestTimeoutMs(options = {}) {
|
||||
const timeoutFromOptions = normalizeScriptTestTimeoutMs(options?.timeoutMs, null);
|
||||
if (timeoutFromOptions !== null) {
|
||||
return timeoutFromOptions;
|
||||
}
|
||||
try {
|
||||
const settingsMap = await settingsService.getSettingsMap();
|
||||
return normalizeScriptTestTimeoutMs(
|
||||
settingsMap?.[SCRIPT_TEST_TIMEOUT_SETTING_KEY],
|
||||
SCRIPT_TEST_TIMEOUT_MS
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('script:test-timeout:settings-read-failed', { error: errorToMeta(error) });
|
||||
return SCRIPT_TEST_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptService {
|
||||
async listScripts() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, script_body, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
ORDER BY order_index ASC, id ASC
|
||||
`
|
||||
);
|
||||
return rows.map(mapScriptRow);
|
||||
}
|
||||
|
||||
async getScriptById(scriptId) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`
|
||||
SELECT id, name, script_body, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
WHERE id = ?
|
||||
`,
|
||||
[normalizedId]
|
||||
);
|
||||
if (!row) {
|
||||
const error = new Error(`Skript #${normalizedId} wurde nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return mapScriptRow(row);
|
||||
}
|
||||
|
||||
async createScript(payload = {}) {
|
||||
const normalized = validateScriptPayload(payload, { partial: false });
|
||||
const db = await getDb();
|
||||
try {
|
||||
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||
const result = await db.run(
|
||||
`
|
||||
INSERT INTO scripts (name, script_body, order_index, created_at, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
[normalized.name, normalized.scriptBody, nextOrderIndex]
|
||||
);
|
||||
return this.getScriptById(result.lastID);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
|
||||
{ field: 'name', message: 'Name muss eindeutig sein.' }
|
||||
]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateScript(scriptId, payload = {}) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const normalized = validateScriptPayload(payload, { partial: false });
|
||||
const db = await getDb();
|
||||
await this.getScriptById(normalizedId);
|
||||
try {
|
||||
await db.run(
|
||||
`
|
||||
UPDATE scripts
|
||||
SET
|
||||
name = ?,
|
||||
script_body = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[normalized.name, normalized.scriptBody, normalizedId]
|
||||
);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
|
||||
{ field: 'name', message: 'Name muss eindeutig sein.' }
|
||||
]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.getScriptById(normalizedId);
|
||||
}
|
||||
|
||||
async deleteScript(scriptId) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const db = await getDb();
|
||||
const existing = await this.getScriptById(normalizedId);
|
||||
await db.run('DELETE FROM scripts WHERE id = ?', [normalizedId]);
|
||||
return existing;
|
||||
}
|
||||
|
||||
async getScriptsByIds(rawIds = []) {
|
||||
const ids = normalizeScriptIdList(rawIds);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const db = await getDb();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, script_body, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
WHERE id IN (${placeholders})
|
||||
`,
|
||||
ids
|
||||
);
|
||||
const byId = new Map(rows.map((row) => [Number(row.id), mapScriptRow(row)]));
|
||||
return ids.map((id) => byId.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
async resolveScriptsByIds(rawIds = [], options = {}) {
|
||||
const ids = normalizeScriptIdList(rawIds);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const strict = options?.strict !== false;
|
||||
const scripts = await this.getScriptsByIds(ids);
|
||||
if (!strict) {
|
||||
return scripts;
|
||||
}
|
||||
const foundIds = new Set(scripts.map((item) => Number(item.id)));
|
||||
const missing = ids.filter((id) => !foundIds.has(Number(id)));
|
||||
if (missing.length > 0) {
|
||||
throw createValidationError(`Skript(e) nicht gefunden: ${missing.join(', ')}`, [
|
||||
{ field: 'selectedPostEncodeScriptIds', message: `Nicht gefunden: ${missing.join(', ')}` }
|
||||
]);
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
|
||||
async reorderScripts(orderedIds = []) {
|
||||
const db = await getDb();
|
||||
const providedIds = normalizeScriptIdList(orderedIds);
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id
|
||||
FROM scripts
|
||||
ORDER BY order_index ASC, id ASC
|
||||
`
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0);
|
||||
const existingSet = new Set(existingIds);
|
||||
const used = new Set();
|
||||
const nextOrder = [];
|
||||
|
||||
for (const id of providedIds) {
|
||||
if (!existingSet.has(id) || used.has(id)) {
|
||||
continue;
|
||||
}
|
||||
used.add(id);
|
||||
nextOrder.push(id);
|
||||
}
|
||||
|
||||
for (const id of existingIds) {
|
||||
if (used.has(id)) {
|
||||
continue;
|
||||
}
|
||||
used.add(id);
|
||||
nextOrder.push(id);
|
||||
}
|
||||
|
||||
await db.exec('BEGIN');
|
||||
try {
|
||||
for (let i = 0; i < nextOrder.length; i += 1) {
|
||||
await db.run(
|
||||
`
|
||||
UPDATE scripts
|
||||
SET order_index = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[i + 1, nextOrder[i]]
|
||||
);
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.listScripts();
|
||||
}
|
||||
|
||||
async _getNextOrderIndex(db) {
|
||||
const row = await db.get(
|
||||
`
|
||||
SELECT COALESCE(MAX(order_index), 0) AS max_order_index
|
||||
FROM scripts
|
||||
`
|
||||
);
|
||||
const maxOrder = Number(row?.max_order_index || 0);
|
||||
if (!Number.isFinite(maxOrder) || maxOrder < 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.trunc(maxOrder) + 1;
|
||||
}
|
||||
|
||||
async createExecutableScriptFile(script, context = {}) {
|
||||
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
|
||||
const scriptBody = normalizeScriptBody(script?.scriptBody);
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-'));
|
||||
const scriptPath = path.join(tempDir, 'script.sh');
|
||||
const wrapped = buildScriptWrapper(scriptBody, {
|
||||
...context,
|
||||
scriptId: script?.id ?? context?.scriptId ?? '',
|
||||
scriptName: name,
|
||||
source: context?.source || 'post_encode'
|
||||
});
|
||||
|
||||
await fs.promises.writeFile(scriptPath, wrapped, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o700
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
logger.warn('script:temp-cleanup-failed', {
|
||||
scriptId: script?.id ?? null,
|
||||
scriptName: name,
|
||||
tempDir,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
scriptPath,
|
||||
cmd: '/usr/bin/env',
|
||||
args: ['bash', scriptPath],
|
||||
argsForLog: ['bash', `<script:${name}>`],
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
|
||||
async testScript(scriptId, options = {}) {
|
||||
const script = await this.getScriptById(scriptId);
|
||||
const effectiveTimeoutMs = await resolveScriptTestTimeoutMs(options);
|
||||
const prepared = await this.createExecutableScriptFile(script, {
|
||||
source: 'settings_test',
|
||||
mode: 'test'
|
||||
});
|
||||
const activityId = runtimeActivityService.startActivity('script', {
|
||||
name: script.name,
|
||||
source: 'settings_test',
|
||||
scriptId: script.id,
|
||||
currentStep: 'Skript-Test läuft'
|
||||
});
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
cancelReason: null,
|
||||
child: null,
|
||||
cancelSignalSent: false
|
||||
};
|
||||
runtimeActivityService.setControls(activityId, {
|
||||
cancel: async (payload = {}) => {
|
||||
if (controlState.cancelRequested) {
|
||||
return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' };
|
||||
}
|
||||
controlState.cancelRequested = true;
|
||||
controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen';
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Abbruch angefordert'
|
||||
});
|
||||
if (controlState.child) {
|
||||
// User cancel should stop instantly.
|
||||
controlState.cancelSignalSent = killChildProcessTree(controlState.child, 'SIGKILL') || controlState.cancelSignalSent;
|
||||
}
|
||||
return { accepted: true, message: 'Abbruch angefordert.' };
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const run = await runProcessCapture({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
onChild: (child) => {
|
||||
controlState.child = child;
|
||||
},
|
||||
onStdoutLine: (line) => {
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
const exitCode = Number.isFinite(Number(run.code)) ? Number(run.code) : null;
|
||||
const finishedSuccessfully = exitCode === 0 && !run.timedOut;
|
||||
const cancelledByUser = Boolean(controlState.cancelRequested)
|
||||
&& (Boolean(controlState.cancelSignalSent) || !finishedSuccessfully);
|
||||
const success = finishedSuccessfully;
|
||||
const message = cancelledByUser
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (run.timedOut
|
||||
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
|
||||
: (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
|
||||
const errorMessage = success
|
||||
? null
|
||||
: (cancelledByUser
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (run.timedOut
|
||||
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
|
||||
: `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'),
|
||||
cancelled: cancelledByUser,
|
||||
exitCode,
|
||||
stdout: run.stdout || null,
|
||||
stderr: run.stderr || null,
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated),
|
||||
errorMessage,
|
||||
message
|
||||
});
|
||||
return {
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success,
|
||||
exitCode: run.code,
|
||||
signal: run.signal,
|
||||
timedOut: run.timedOut,
|
||||
durationMs: run.durationMs,
|
||||
stdout: run.stdout,
|
||||
stderr: run.stderr,
|
||||
stdoutTruncated: run.stdoutTruncated,
|
||||
stderrTruncated: run.stderrTruncated
|
||||
};
|
||||
} catch (error) {
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: controlState.cancelRequested ? 'cancelled' : 'error',
|
||||
cancelled: Boolean(controlState.cancelRequested),
|
||||
errorMessage: controlState.cancelRequested
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (error?.message || 'Skript-Test Fehler'),
|
||||
message: controlState.cancelRequested
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (error?.message || 'Skript-Test Fehler')
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
controlState.child = null;
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ScriptService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const logger = require('./logger').child('TEMP_CLEANUP');
|
||||
|
||||
const TMP_ROOT = os.tmpdir();
|
||||
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
const TOP_LEVEL_PREFIXES = [
|
||||
'ripster-merge-',
|
||||
'ripster-script-',
|
||||
'ripster-export-'
|
||||
];
|
||||
|
||||
const MANAGED_UPLOAD_DIRS = [
|
||||
'ripster-converter-uploads',
|
||||
'ripster-audiobook-uploads'
|
||||
];
|
||||
|
||||
function getEntryAgeMs(stats) {
|
||||
const referenceTime = Math.max(
|
||||
Number(stats?.mtimeMs) || 0,
|
||||
Number(stats?.ctimeMs) || 0,
|
||||
Number(stats?.birthtimeMs) || 0
|
||||
);
|
||||
return Date.now() - referenceTime;
|
||||
}
|
||||
|
||||
function isStale(stats, minAgeMs = STALE_ENTRY_MIN_AGE_MS) {
|
||||
return getEntryAgeMs(stats) >= minAgeMs;
|
||||
}
|
||||
|
||||
function removeEntry(targetPath, stats) {
|
||||
const isDirectory = stats?.isDirectory?.() || false;
|
||||
fs.rmSync(targetPath, {
|
||||
recursive: isDirectory,
|
||||
force: true
|
||||
});
|
||||
}
|
||||
|
||||
class TempCleanupService {
|
||||
constructor() {
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.runSweep('startup');
|
||||
|
||||
if (!this.interval) {
|
||||
this.interval = setInterval(() => {
|
||||
this.runSweep('interval').catch((error) => {
|
||||
logger.warn('temp:sweep:interval-failed', { error: error?.message || String(error) });
|
||||
});
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
this.interval.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async runSweep(reason = 'manual') {
|
||||
const deleted = [];
|
||||
const skipped = [];
|
||||
const failed = [];
|
||||
|
||||
let topLevelEntries = [];
|
||||
try {
|
||||
topLevelEntries = fs.readdirSync(TMP_ROOT, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
logger.warn('temp:sweep:readdir-failed', {
|
||||
reason,
|
||||
tmpRoot: TMP_ROOT,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return { deleted, skipped, failed };
|
||||
}
|
||||
|
||||
for (const entry of topLevelEntries) {
|
||||
const entryName = String(entry?.name || '').trim();
|
||||
if (!entryName || !TOP_LEVEL_PREFIXES.some((prefix) => entryName.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
const targetPath = path.join(TMP_ROOT, entryName);
|
||||
try {
|
||||
const stats = fs.lstatSync(targetPath);
|
||||
if (!isStale(stats)) {
|
||||
skipped.push({ path: targetPath, reason: 'fresh-top-level-entry' });
|
||||
continue;
|
||||
}
|
||||
removeEntry(targetPath, stats);
|
||||
deleted.push(targetPath);
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: targetPath,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dirName of MANAGED_UPLOAD_DIRS) {
|
||||
const uploadDir = path.join(TMP_ROOT, dirName);
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uploadEntries = [];
|
||||
try {
|
||||
uploadEntries = fs.readdirSync(uploadDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: uploadDir,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of uploadEntries) {
|
||||
const targetPath = path.join(uploadDir, entry.name);
|
||||
try {
|
||||
const stats = fs.lstatSync(targetPath);
|
||||
if (!isStale(stats)) {
|
||||
skipped.push({ path: targetPath, reason: 'fresh-upload-entry' });
|
||||
continue;
|
||||
}
|
||||
removeEntry(targetPath, stats);
|
||||
deleted.push(targetPath);
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: targetPath,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('temp:sweep:completed', {
|
||||
reason,
|
||||
tmpRoot: TMP_ROOT,
|
||||
deletedCount: deleted.length,
|
||||
skippedCount: skipped.length,
|
||||
failedCount: failed.length,
|
||||
deleted: deleted.slice(0, 50),
|
||||
failed: failed.slice(0, 20)
|
||||
});
|
||||
|
||||
return { deleted, skipped, failed };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TempCleanupService();
|
||||
@@ -0,0 +1,316 @@
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { dataDir } = require('../config');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('THUMBNAIL');
|
||||
|
||||
const THUMBNAILS_DIR = path.join(dataDir, 'thumbnails');
|
||||
const CACHE_DIR = path.join(THUMBNAILS_DIR, 'cache');
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
function ensureDirs() {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function cacheFilePath(jobId) {
|
||||
return path.join(CACHE_DIR, `job-${jobId}.jpg`);
|
||||
}
|
||||
|
||||
function persistentFilePath(jobId) {
|
||||
return path.join(THUMBNAILS_DIR, `job-${jobId}.jpg`);
|
||||
}
|
||||
|
||||
function localUrl(jobId) {
|
||||
return `/api/thumbnails/job-${jobId}.jpg`;
|
||||
}
|
||||
|
||||
function isLocalUrl(url) {
|
||||
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
|
||||
}
|
||||
|
||||
function resolveLocalThumbnailPath(url) {
|
||||
const raw = String(url || '').trim();
|
||||
const match = raw.match(/^\/api\/thumbnails\/job-(\d+)\.jpg$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const jobId = Number(match[1]);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return null;
|
||||
}
|
||||
return persistentFilePath(Math.trunc(jobId));
|
||||
}
|
||||
|
||||
function localThumbnailUrlExists(url) {
|
||||
const targetPath = resolveLocalThumbnailPath(url);
|
||||
if (!targetPath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(targetPath);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirectsLeft <= 0) {
|
||||
return reject(new Error('Zu viele Weiterleitungen beim Bild-Download'));
|
||||
}
|
||||
|
||||
const proto = url.startsWith('https') ? https : http;
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const cleanup = () => {
|
||||
try { file.destroy(); } catch (_) {}
|
||||
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
|
||||
};
|
||||
|
||||
proto.get(url, { timeout: 15000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
file.close(() => {
|
||||
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
|
||||
downloadImage(res.headers.location, destPath, redirectsLeft - 1).then(resolve).catch(reject);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
cleanup();
|
||||
return reject(new Error(`HTTP ${res.statusCode} beim Bild-Download`));
|
||||
}
|
||||
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(() => resolve()));
|
||||
file.on('error', (err) => { cleanup(); reject(err); });
|
||||
}).on('error', (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}).on('timeout', function () {
|
||||
this.destroy();
|
||||
cleanup();
|
||||
reject(new Error('Timeout beim Bild-Download'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das Bild einer extern-URL in den Cache herunter.
|
||||
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
|
||||
* @returns {Promise<string|null>} lokaler Pfad oder null
|
||||
*/
|
||||
async function cacheJobThumbnailDetailed(jobId, posterUrl) {
|
||||
const normalizedPosterUrl = String(posterUrl || '').trim();
|
||||
if (!normalizedPosterUrl || isLocalUrl(normalizedPosterUrl)) {
|
||||
return {
|
||||
ok: false,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl || null,
|
||||
cachedPath: null,
|
||||
error: 'Kein externer Poster-Link vorhanden.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDirs();
|
||||
const dest = cacheFilePath(jobId);
|
||||
await downloadImage(normalizedPosterUrl, dest);
|
||||
logger.info('thumbnail:cached', { jobId, posterUrl: normalizedPosterUrl, dest });
|
||||
return {
|
||||
ok: true,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl,
|
||||
cachedPath: dest,
|
||||
error: null
|
||||
};
|
||||
} catch (err) {
|
||||
const message = String(err?.message || err || 'Bild-Download fehlgeschlagen');
|
||||
logger.warn('thumbnail:cache:failed', { jobId, posterUrl: normalizedPosterUrl, error: message });
|
||||
return {
|
||||
ok: false,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl,
|
||||
cachedPath: null,
|
||||
error: message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||
const result = await cacheJobThumbnailDetailed(jobId, posterUrl);
|
||||
return result?.ok ? result.cachedPath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt das gecachte Bild in den persistenten Ordner.
|
||||
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
|
||||
* Wird nach erfolgreichem Rip aufgerufen.
|
||||
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
|
||||
*/
|
||||
function promoteJobThumbnail(jobId) {
|
||||
try {
|
||||
ensureDirs();
|
||||
const src = cacheFilePath(jobId);
|
||||
const dest = persistentFilePath(jobId);
|
||||
|
||||
if (fs.existsSync(src)) {
|
||||
fs.renameSync(src, dest);
|
||||
logger.info('thumbnail:promoted', { jobId, dest });
|
||||
return localUrl(jobId);
|
||||
}
|
||||
|
||||
// Falls kein Cache vorhanden, aber persistente Datei schon existiert
|
||||
if (fs.existsSync(dest)) {
|
||||
return localUrl(jobId);
|
||||
}
|
||||
|
||||
logger.warn('thumbnail:promote:no-source', { jobId });
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:promote:failed', { jobId, error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Pfad zum persistenten Thumbnail-Ordner zurück (für Static-Serving).
|
||||
*/
|
||||
function getThumbnailsDir() {
|
||||
return THUMBNAILS_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopiert das persistente Thumbnail von sourceJobId zu targetJobId.
|
||||
* Wird bei Rip-Neustart genutzt, damit der neue Job ein eigenes Bild hat
|
||||
* und nicht auf die Datei des alten Jobs angewiesen ist.
|
||||
* @returns {string|null} neue lokale URL oder null
|
||||
*/
|
||||
function copyThumbnail(sourceJobId, targetJobId) {
|
||||
try {
|
||||
const src = persistentFilePath(sourceJobId);
|
||||
if (!fs.existsSync(src)) return null;
|
||||
ensureDirs();
|
||||
const dest = persistentFilePath(targetJobId);
|
||||
fs.copyFileSync(src, dest);
|
||||
logger.info('thumbnail:copied', { sourceJobId, targetJobId });
|
||||
return localUrl(targetJobId);
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:copy:failed', { sourceJobId, targetJobId, error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert ein lokal extrahiertes Bild als persistentes Job-Thumbnail.
|
||||
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
|
||||
*/
|
||||
function storeLocalThumbnail(jobId, sourcePath) {
|
||||
try {
|
||||
const src = String(sourcePath || '').trim();
|
||||
if (!src || !fs.existsSync(src)) {
|
||||
return null;
|
||||
}
|
||||
ensureDirs();
|
||||
const dest = persistentFilePath(jobId);
|
||||
fs.copyFileSync(src, dest);
|
||||
logger.info('thumbnail:stored-local', { jobId, sourcePath: src, dest });
|
||||
return localUrl(jobId);
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:store-local:failed', { jobId, sourcePath, error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht Cache- und persistente Thumbnail-Datei eines Jobs.
|
||||
* Wird beim Löschen eines Jobs aufgerufen.
|
||||
*/
|
||||
function deleteThumbnail(jobId) {
|
||||
for (const filePath of [persistentFilePath(jobId), cacheFilePath(jobId)]) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:delete:failed', { jobId, filePath, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migriert bestehende Jobs: lädt alle externen poster_url-Bilder herunter
|
||||
* und speichert sie lokal. Läuft beim Start im Hintergrund, sequenziell
|
||||
* mit kurzem Delay um externe Server nicht zu überlasten.
|
||||
*/
|
||||
async function migrateExistingThumbnails() {
|
||||
try {
|
||||
ensureDirs();
|
||||
const db = await getDb();
|
||||
|
||||
// Alle abgeschlossenen Jobs mit externer poster_url, die noch kein lokales Bild haben
|
||||
const jobs = await db.all(
|
||||
`SELECT id, poster_url FROM jobs
|
||||
WHERE rip_successful = 1
|
||||
AND poster_url IS NOT NULL
|
||||
AND poster_url != ''
|
||||
AND poster_url NOT LIKE '/api/thumbnails/%'
|
||||
ORDER BY id ASC`
|
||||
);
|
||||
|
||||
if (!jobs.length) {
|
||||
logger.info('thumbnail:migrate:nothing-to-do');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('thumbnail:migrate:start', { count: jobs.length });
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const job of jobs) {
|
||||
// Persistente Datei bereits vorhanden? Dann nur DB aktualisieren.
|
||||
const dest = persistentFilePath(job.id);
|
||||
if (fs.existsSync(dest)) {
|
||||
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
|
||||
succeeded++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadImage(job.poster_url, dest);
|
||||
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
|
||||
logger.info('thumbnail:migrate:ok', { jobId: job.id });
|
||||
succeeded++;
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:migrate:failed', { jobId: job.id, url: job.poster_url, error: err.message });
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Kurze Pause zwischen Downloads (externe Server schonen)
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
logger.info('thumbnail:migrate:done', { succeeded, failed, total: jobs.length });
|
||||
} catch (err) {
|
||||
logger.error('thumbnail:migrate:error', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cacheJobThumbnail,
|
||||
cacheJobThumbnailDetailed,
|
||||
promoteJobThumbnail,
|
||||
copyThumbnail,
|
||||
storeLocalThumbnail,
|
||||
deleteThumbnail,
|
||||
getThumbnailsDir,
|
||||
migrateExistingThumbnails,
|
||||
isLocalUrl,
|
||||
resolveLocalThumbnailPath,
|
||||
localThumbnailUrlExists
|
||||
};
|
||||
@@ -0,0 +1,488 @@
|
||||
'use strict';
|
||||
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('TMDB');
|
||||
|
||||
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
|
||||
const TMDB_TIMEOUT_MS = 15000;
|
||||
|
||||
class TmdbService {
|
||||
isAbortError(error) {
|
||||
const name = String(error?.name || '').trim().toLowerCase();
|
||||
const message = String(error?.message || '').trim().toLowerCase();
|
||||
return name === 'aborterror' || message.includes('aborted');
|
||||
}
|
||||
|
||||
classifyRequestError(error) {
|
||||
if (this.isAbortError(error)) {
|
||||
return 'timeout';
|
||||
}
|
||||
const statusCode = Number(error?.statusCode || 0) || null;
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return 'auth';
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return 'upstream';
|
||||
}
|
||||
if (statusCode >= 400) {
|
||||
return 'request_failed';
|
||||
}
|
||||
return 'network';
|
||||
}
|
||||
|
||||
readFailureCode(rows) {
|
||||
const value = rows && typeof rows.tmdbFailureCode === 'string'
|
||||
? rows.tmdbFailureCode
|
||||
: '';
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
attachFailureCode(rows, failureCode = null) {
|
||||
const output = Array.isArray(rows) ? rows : [];
|
||||
const normalized = String(failureCode || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return output;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(output, 'tmdbFailureCode', {
|
||||
value: normalized,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
} catch (_error) {
|
||||
output.tmdbFailureCode = normalized;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
normalizeNameList(values = [], options = {}) {
|
||||
const maxItems = Math.max(1, Number(options.maxItems || 10));
|
||||
const source = Array.isArray(values) ? values : [];
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
for (const item of source) {
|
||||
const name = String(item?.name || item || '').trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(name);
|
||||
if (output.length >= maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
formatRuntimeLabel(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => Number(entry))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||
.map((entry) => Math.trunc(entry));
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
return min === max ? `${min} min` : `${min}-${max} min`;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return `${Math.trunc(numeric)} min`;
|
||||
}
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return {
|
||||
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
|
||||
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE'
|
||||
};
|
||||
}
|
||||
|
||||
async isConfigured() {
|
||||
const config = await this.getConfig();
|
||||
return Boolean(config.readAccessToken);
|
||||
}
|
||||
|
||||
async resolveLanguage(explicitLanguage = null) {
|
||||
const config = await this.getConfig();
|
||||
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
|
||||
}
|
||||
|
||||
async request(pathName, options = {}) {
|
||||
const config = await this.getConfig();
|
||||
if (!config.readAccessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const normalizedPath = String(pathName || '').replace(/^\/+/, '');
|
||||
const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`);
|
||||
if (options.query && typeof options.query === 'object') {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Authorization': `Bearer ${config.readAccessToken}`,
|
||||
'User-Agent': 'Ripster/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`TMDb request failed (${response.status})`);
|
||||
error.statusCode = response.status;
|
||||
error.url = url.toString();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
buildImageUrl(imagePath, size = 'w342') {
|
||||
const normalizedPath = String(imagePath || '').trim();
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await this.request('/search/tv', {
|
||||
query: {
|
||||
query: normalizedQuery,
|
||||
first_air_date_year: options.year || undefined,
|
||||
language,
|
||||
page: options.page || 1,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const failureCode = this.classifyRequestError(error);
|
||||
logger.warn('search:failed', {
|
||||
query: normalizedQuery,
|
||||
error: error?.message || String(error),
|
||||
failureCode,
|
||||
statusCode: Number(error?.statusCode || 0) || null
|
||||
});
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data?.results) ? data.results : [];
|
||||
const normalizedRows = rows
|
||||
.map((row) => ({
|
||||
id: Number(row?.id || 0) || null,
|
||||
title: String(row?.name || row?.original_name || '').trim() || null,
|
||||
originalTitle: String(row?.original_name || '').trim() || null,
|
||||
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
|
||||
overview: String(row?.overview || '').trim() || null,
|
||||
posterPath: String(row?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(row?.poster_path, 'w342'),
|
||||
backdropPath: String(row?.backdrop_path || '').trim() || null,
|
||||
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
|
||||
originalLanguage: String(row?.original_language || '').trim() || null,
|
||||
popularity: Number(row?.popularity || 0) || 0
|
||||
}))
|
||||
.filter((row) => row.id && row.title);
|
||||
return this.attachFailureCode(normalizedRows, null);
|
||||
}
|
||||
|
||||
async getSeriesDetails(seriesId, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
const appendToResponse = Array.isArray(options.appendToResponse)
|
||||
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return this.request(`/tv/${Math.trunc(id)}`, {
|
||||
query: {
|
||||
language,
|
||||
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('series:details:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async getEpisodeGroups(seriesId) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => {
|
||||
logger.warn('series:episode-groups:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
}
|
||||
|
||||
async getEpisodeGroupDetails(groupId, options = {}) {
|
||||
const normalizedId = String(groupId || '').trim();
|
||||
if (!normalizedId || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/episode_group/${normalizedId}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('episode-group:details:failed', {
|
||||
groupId: normalizedId,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async getSeasonDetails(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
async getSeasonCredits(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('season:credits:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
seasonNumber: Math.trunc(season),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
buildSeasonSummary(seasonDetails = null) {
|
||||
const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {};
|
||||
const episodes = Array.isArray(details.episodes) ? details.episodes : [];
|
||||
return {
|
||||
seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null,
|
||||
name: String(details.name || '').trim() || null,
|
||||
overview: String(details.overview || '').trim() || null,
|
||||
posterPath: String(details.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(details.poster_path, 'w342'),
|
||||
episodeCount: episodes.length,
|
||||
episodes: episodes.map((episode) => ({
|
||||
id: Number(episode?.id || 0) || null,
|
||||
number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null,
|
||||
seasonNumber: Number(episode?.season_number || details.season_number || 0) || null,
|
||||
name: String(episode?.name || '').trim() || null,
|
||||
overview: String(episode?.overview || '').trim() || null,
|
||||
runtime: Number(episode?.runtime || 0) || null,
|
||||
airDate: String(episode?.air_date || '').trim() || null,
|
||||
stillPath: String(episode?.still_path || '').trim() || null,
|
||||
still: this.buildImageUrl(episode?.still_path, 'w300')
|
||||
})).filter((episode) => episode.id && episode.number)
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesDetailsSummary(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
|
||||
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
|
||||
const creators = Array.isArray(details?.created_by) ? details.created_by : [];
|
||||
const genres = Array.isArray(details?.genres) ? details.genres : [];
|
||||
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
|
||||
const directorNames = this.normalizeNameList(
|
||||
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
|
||||
{ maxItems: 5 }
|
||||
);
|
||||
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
|
||||
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
|
||||
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
|
||||
const voteAverageRaw = Number(details?.vote_average || 0);
|
||||
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
|
||||
? Number(voteAverageRaw.toFixed(1))
|
||||
: null;
|
||||
const voteCount = Number(details?.vote_count || 0);
|
||||
const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null;
|
||||
|
||||
return {
|
||||
director: directorNames.length > 0
|
||||
? directorNames.join(', ')
|
||||
: (creatorNames.length > 0 ? creatorNames.join(', ') : null),
|
||||
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
|
||||
runtime: runtimeLabel,
|
||||
runtimeLabel,
|
||||
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
|
||||
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
|
||||
voteAverage,
|
||||
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
|
||||
rottenTomatoes: null,
|
||||
imdbId,
|
||||
tmdbId: Number(details?.id || 0) || null,
|
||||
firstAirDate: String(details?.first_air_date || '').trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesMetadataCandidate(series = {}, options = {}) {
|
||||
const season = series?.season && typeof series.season === 'object'
|
||||
? series.season
|
||||
: null;
|
||||
const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null;
|
||||
const providerId = seasonNumber
|
||||
? `tmdb:${series.id}:season:${seasonNumber}`
|
||||
: `tmdb:${series.id}`;
|
||||
|
||||
return {
|
||||
provider: 'tmdb',
|
||||
providerId,
|
||||
metadataKind: seasonNumber ? 'season' : 'series',
|
||||
tmdbId: Number(series?.id || 0) || null,
|
||||
title: String(series?.title || '').trim() || null,
|
||||
originalTitle: String(series?.originalTitle || '').trim() || null,
|
||||
year: Number(series?.year || 0) || null,
|
||||
overview: String(series?.overview || '').trim() || null,
|
||||
poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'),
|
||||
backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'),
|
||||
seasonNumber,
|
||||
seasonName: String(season?.name || '').trim() || null,
|
||||
seasonOverview: String(season?.overview || '').trim() || null,
|
||||
seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'),
|
||||
episodeCount: Number(season?.episodeCount || 0) || 0,
|
||||
episodes: Array.isArray(season?.episodes) ? season.episodes : []
|
||||
};
|
||||
}
|
||||
|
||||
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const seasons = Array.isArray(details.seasons) ? details.seasons : [];
|
||||
return seasons
|
||||
.map((season) => ({
|
||||
seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null,
|
||||
name: String(season?.name || '').trim() || null,
|
||||
overview: String(season?.overview || '').trim() || null,
|
||||
posterPath: String(season?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(season?.poster_path, 'w342'),
|
||||
episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0,
|
||||
episodes: []
|
||||
}))
|
||||
.filter((season) => season.seasonNumber !== null && season.episodeCount > 0);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeasons(query, options = {}) {
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
|
||||
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const details = await this.getSeriesDetails(candidate.id, options);
|
||||
const seasons = this.buildSeasonSummariesFromSeriesDetails(details);
|
||||
if (seasons.length === 0) {
|
||||
return [this.buildSeriesMetadataCandidate(candidate)];
|
||||
}
|
||||
return seasons.map((season) => this.buildSeriesMetadataCandidate({
|
||||
...candidate,
|
||||
season
|
||||
}, {
|
||||
seasonNumber: season.seasonNumber
|
||||
}));
|
||||
}));
|
||||
|
||||
const normalized = expanded.flat().filter((item) => item?.tmdbId && item?.title);
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
const normalizedSeason = Number(seasonNumber);
|
||||
if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options);
|
||||
if (!seasonDetails) {
|
||||
return {
|
||||
...candidate,
|
||||
season: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
...candidate,
|
||||
season: this.buildSeasonSummary(seasonDetails)
|
||||
};
|
||||
}));
|
||||
|
||||
const normalized = withSeasons
|
||||
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
|
||||
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
|
||||
seasonNumber: normalizedSeason
|
||||
}));
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TmdbService();
|
||||
@@ -0,0 +1,133 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('USER_PRESET');
|
||||
|
||||
const VALID_MEDIA_TYPES = new Set(['bluray', 'dvd', 'other', 'all']);
|
||||
|
||||
function normalizeMediaType(value) {
|
||||
const v = String(value || '').trim().toLowerCase();
|
||||
return VALID_MEDIA_TYPES.has(v) ? v : 'all';
|
||||
}
|
||||
|
||||
function rowToPreset(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
mediaType: row.media_type,
|
||||
handbrakePreset: row.handbrake_preset || null,
|
||||
extraArgs: row.extra_args || null,
|
||||
description: row.description || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
async function listPresets(mediaType = null) {
|
||||
const db = await getDb();
|
||||
let rows;
|
||||
if (mediaType && VALID_MEDIA_TYPES.has(mediaType)) {
|
||||
rows = await db.all(
|
||||
`SELECT * FROM user_presets WHERE media_type = ? OR media_type = 'all' ORDER BY name ASC`,
|
||||
[mediaType]
|
||||
);
|
||||
} else {
|
||||
rows = await db.all(`SELECT * FROM user_presets ORDER BY media_type ASC, name ASC`);
|
||||
}
|
||||
return rows.map(rowToPreset);
|
||||
}
|
||||
|
||||
async function getPresetById(id) {
|
||||
const db = await getDb();
|
||||
const row = await db.get(`SELECT * FROM user_presets WHERE id = ? LIMIT 1`, [id]);
|
||||
return rowToPreset(row);
|
||||
}
|
||||
|
||||
async function createPreset(payload) {
|
||||
const name = String(payload?.name || '').trim();
|
||||
if (!name) {
|
||||
const error = new Error('Preset-Name darf nicht leer sein.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mediaType = normalizeMediaType(payload?.mediaType);
|
||||
const handbrakePreset = String(payload?.handbrakePreset || '').trim() || null;
|
||||
const extraArgs = String(payload?.extraArgs || '').trim() || null;
|
||||
const description = String(payload?.description || '').trim() || null;
|
||||
|
||||
const db = await getDb();
|
||||
const result = await db.run(
|
||||
`INSERT INTO user_presets (name, media_type, handbrake_preset, extra_args, description)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[name, mediaType, handbrakePreset, extraArgs, description]
|
||||
);
|
||||
|
||||
const preset = await getPresetById(result.lastID);
|
||||
logger.info('create', { id: preset.id, name: preset.name, mediaType: preset.mediaType });
|
||||
return preset;
|
||||
}
|
||||
|
||||
async function updatePreset(id, payload) {
|
||||
const db = await getDb();
|
||||
const existing = await getPresetById(id);
|
||||
if (!existing) {
|
||||
const error = new Error(`Preset ${id} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const name = payload?.name !== undefined ? String(payload.name || '').trim() : existing.name;
|
||||
if (!name) {
|
||||
const error = new Error('Preset-Name darf nicht leer sein.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mediaType = payload?.mediaType !== undefined
|
||||
? normalizeMediaType(payload.mediaType)
|
||||
: existing.mediaType;
|
||||
const handbrakePreset = payload?.handbrakePreset !== undefined
|
||||
? (String(payload.handbrakePreset || '').trim() || null)
|
||||
: existing.handbrakePreset;
|
||||
const extraArgs = payload?.extraArgs !== undefined
|
||||
? (String(payload.extraArgs || '').trim() || null)
|
||||
: existing.extraArgs;
|
||||
const description = payload?.description !== undefined
|
||||
? (String(payload.description || '').trim() || null)
|
||||
: existing.description;
|
||||
|
||||
await db.run(
|
||||
`UPDATE user_presets
|
||||
SET name = ?, media_type = ?, handbrake_preset = ?, extra_args = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[name, mediaType, handbrakePreset, extraArgs, description, id]
|
||||
);
|
||||
|
||||
const updated = await getPresetById(id);
|
||||
logger.info('update', { id: updated.id, name: updated.name });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function deletePreset(id) {
|
||||
const db = await getDb();
|
||||
const existing = await getPresetById(id);
|
||||
if (!existing) {
|
||||
const error = new Error(`Preset ${id} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db.run(`DELETE FROM user_presets WHERE id = ?`, [id]);
|
||||
logger.info('delete', { id: existing.id, name: existing.name });
|
||||
return existing;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listPresets,
|
||||
getPresetById,
|
||||
createPreset,
|
||||
updatePreset,
|
||||
deletePreset
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const logger = require('./logger').child('WS');
|
||||
|
||||
class WebSocketService {
|
||||
constructor() {
|
||||
this.wss = null;
|
||||
this.clients = new Set();
|
||||
}
|
||||
|
||||
_removeClient(socket, logLevel = 'info', event = 'client:removed') {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
const deleted = this.clients.delete(socket);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
logger[logLevel](event, { clients: this.clients.size });
|
||||
}
|
||||
|
||||
init(httpServer) {
|
||||
if (this.wss) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||
|
||||
this.wss.on('connection', (socket) => {
|
||||
this.clients.add(socket);
|
||||
logger.info('client:connected', { clients: this.clients.size });
|
||||
|
||||
try {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'WS_CONNECTED',
|
||||
payload: { connectedAt: new Date().toISOString() }
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('client:connected:initial-send-failed', {
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(socket, 'warn', 'client:connected:removed-after-send-failure');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on('close', () => {
|
||||
this._removeClient(socket, 'info', 'client:closed');
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logger.warn('client:error', {
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(socket, 'warn', 'client:error:removed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(type, payload) {
|
||||
if (!this.wss) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('broadcast', {
|
||||
type,
|
||||
clients: this.clients.size,
|
||||
payloadKeys: payload && typeof payload === 'object' ? Object.keys(payload) : []
|
||||
});
|
||||
|
||||
const message = JSON.stringify({
|
||||
type,
|
||||
payload,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (!client || client.readyState !== client.OPEN) {
|
||||
if (client && client.readyState === client.CLOSED) {
|
||||
this._removeClient(client, 'info', 'client:pruned-closed');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
client.send(message, (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
logger.warn('broadcast:send-failed', {
|
||||
type,
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(client, 'warn', 'client:removed-after-send-failure');
|
||||
try {
|
||||
client.terminate();
|
||||
} catch (_error) {
|
||||
// noop
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('broadcast:send-threw', {
|
||||
type,
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(client, 'warn', 'client:removed-after-send-throw');
|
||||
try {
|
||||
client.terminate();
|
||||
} catch (_terminateError) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WebSocketService();
|
||||
Reference in New Issue
Block a user