0.10.0-8 Audbile Meta
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user