0.16.1-1 release snapshot

This commit is contained in:
2026-04-29 08:35:24 +00:00
parent 6115090da1
commit 901a3b1ad7
269 changed files with 127336 additions and 1 deletions
@@ -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 };
+819
View File
@@ -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
};
+196
View File
@@ -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
};
+784
View File
@@ -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();
+662
View File
@@ -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
+572
View File
@@ -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
+46
View File
@@ -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
};
+238
View File
@@ -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
};
+215
View File
@@ -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
};
+169
View File
@@ -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();
+165
View File
@@ -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();
+225
View File
@@ -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
+114
View File
@@ -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();
+927
View File
@@ -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();
+688
View File
@@ -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
+156
View File
@@ -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();
+316
View File
@@ -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
};
+488
View File
@@ -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();
+133
View File
@@ -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
};
+121
View File
@@ -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();