0.10.0-8 Audbile Meta

This commit is contained in:
2026-03-15 09:22:46 +00:00
parent 25d5339ada
commit 9c0af285ea
19 changed files with 1318 additions and 146 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.10.0-7",
"version": "0.10.0-8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.10.0-7",
"version": "0.10.0-8",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.7",

View File

@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.10.0-7",
"version": "0.10.0-8",
"private": true,
"type": "commonjs",
"scripts": {

View File

@@ -828,6 +828,7 @@ const SETTINGS_CATEGORY_MOVES = [
{ key: 'output_template_bluray', category: 'Pfade' },
{ key: 'output_template_dvd', category: 'Pfade' },
{ key: 'output_template_audiobook', category: 'Pfade' },
{ key: 'output_chapter_template_audiobook', category: 'Pfade' },
{ key: 'audiobook_raw_template', category: 'Pfade' }
];
@@ -917,6 +918,12 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_chapter_template_audiobook', 'Pfade', 'Kapitel Template (Audiobook)', 'string', 1, 'Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}', '[]', '{"minLength":1}', 7355)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_chapter_template_audiobook', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736)`

View File

@@ -5,6 +5,7 @@ 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: {
@@ -38,6 +39,43 @@ function parseOptionalYear(value) {
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';
@@ -123,22 +161,141 @@ function pickTag(tags, keys = []) {
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 = Number(chapter?.start_time || chapter?.start || 0);
const endSeconds = Number(chapter?.end_time || chapter?.end || 0);
const title = tags.title || tags.chapter || `Kapitel ${index + 1}`;
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: index + 1,
index: chapterIndex,
title,
startSeconds: Number.isFinite(startSeconds) ? startSeconds : 0,
endSeconds: Number.isFinite(endSeconds) ? endSeconds : 0
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;
@@ -156,20 +313,39 @@ function buildMetadataFromProbe(probe = null, originalName = null) {
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, ['artist', 'album_artist', 'composer']) || 'Unknown Author';
const narrator = pickTag(tags, ['narrator', 'performer', 'comment']) || null;
const series = pickTag(tags, ['series', 'grouping']) || null;
const part = pickTag(tags, ['part', 'disc', 'track']) || null;
const year = parseOptionalYear(pickTag(tags, ['date', 'year']));
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 = buildChapterList(probe);
const chapters = normalizeChapterList(buildChapterList(probe), {
durationMs,
fallbackTitle: title,
createFallback: false
});
const cover = detectCoverStream(probe);
return {
title,
author,
narrator,
description,
series,
part,
year,
@@ -177,6 +353,8 @@ function buildMetadataFromProbe(probe = null, originalName = null) {
artist: author,
durationMs,
chapters,
cover,
hasEmbeddedCover: Boolean(cover),
tags
};
}
@@ -189,6 +367,15 @@ function normalizeTemplateTokenKey(rawKey) {
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;
}
@@ -214,21 +401,27 @@ function renderTemplate(template, values) {
return cleanupRenderedTemplate(rendered);
}
function buildTemplateValues(metadata = {}, format = null) {
const author = sanitizeFileName(normalizeText(metadata.author || metadata.artist || 'Unknown Author'));
const title = sanitizeFileName(normalizeText(metadata.title || metadata.album || 'Unknown Audiobook'));
const narrator = sanitizeFileName(normalizeText(metadata.narrator || ''), 'unknown');
const series = sanitizeFileName(normalizeText(metadata.series || ''), 'unknown');
const part = sanitizeFileName(normalizeText(metadata.part || ''), 'unknown');
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: narrator === 'unknown' ? '' : narrator,
series: series === 'unknown' ? '' : series,
part: part === 'unknown' ? '' : part,
narrator,
series,
part,
year,
format: format ? String(format).trim().toLowerCase() : ''
format: format ? String(format).trim().toLowerCase() : '',
chapterNr: String(safeChapterIndex).padStart(2, '0'),
chapterNo: String(safeChapterIndex),
chapterTitle
};
}
@@ -281,6 +474,71 @@ function buildOutputPath(metadata, movieBaseDir, outputTemplate = DEFAULT_AUDIOB
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 {
@@ -296,35 +554,203 @@ function buildProbeCommand(ffprobeCommand, inputPath) {
};
}
function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}) {
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',
'-i', inputPath,
'-i', inputPath
];
if (extra.chapterMetadataPath) {
commonArgs.push('-f', 'ffmetadata', '-i', extra.chapterMetadataPath);
}
commonArgs.push(
'-map', '0:a:0?',
'-map_metadata', '0',
'-map_chapters', '0',
'-map_chapters', extra.chapterMetadataPath ? '1' : '0',
'-vn',
'-sn',
'-dn'
];
let codecArgs = ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`];
if (format === 'm4b') {
codecArgs = ['-c:a', 'copy'];
} else if (format === 'flac') {
codecArgs = ['-codec:a', 'flac', '-compression_level', String(normalizedOptions.flacCompression)];
} else if (normalizedOptions.mp3Mode === 'vbr') {
codecArgs = ['-codec:a', 'libmp3lame', '-q:a', String(normalizedOptions.mp3Quality)];
}
);
const metadataArgs = buildMetadataArgs(extra.metadata, extra.metadataOptions);
const codecArgs = buildCodecArgs(format, normalizedOptions);
return {
cmd,
args: [...commonArgs, ...codecArgs, outputPath],
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
) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const format = normalizeOutputFormat(outputFormat);
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
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',
'-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+))?$/);
@@ -368,16 +794,22 @@ module.exports = {
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
};

View File

@@ -0,0 +1,156 @@
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)
};
}
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,
fetchChaptersByAsin
};

View File

@@ -345,7 +345,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
if (hasAudiobookStructure(rawPath) || hasAudiobookStructure(encodeInputPath)) {
return 'audiobook';
}
if (String(hbInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(hbInfo?.mode || '').trim().toLowerCase())) {
return 'audiobook';
}
if (String(plan?.mode || '').trim().toLowerCase() === 'audiobook') {
@@ -504,12 +504,28 @@ function getConfiguredMediaPathList(settings = {}, baseKey) {
return unique;
}
function isDirectoryLikeOutput(mediaType, encodePlan = null, handbrakeInfo = null) {
if (mediaType === 'cd') {
return true;
}
if (mediaType !== 'audiobook') {
return false;
}
const hbMode = String(handbrakeInfo?.mode || '').trim().toLowerCase();
if (hbMode === 'audiobook_encode_split') {
return true;
}
const format = String(encodePlan?.format || '').trim().toLowerCase();
return Boolean(format && format !== 'm4b');
}
function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = {}) {
const mkInfo = parsed?.makemkvInfo || parseJsonSafe(job?.makemkv_info_json, null);
const miInfo = parsed?.mediainfoInfo || parseJsonSafe(job?.mediainfo_info_json, null);
const plan = parsed?.encodePlan || parseJsonSafe(job?.encode_plan_json, null);
const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null);
const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo);
const directoryLikeOutput = isDirectoryLikeOutput(mediaType, plan, handbrakeInfo);
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
const rawDir = String(effectiveSettings?.raw_dir || '').trim();
const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim();
@@ -519,13 +535,13 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
const effectiveRawPath = job?.raw_path
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
: (job?.raw_path || null);
// For CD, output_path is a directory (album folder) — skip path-relocation heuristic
const effectiveOutputPath = (mediaType !== 'cd' && configuredMovieDir && job?.output_path)
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path)
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
: (job?.output_path || null);
return {
mediaType,
directoryLikeOutput,
rawDir,
movieDir,
effectiveRawPath,
@@ -561,11 +577,12 @@ function enrichJobRow(job, settings = null, options = {}) {
const omdbInfo = parseJsonSafe(job.omdb_json, null);
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
const handbrakeInfo = resolvedPaths.handbrakeInfo;
const directoryLikeOutput = Boolean(resolvedPaths.directoryLikeOutput);
const outputStatus = includeFsChecks
? (resolvedPaths.mediaType === 'cd'
? (directoryLikeOutput
? inspectDirectory(resolvedPaths.effectiveOutputPath)
: inspectOutputFile(resolvedPaths.effectiveOutputPath))
: (resolvedPaths.mediaType === 'cd'
: (directoryLikeOutput
? buildUnknownDirectoryStatus(resolvedPaths.effectiveOutputPath)
: buildUnknownFileStatus(resolvedPaths.effectiveOutputPath));
const rawStatus = includeFsChecks
@@ -582,7 +599,7 @@ function enrichJobRow(job, settings = null, options = {}) {
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|| String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
const backupSuccess = ripSuccessful;
const encodeSuccess = mediaType === 'cd'
const encodeSuccess = directoryLikeOutput
? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists))
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';

View File

@@ -7,6 +7,7 @@ const settingsService = require('./settingsService');
const historyService = require('./historyService');
const omdbService = require('./omdbService');
const musicBrainzService = require('./musicBrainzService');
const audnexService = require('./audnexService');
const cdRipService = require('./cdRipService');
const audiobookService = require('./audiobookService');
const scriptService = require('./scriptService');
@@ -495,6 +496,12 @@ function withTimestampBeforeExtension(targetPath, suffix) {
return path.join(dir, `${base}_${suffix}${ext}`);
}
function withTimestampSuffix(targetPath, suffix) {
const dir = path.dirname(targetPath);
const base = path.basename(targetPath);
return path.join(dir, `${base}_${suffix}`);
}
function resolveOutputTemplateValues(job, fallbackJobId = null) {
return {
title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'),
@@ -557,10 +564,21 @@ function ensureUniqueOutputPath(outputPath) {
}
const ts = fileTimestamp();
let attempt = withTimestampBeforeExtension(outputPath, ts);
let stat = null;
try {
stat = fs.statSync(outputPath);
} catch (_error) {
stat = null;
}
const isDirectory = Boolean(stat?.isDirectory?.());
let attempt = isDirectory
? withTimestampSuffix(outputPath, ts)
: withTimestampBeforeExtension(outputPath, ts);
let i = 1;
while (fs.existsSync(attempt)) {
attempt = withTimestampBeforeExtension(outputPath, `${ts}-${i}`);
attempt = isDirectory
? withTimestampSuffix(outputPath, `${ts}-${i}`)
: withTimestampBeforeExtension(outputPath, `${ts}-${i}`);
i += 1;
}
return attempt;
@@ -594,6 +612,24 @@ function moveFileWithFallback(sourcePath, targetPath) {
}
}
function movePathWithFallback(sourcePath, targetPath) {
try {
fs.renameSync(sourcePath, targetPath);
} catch (error) {
if (error?.code !== 'EXDEV') {
throw error;
}
const stat = fs.statSync(sourcePath);
if (stat.isDirectory()) {
fs.cpSync(sourcePath, targetPath, { recursive: true });
fs.rmSync(sourcePath, { recursive: true, force: true });
return;
}
fs.copyFileSync(sourcePath, targetPath);
fs.unlinkSync(sourcePath);
}
}
function removeDirectoryIfEmpty(directoryPath) {
try {
const entries = fs.readdirSync(directoryPath);
@@ -632,7 +668,7 @@ function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFin
}
ensureDir(path.dirname(targetPath));
moveFileWithFallback(sourcePath, targetPath);
movePathWithFallback(sourcePath, targetPath);
removeDirectoryIfEmpty(path.dirname(sourcePath));
return {
@@ -651,21 +687,34 @@ function buildAudiobookMetadataForJob(job, makemkvInfo = null, encodePlan = null
? mkInfo.selectedMetadata
: (mkInfo?.detectedMetadata && typeof mkInfo.detectedMetadata === 'object' ? mkInfo.detectedMetadata : {})
);
const title = String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook';
const durationMs = Number.isFinite(Number(metadataSource?.durationMs))
? Number(metadataSource.durationMs)
: 0;
const chaptersSource = Array.isArray(metadataSource?.chapters)
? metadataSource.chapters
: (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : []);
const chapters = audiobookService.normalizeChapterList(chaptersSource, {
durationMs,
fallbackTitle: title,
createFallback: false
});
return {
title: String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook',
title,
author: String(metadataSource?.author || metadataSource?.artist || '').trim() || null,
asin: String(metadataSource?.asin || '').trim() || null,
chapterSource: String(metadataSource?.chapterSource || '').trim() || null,
narrator: String(metadataSource?.narrator || '').trim() || null,
description: String(metadataSource?.description || '').trim() || null,
series: String(metadataSource?.series || '').trim() || null,
part: String(metadataSource?.part || '').trim() || null,
year: Number.isFinite(Number(metadataSource?.year))
? Math.trunc(Number(metadataSource.year))
: (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null),
durationMs: Number.isFinite(Number(metadataSource?.durationMs))
? Number(metadataSource.durationMs)
: 0,
chapters: Array.isArray(metadataSource?.chapters)
? metadataSource.chapters
: (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : [])
durationMs,
chapters,
poster: String(metadataSource?.poster || job?.poster_url || '').trim() || null
};
}
@@ -682,25 +731,62 @@ function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePla
settings?.output_template
|| audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE
).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
const chapterOutputTemplate = String(
settings?.output_chapter_template_audiobook
|| audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE
).trim() || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE;
const outputFormat = audiobookService.normalizeOutputFormat(
encodePlan?.format || settings?.output_extension || 'mp3'
);
const preferredFinalOutputPath = audiobookService.buildOutputPath(
metadata,
movieDir,
outputTemplate,
outputFormat
);
const numericJobId = Number(fallbackJobId || job?.id || 0);
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
? `Incomplete_job-${numericJobId}`
: 'Incomplete_job-unknown';
const incompleteOutputPath = path.join(movieDir, incompleteFolder, path.basename(preferredFinalOutputPath));
return {
const incompleteBaseDir = path.join(movieDir, incompleteFolder);
if (outputFormat === 'm4b') {
const preferredFinalOutputPath = audiobookService.buildOutputPath(
metadata,
movieDir,
outputTemplate,
outputFormat
);
const incompleteOutputPath = path.join(incompleteBaseDir, path.basename(preferredFinalOutputPath));
return {
metadata,
outputFormat,
preferredFinalOutputPath,
incompleteOutputPath,
preferredChapterPlan: null,
incompleteChapterPlan: null
};
}
const preferredChapterPlan = audiobookService.buildChapterOutputPlan(
metadata,
metadata.chapters,
movieDir,
chapterOutputTemplate,
outputFormat
);
const incompleteChapterPlan = audiobookService.buildChapterOutputPlan(
metadata,
preferredChapterPlan.chapters,
incompleteBaseDir,
chapterOutputTemplate,
outputFormat
);
return {
metadata: {
...metadata,
chapters: preferredChapterPlan.chapters
},
outputFormat,
preferredFinalOutputPath,
incompleteOutputPath
preferredFinalOutputPath: preferredChapterPlan.outputDir,
incompleteOutputPath: incompleteChapterPlan.outputDir,
preferredChapterPlan,
incompleteChapterPlan
};
}
@@ -10800,6 +10886,7 @@ class PipelineService extends EventEmitter {
);
const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat);
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
const ffmpegCommand = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
const job = await historyService.createJob({
discDevice: null,
@@ -10859,6 +10946,79 @@ class PipelineService extends EventEmitter {
stagedRawFilePath
});
let detectedAsin = null;
let audnexChapters = [];
try {
detectedAsin = await audnexService.extractAsinFromAaxFile(storagePaths.rawFilePath);
if (detectedAsin) {
await historyService.appendLog(job.id, 'SYSTEM', `ASIN erkannt: ${detectedAsin}`);
audnexChapters = await audnexService.fetchChaptersByAsin(detectedAsin, 'de');
if (audnexChapters.length > 0) {
await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Kapitel geladen: ${audnexChapters.length}`);
} else {
await historyService.appendLog(job.id, 'SYSTEM', `Keine Audnex-Kapitel fuer ASIN ${detectedAsin} gefunden.`);
}
} else {
await historyService.appendLog(job.id, 'SYSTEM', 'Keine ASIN in der AAX-Datei gefunden, verwende eingebettete Kapitel.');
}
} catch (audnexError) {
logger.warn('audiobook:upload:audnex-chapters-failed', {
jobId: job.id,
stagedRawFilePath: storagePaths.rawFilePath,
asin: detectedAsin,
error: errorToMeta(audnexError)
});
await historyService.appendLog(
job.id,
'SYSTEM',
`Audnex-Kapitel konnten nicht geladen werden: ${audnexError?.message || 'unknown'}`
).catch(() => {});
}
let posterUrl = null;
if (metadata?.hasEmbeddedCover && metadata?.cover) {
const coverTempPath = path.join(storagePaths.rawDir, `.job-${job.id}-cover.jpg`);
try {
const coverCommand = audiobookService.buildCoverExtractionCommand(
ffmpegCommand,
storagePaths.rawFilePath,
coverTempPath,
metadata.cover
);
await this.runCapturedCommand(coverCommand.cmd, coverCommand.args);
posterUrl = thumbnailService.storeLocalThumbnail(job.id, coverTempPath);
if (posterUrl) {
await historyService.appendLog(job.id, 'SYSTEM', 'Eingebettetes AAX-Cover erkannt und gespeichert.');
}
} catch (coverError) {
logger.warn('audiobook:upload:cover-extract-failed', {
jobId: job.id,
stagedRawFilePath: storagePaths.rawFilePath,
error: errorToMeta(coverError)
});
} finally {
try {
fs.rmSync(coverTempPath, { force: true });
} catch (_error) {
// best effort cleanup
}
}
}
const resolvedMetadata = {
...metadata,
asin: detectedAsin || null,
chapterSource: audnexChapters.length > 0 ? 'audnex' : 'probe',
chapters: audnexChapters.length > 0
? audiobookService.normalizeChapterList(audnexChapters, {
durationMs: metadata.durationMs,
fallbackTitle: metadata.title,
createFallback: false
})
: metadata.chapters,
poster: posterUrl || null
};
const makemkvInfo = this.withAnalyzeContextMediaProfile({
status: 'SUCCESS',
source: 'aax_upload',
@@ -10866,12 +11026,14 @@ class PipelineService extends EventEmitter {
mediaProfile: 'audiobook',
rawFileName: storagePaths.rawFileName,
rawFilePath: storagePaths.rawFilePath,
chapters: metadata.chapters,
detectedMetadata: metadata,
selectedMetadata: metadata,
chapters: resolvedMetadata.chapters,
detectedMetadata: resolvedMetadata,
selectedMetadata: resolvedMetadata,
probeSummary: {
durationMs: metadata.durationMs,
tagKeys: Object.keys(metadata.tags || {})
durationMs: resolvedMetadata.durationMs,
tagKeys: Object.keys(resolvedMetadata.tags || {}),
asin: detectedAsin || null,
chapterSource: resolvedMetadata.chapterSource || 'probe'
}
}, 'audiobook');
@@ -10885,16 +11047,16 @@ class PipelineService extends EventEmitter {
rawTemplate,
outputTemplate,
encodeInputPath: storagePaths.rawFilePath,
metadata,
metadata: resolvedMetadata,
reviewConfirmed: true
};
await historyService.updateJob(job.id, {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
title: metadata.title || detectedTitle,
detected_title: metadata.title || detectedTitle,
year: metadata.year ?? null,
title: resolvedMetadata.title || detectedTitle,
detected_title: resolvedMetadata.title || detectedTitle,
year: resolvedMetadata.year ?? null,
raw_path: storagePaths.rawDir,
rip_successful: 1,
makemkv_info_json: JSON.stringify(makemkvInfo),
@@ -10904,15 +11066,15 @@ class PipelineService extends EventEmitter {
encode_input_path: storagePaths.rawFilePath,
encode_review_confirmed: 1,
output_path: null,
poster_url: posterUrl || null,
error_message: null,
start_time: null,
end_time: null
});
await historyService.appendLog(
job.id,
'SYSTEM',
`Audiobook analysiert: ${metadata.title || detectedTitle} | Autor: ${metadata.author || '-'} | Format: ${outputFormat.toUpperCase()}`
`Audiobook analysiert: ${resolvedMetadata.title || detectedTitle} | Autor: ${resolvedMetadata.author || '-'} | Format: ${outputFormat.toUpperCase()}`
);
if (!startImmediately) {
@@ -11004,6 +11166,29 @@ class PipelineService extends EventEmitter {
config?.formatOptions || encodePlan?.formatOptions || {}
);
const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan);
const chapters = audiobookService.normalizeChapterList(
Array.isArray(config?.chapters) ? config.chapters : metadata.chapters,
{
durationMs: metadata.durationMs,
fallbackTitle: metadata.title,
createFallback: false
}
);
const resolvedMetadata = {
...metadata,
chapters
};
const nextMakemkvInfo = {
...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}),
chapters,
selectedMetadata: {
...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {}),
...resolvedMetadata,
poster: metadata.poster || job.poster_url || null
}
};
const nextEncodePlan = {
...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
@@ -11011,15 +11196,16 @@ class PipelineService extends EventEmitter {
mode: 'audiobook',
format,
formatOptions,
metadata,
metadata: resolvedMetadata,
reviewConfirmed: true
};
await historyService.updateJob(normalizedJobId, {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
title: metadata.title || job.title || job.detected_title || 'Audiobook',
year: metadata.year ?? job.year ?? null,
title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook',
year: resolvedMetadata.year ?? job.year ?? null,
makemkv_info_json: JSON.stringify(nextMakemkvInfo),
encode_plan_json: JSON.stringify(nextEncodePlan),
encode_review_confirmed: 1,
error_message: null,
@@ -11030,7 +11216,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
normalizedJobId,
'USER_ACTION',
`Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()}`
`Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()} | Kapitel: ${chapters.length || 0}`
);
const startResult = await this.startPreparedJob(normalizedJobId);
@@ -11107,13 +11293,29 @@ class PipelineService extends EventEmitter {
metadata,
outputFormat,
preferredFinalOutputPath,
incompleteOutputPath
incompleteOutputPath,
preferredChapterPlan,
incompleteChapterPlan
} = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId);
const formatOptions = audiobookService.normalizeFormatOptions(
outputFormat,
encodePlan?.formatOptions || {}
);
ensureDir(path.dirname(incompleteOutputPath));
const isSplitOutput = outputFormat !== 'm4b';
const activeChapters = isSplitOutput
? (Array.isArray(incompleteChapterPlan?.chapters) ? incompleteChapterPlan.chapters : [])
: (Array.isArray(metadata.chapters) ? metadata.chapters : []);
if (isSplitOutput) {
try {
fs.rmSync(incompleteOutputPath, { recursive: true, force: true });
} catch (_error) {
// best effort cleanup
}
ensureDir(incompleteOutputPath);
} else {
ensureDir(path.dirname(incompleteOutputPath));
}
await historyService.resetProcessLog(jobId);
await this.setState('ENCODING', {
@@ -11129,17 +11331,18 @@ class PipelineService extends EventEmitter {
outputPath: incompleteOutputPath,
format: outputFormat,
formatOptions,
chapters: metadata.chapters,
chapters: activeChapters,
selectedMetadata: {
title: metadata.title || job.title || job.detected_title || null,
year: metadata.year ?? job.year ?? null,
author: metadata.author || null,
narrator: metadata.narrator || null,
description: metadata.description || null,
series: metadata.series || null,
part: metadata.part || null,
chapters: Array.isArray(metadata.chapters) ? metadata.chapters : [],
chapters: activeChapters,
durationMs: metadata.durationMs || 0,
poster: job.poster_url || null
poster: metadata.poster || job.poster_url || null
},
audiobookConfig: {
format: outputFormat,
@@ -11164,7 +11367,9 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
jobId,
'SYSTEM',
`Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}`
isSplitOutput
? `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()} | Kapitel-Dateien: ${activeChapters.length || 0}`
: `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}`
);
void this.notifyPushover('encoding_started', {
@@ -11172,30 +11377,161 @@ class PipelineService extends EventEmitter {
message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
});
let temporaryChapterMetadataPath = null;
try {
const ffmpegConfig = audiobookService.buildEncodeCommand(
settings?.ffmpeg_command || 'ffmpeg',
inputPath,
incompleteOutputPath,
outputFormat,
formatOptions
);
logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
const ffmpegRunInfo = await this.runCommand({
jobId,
stage: 'ENCODING',
source: 'FFMPEG',
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
parser: audiobookService.buildProgressParser(metadata.durationMs)
});
let ffmpegRunInfo = null;
if (isSplitOutput) {
const outputFiles = Array.isArray(incompleteChapterPlan?.outputFiles)
? incompleteChapterPlan.outputFiles
: [];
if (outputFiles.length === 0) {
throw new Error('Keine Audiobook-Kapitel für den Encode verfügbar.');
}
const chapterRunInfos = [];
for (let index = 0; index < outputFiles.length; index += 1) {
const entry = outputFiles[index];
const chapter = entry?.chapter || {};
const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`;
const startPercent = Number(((index / outputFiles.length) * 100).toFixed(2));
const endPercent = Number((((index + 1) / outputFiles.length) * 100).toFixed(2));
ensureDir(path.dirname(entry.outputPath));
await historyService.appendLog(
jobId,
'SYSTEM',
`Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle} -> ${path.basename(entry.outputPath)}`
);
await this.updateProgress(
'ENCODING',
startPercent,
null,
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`,
jobId,
{
contextPatch: {
outputPath: incompleteOutputPath,
currentChapter: {
index: index + 1,
total: outputFiles.length,
title: chapterTitle
}
}
}
);
const ffmpegConfig = audiobookService.buildChapterEncodeCommand(
settings?.ffmpeg_command || 'ffmpeg',
inputPath,
entry.outputPath,
outputFormat,
formatOptions,
metadata,
chapter,
outputFiles.length
);
const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
const scaledParser = baseParser
? (line) => {
const progress = baseParser(line);
if (!progress || progress.percent == null) {
return null;
}
const scaledPercent = startPercent + ((endPercent - startPercent) * (progress.percent / 100));
return {
percent: Number(scaledPercent.toFixed(2)),
eta: null
};
}
: null;
logger.info('audiobook:encode:chapter-command', {
jobId,
chapterIndex: index + 1,
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args
});
const chapterRunInfo = await this.runCommand({
jobId,
stage: 'ENCODING',
source: 'FFMPEG',
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
parser: scaledParser
});
chapterRunInfos.push({
...chapterRunInfo,
chapterIndex: index + 1,
chapterTitle,
outputPath: entry.outputPath
});
}
ffmpegRunInfo = {
source: 'FFMPEG',
stage: 'ENCODING',
cmd: String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg',
args: ['<split-by-chapter>'],
startedAt: chapterRunInfos[0]?.startedAt || nowIso(),
endedAt: chapterRunInfos[chapterRunInfos.length - 1]?.endedAt || nowIso(),
durationMs: chapterRunInfos.reduce((sum, item) => sum + Number(item?.durationMs || 0), 0),
status: 'SUCCESS',
exitCode: 0,
stdoutLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stdoutLines || 0), 0),
stderrLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stderrLines || 0), 0),
lastProgress: 100,
eta: null,
lastDetail: `${chapterRunInfos.length} Kapitel abgeschlossen`,
highlights: chapterRunInfos.flatMap((item) => (Array.isArray(item?.highlights) ? item.highlights : [])).slice(0, 120),
steps: chapterRunInfos
};
} else {
temporaryChapterMetadataPath = path.join(path.dirname(inputPath), `.job-${jobId}-chapters.ffmeta`);
fs.writeFileSync(
temporaryChapterMetadataPath,
audiobookService.buildChapterMetadataContent(activeChapters, metadata),
'utf8'
);
const ffmpegConfig = audiobookService.buildEncodeCommand(
settings?.ffmpeg_command || 'ffmpeg',
inputPath,
incompleteOutputPath,
outputFormat,
formatOptions,
{
chapterMetadataPath: temporaryChapterMetadataPath,
metadata
}
);
logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
ffmpegRunInfo = await this.runCommand({
jobId,
stage: 'ENCODING',
source: 'FFMPEG',
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
parser: audiobookService.buildProgressParser(metadata.durationMs)
});
}
const outputFinalization = finalizeOutputPathForCompletedEncode(
incompleteOutputPath,
preferredFinalOutputPath
);
const finalizedOutputPath = outputFinalization.outputPath;
chownRecursive(path.dirname(finalizedOutputPath), settings?.movie_dir_owner);
let ownershipTarget = path.dirname(finalizedOutputPath);
try {
const finalizedStat = fs.statSync(finalizedOutputPath);
if (finalizedStat.isDirectory()) {
ownershipTarget = finalizedOutputPath;
}
} catch (_error) {
ownershipTarget = path.dirname(finalizedOutputPath);
}
chownRecursive(ownershipTarget, settings?.movie_dir_owner);
if (outputFinalization.outputPathWithTimestamp) {
await historyService.appendLog(
@@ -11211,14 +11547,27 @@ class PipelineService extends EventEmitter {
`Audiobook-Output finalisiert: ${finalizedOutputPath}`
);
const finalizedOutputFiles = isSplitOutput
? (Array.isArray(preferredChapterPlan?.outputFiles)
? preferredChapterPlan.outputFiles.map((entry) => {
const relativePath = path.relative(preferredFinalOutputPath, entry.outputPath);
return path.join(finalizedOutputPath, relativePath);
})
: [])
: null;
const ffmpegInfo = {
...ffmpegRunInfo,
mode: 'audiobook_encode',
mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode',
format: outputFormat,
formatOptions,
metadata,
metadata: {
...metadata,
chapters: activeChapters
},
inputPath,
outputPath: finalizedOutputPath
outputPath: finalizedOutputPath,
chapterCount: activeChapters.length,
outputFiles: finalizedOutputFiles
};
await historyService.updateJob(jobId, {
@@ -11269,11 +11618,18 @@ class PipelineService extends EventEmitter {
outputPath: finalizedOutputPath
};
} catch (error) {
if (temporaryChapterMetadataPath) {
try {
fs.rmSync(temporaryChapterMetadataPath, { force: true });
} catch (_error) {
// best effort cleanup
}
}
if (error.runInfo && error.runInfo.source === 'FFMPEG') {
await historyService.updateJob(jobId, {
handbrake_info_json: JSON.stringify({
...error.runInfo,
mode: 'audiobook_encode',
mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode',
format: outputFormat,
formatOptions,
inputPath
@@ -11284,6 +11640,14 @@ class PipelineService extends EventEmitter {
await this.failJob(jobId, 'ENCODING', error);
error.jobAlreadyFailed = true;
throw error;
} finally {
if (temporaryChapterMetadataPath) {
try {
fs.rmSync(temporaryChapterMetadataPath, { force: true });
} catch (_error) {
// best effort cleanup
}
}
}
}

View File

@@ -156,6 +156,27 @@ function copyThumbnail(sourceJobId, targetJobId) {
}
}
/**
* 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.
@@ -232,6 +253,7 @@ module.exports = {
cacheJobThumbnail,
promoteJobThumbnail,
copyThumbnail,
storeLocalThumbnail,
deleteThumbnail,
getThumbnailsDir,
migrateExistingThumbnails,