0.10.0-8 Audbile Meta
This commit is contained in:
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -828,6 +828,7 @@ const SETTINGS_CATEGORY_MOVES = [
|
|||||||
{ key: 'output_template_bluray', category: 'Pfade' },
|
{ key: 'output_template_bluray', category: 'Pfade' },
|
||||||
{ key: 'output_template_dvd', category: 'Pfade' },
|
{ key: 'output_template_dvd', category: 'Pfade' },
|
||||||
{ key: 'output_template_audiobook', category: 'Pfade' },
|
{ key: 'output_template_audiobook', category: 'Pfade' },
|
||||||
|
{ key: 'output_chapter_template_audiobook', category: 'Pfade' },
|
||||||
{ key: 'audiobook_raw_template', 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_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(
|
await db.run(
|
||||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
`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)`
|
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)`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']);
|
|||||||
const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']);
|
const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']);
|
||||||
const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})';
|
const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})';
|
||||||
const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{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 = {
|
const AUDIOBOOK_FORMAT_DEFAULTS = {
|
||||||
m4b: {},
|
m4b: {},
|
||||||
flac: {
|
flac: {
|
||||||
@@ -38,6 +39,43 @@ function parseOptionalYear(value) {
|
|||||||
return Number(match[0]);
|
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) {
|
function normalizeOutputFormat(value) {
|
||||||
const format = String(value || '').trim().toLowerCase();
|
const format = String(value || '').trim().toLowerCase();
|
||||||
return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3';
|
return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3';
|
||||||
@@ -123,22 +161,141 @@ function pickTag(tags, keys = []) {
|
|||||||
return null;
|
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) {
|
function buildChapterList(probe = null) {
|
||||||
const chapters = Array.isArray(probe?.chapters) ? probe.chapters : [];
|
const chapters = Array.isArray(probe?.chapters) ? probe.chapters : [];
|
||||||
return chapters.map((chapter, index) => {
|
return chapters.map((chapter, index) => {
|
||||||
|
const chapterIndex = index + 1;
|
||||||
const tags = normalizeTagMap(chapter?.tags);
|
const tags = normalizeTagMap(chapter?.tags);
|
||||||
const startSeconds = Number(chapter?.start_time || chapter?.start || 0);
|
const startSeconds = parseOptionalNumber(chapter?.start_time);
|
||||||
const endSeconds = Number(chapter?.end_time || chapter?.end || 0);
|
const endSeconds = parseOptionalNumber(chapter?.end_time);
|
||||||
const title = tags.title || tags.chapter || `Kapitel ${index + 1}`;
|
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 {
|
return {
|
||||||
index: index + 1,
|
index: chapterIndex,
|
||||||
title,
|
title,
|
||||||
startSeconds: Number.isFinite(startSeconds) ? startSeconds : 0,
|
startSeconds: Number((startMs / 1000).toFixed(3)),
|
||||||
endSeconds: Number.isFinite(endSeconds) ? endSeconds : 0
|
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) {
|
function parseProbeOutput(rawOutput) {
|
||||||
if (!rawOutput) {
|
if (!rawOutput) {
|
||||||
return null;
|
return null;
|
||||||
@@ -156,20 +313,39 @@ function buildMetadataFromProbe(probe = null, originalName = null) {
|
|||||||
const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || '')));
|
const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || '')));
|
||||||
const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook';
|
const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook';
|
||||||
const title = pickTag(tags, ['title', 'album']) || fallbackTitle;
|
const title = pickTag(tags, ['title', 'album']) || fallbackTitle;
|
||||||
const author = pickTag(tags, ['artist', 'album_artist', 'composer']) || 'Unknown Author';
|
const author = pickTag(tags, ['author', 'artist', 'writer', 'album_artist', 'composer']) || 'Unknown Author';
|
||||||
const narrator = pickTag(tags, ['narrator', 'performer', 'comment']) || null;
|
const description = pickTag(tags, [
|
||||||
const series = pickTag(tags, ['series', 'grouping']) || null;
|
'description',
|
||||||
const part = pickTag(tags, ['part', 'disc', 'track']) || null;
|
'synopsis',
|
||||||
const year = parseOptionalYear(pickTag(tags, ['date', 'year']));
|
'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 durationSeconds = Number(format.duration || 0);
|
||||||
const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0
|
const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||||
? Math.round(durationSeconds * 1000)
|
? Math.round(durationSeconds * 1000)
|
||||||
: 0;
|
: 0;
|
||||||
const chapters = buildChapterList(probe);
|
const chapters = normalizeChapterList(buildChapterList(probe), {
|
||||||
|
durationMs,
|
||||||
|
fallbackTitle: title,
|
||||||
|
createFallback: false
|
||||||
|
});
|
||||||
|
const cover = detectCoverStream(probe);
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
narrator,
|
narrator,
|
||||||
|
description,
|
||||||
series,
|
series,
|
||||||
part,
|
part,
|
||||||
year,
|
year,
|
||||||
@@ -177,6 +353,8 @@ function buildMetadataFromProbe(probe = null, originalName = null) {
|
|||||||
artist: author,
|
artist: author,
|
||||||
durationMs,
|
durationMs,
|
||||||
chapters,
|
chapters,
|
||||||
|
cover,
|
||||||
|
hasEmbeddedCover: Boolean(cover),
|
||||||
tags
|
tags
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -189,6 +367,15 @@ function normalizeTemplateTokenKey(rawKey) {
|
|||||||
if (key === 'artist') {
|
if (key === 'artist') {
|
||||||
return 'author';
|
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;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,21 +401,27 @@ function renderTemplate(template, values) {
|
|||||||
return cleanupRenderedTemplate(rendered);
|
return cleanupRenderedTemplate(rendered);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTemplateValues(metadata = {}, format = null) {
|
function buildTemplateValues(metadata = {}, format = null, chapter = null) {
|
||||||
const author = sanitizeFileName(normalizeText(metadata.author || metadata.artist || 'Unknown Author'));
|
const chapterIndex = Number(chapter?.index || chapter?.chapterNo || 0);
|
||||||
const title = sanitizeFileName(normalizeText(metadata.title || metadata.album || 'Unknown Audiobook'));
|
const safeChapterIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 ? Math.trunc(chapterIndex) : 1;
|
||||||
const narrator = sanitizeFileName(normalizeText(metadata.narrator || ''), 'unknown');
|
const author = sanitizeTemplateValue(metadata.author || metadata.artist || 'Unknown Author', 'Unknown Author');
|
||||||
const series = sanitizeFileName(normalizeText(metadata.series || ''), 'unknown');
|
const title = sanitizeTemplateValue(metadata.title || metadata.album || 'Unknown Audiobook', 'Unknown Audiobook');
|
||||||
const part = sanitizeFileName(normalizeText(metadata.part || ''), 'unknown');
|
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) : '';
|
const year = metadata.year ? String(metadata.year) : '';
|
||||||
return {
|
return {
|
||||||
author,
|
author,
|
||||||
title,
|
title,
|
||||||
narrator: narrator === 'unknown' ? '' : narrator,
|
narrator,
|
||||||
series: series === 'unknown' ? '' : series,
|
series,
|
||||||
part: part === 'unknown' ? '' : part,
|
part,
|
||||||
year,
|
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}`);
|
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) {
|
function buildProbeCommand(ffprobeCommand, inputPath) {
|
||||||
const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe';
|
const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe';
|
||||||
return {
|
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 cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
|
||||||
const format = normalizeOutputFormat(outputFormat);
|
const format = normalizeOutputFormat(outputFormat);
|
||||||
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
|
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
|
||||||
|
const extra = options && typeof options === 'object' ? options : {};
|
||||||
const commonArgs = [
|
const commonArgs = [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', inputPath,
|
'-i', inputPath
|
||||||
|
];
|
||||||
|
if (extra.chapterMetadataPath) {
|
||||||
|
commonArgs.push('-f', 'ffmetadata', '-i', extra.chapterMetadataPath);
|
||||||
|
}
|
||||||
|
commonArgs.push(
|
||||||
'-map', '0:a:0?',
|
'-map', '0:a:0?',
|
||||||
'-map_metadata', '0',
|
'-map_metadata', '0',
|
||||||
'-map_chapters', '0',
|
'-map_chapters', extra.chapterMetadataPath ? '1' : '0',
|
||||||
'-vn',
|
'-vn',
|
||||||
'-sn',
|
'-sn',
|
||||||
'-dn'
|
'-dn'
|
||||||
];
|
);
|
||||||
let codecArgs = ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`];
|
const metadataArgs = buildMetadataArgs(extra.metadata, extra.metadataOptions);
|
||||||
if (format === 'm4b') {
|
const codecArgs = buildCodecArgs(format, normalizedOptions);
|
||||||
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)];
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
cmd,
|
cmd,
|
||||||
args: [...commonArgs, ...codecArgs, outputPath],
|
args: [...commonArgs, ...codecArgs, ...metadataArgs, outputPath],
|
||||||
|
metadataArgs,
|
||||||
formatOptions: normalizedOptions
|
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) {
|
function parseFfmpegTimestampToMs(rawValue) {
|
||||||
const value = String(rawValue || '').trim();
|
const value = String(rawValue || '').trim();
|
||||||
const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
|
const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
|
||||||
@@ -368,16 +794,22 @@ module.exports = {
|
|||||||
SUPPORTED_OUTPUT_FORMATS,
|
SUPPORTED_OUTPUT_FORMATS,
|
||||||
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
|
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
|
||||||
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
||||||
|
DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
|
||||||
AUDIOBOOK_FORMAT_DEFAULTS,
|
AUDIOBOOK_FORMAT_DEFAULTS,
|
||||||
normalizeOutputFormat,
|
normalizeOutputFormat,
|
||||||
getDefaultFormatOptions,
|
getDefaultFormatOptions,
|
||||||
normalizeFormatOptions,
|
normalizeFormatOptions,
|
||||||
isSupportedInputFile,
|
isSupportedInputFile,
|
||||||
buildMetadataFromProbe,
|
buildMetadataFromProbe,
|
||||||
|
normalizeChapterList,
|
||||||
buildRawStoragePaths,
|
buildRawStoragePaths,
|
||||||
buildOutputPath,
|
buildOutputPath,
|
||||||
|
buildChapterOutputPlan,
|
||||||
buildProbeCommand,
|
buildProbeCommand,
|
||||||
parseProbeOutput,
|
parseProbeOutput,
|
||||||
buildEncodeCommand,
|
buildEncodeCommand,
|
||||||
|
buildChapterEncodeCommand,
|
||||||
|
buildChapterMetadataContent,
|
||||||
|
buildCoverExtractionCommand,
|
||||||
buildProgressParser
|
buildProgressParser
|
||||||
};
|
};
|
||||||
|
|||||||
156
backend/src/services/audnexService.js
Normal file
156
backend/src/services/audnexService.js
Normal 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
|
||||||
|
};
|
||||||
@@ -345,7 +345,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
|
|||||||
if (hasAudiobookStructure(rawPath) || hasAudiobookStructure(encodeInputPath)) {
|
if (hasAudiobookStructure(rawPath) || hasAudiobookStructure(encodeInputPath)) {
|
||||||
return 'audiobook';
|
return 'audiobook';
|
||||||
}
|
}
|
||||||
if (String(hbInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
|
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(hbInfo?.mode || '').trim().toLowerCase())) {
|
||||||
return 'audiobook';
|
return 'audiobook';
|
||||||
}
|
}
|
||||||
if (String(plan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
if (String(plan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||||
@@ -504,12 +504,28 @@ function getConfiguredMediaPathList(settings = {}, baseKey) {
|
|||||||
return unique;
|
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 = {}) {
|
function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = {}) {
|
||||||
const mkInfo = parsed?.makemkvInfo || parseJsonSafe(job?.makemkv_info_json, null);
|
const mkInfo = parsed?.makemkvInfo || parseJsonSafe(job?.makemkv_info_json, null);
|
||||||
const miInfo = parsed?.mediainfoInfo || parseJsonSafe(job?.mediainfo_info_json, null);
|
const miInfo = parsed?.mediainfoInfo || parseJsonSafe(job?.mediainfo_info_json, null);
|
||||||
const plan = parsed?.encodePlan || parseJsonSafe(job?.encode_plan_json, null);
|
const plan = parsed?.encodePlan || parseJsonSafe(job?.encode_plan_json, null);
|
||||||
const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null);
|
const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null);
|
||||||
const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo);
|
const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo);
|
||||||
|
const directoryLikeOutput = isDirectoryLikeOutput(mediaType, plan, handbrakeInfo);
|
||||||
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
|
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
|
||||||
const rawDir = String(effectiveSettings?.raw_dir || '').trim();
|
const rawDir = String(effectiveSettings?.raw_dir || '').trim();
|
||||||
const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim();
|
const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim();
|
||||||
@@ -519,13 +535,13 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
|||||||
const effectiveRawPath = job?.raw_path
|
const effectiveRawPath = job?.raw_path
|
||||||
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
|
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
|
||||||
: (job?.raw_path || null);
|
: (job?.raw_path || null);
|
||||||
// For CD, output_path is a directory (album folder) — skip path-relocation heuristic
|
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path)
|
||||||
const effectiveOutputPath = (mediaType !== 'cd' && configuredMovieDir && job?.output_path)
|
|
||||||
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
|
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
|
||||||
: (job?.output_path || null);
|
: (job?.output_path || null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaType,
|
mediaType,
|
||||||
|
directoryLikeOutput,
|
||||||
rawDir,
|
rawDir,
|
||||||
movieDir,
|
movieDir,
|
||||||
effectiveRawPath,
|
effectiveRawPath,
|
||||||
@@ -561,11 +577,12 @@ function enrichJobRow(job, settings = null, options = {}) {
|
|||||||
const omdbInfo = parseJsonSafe(job.omdb_json, null);
|
const omdbInfo = parseJsonSafe(job.omdb_json, null);
|
||||||
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
||||||
const handbrakeInfo = resolvedPaths.handbrakeInfo;
|
const handbrakeInfo = resolvedPaths.handbrakeInfo;
|
||||||
|
const directoryLikeOutput = Boolean(resolvedPaths.directoryLikeOutput);
|
||||||
const outputStatus = includeFsChecks
|
const outputStatus = includeFsChecks
|
||||||
? (resolvedPaths.mediaType === 'cd'
|
? (directoryLikeOutput
|
||||||
? inspectDirectory(resolvedPaths.effectiveOutputPath)
|
? inspectDirectory(resolvedPaths.effectiveOutputPath)
|
||||||
: inspectOutputFile(resolvedPaths.effectiveOutputPath))
|
: inspectOutputFile(resolvedPaths.effectiveOutputPath))
|
||||||
: (resolvedPaths.mediaType === 'cd'
|
: (directoryLikeOutput
|
||||||
? buildUnknownDirectoryStatus(resolvedPaths.effectiveOutputPath)
|
? buildUnknownDirectoryStatus(resolvedPaths.effectiveOutputPath)
|
||||||
: buildUnknownFileStatus(resolvedPaths.effectiveOutputPath));
|
: buildUnknownFileStatus(resolvedPaths.effectiveOutputPath));
|
||||||
const rawStatus = includeFsChecks
|
const rawStatus = includeFsChecks
|
||||||
@@ -582,7 +599,7 @@ function enrichJobRow(job, settings = null, options = {}) {
|
|||||||
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|
||||||
|| String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
|| String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
||||||
const backupSuccess = ripSuccessful;
|
const backupSuccess = ripSuccessful;
|
||||||
const encodeSuccess = mediaType === 'cd'
|
const encodeSuccess = directoryLikeOutput
|
||||||
? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists))
|
? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists))
|
||||||
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const settingsService = require('./settingsService');
|
|||||||
const historyService = require('./historyService');
|
const historyService = require('./historyService');
|
||||||
const omdbService = require('./omdbService');
|
const omdbService = require('./omdbService');
|
||||||
const musicBrainzService = require('./musicBrainzService');
|
const musicBrainzService = require('./musicBrainzService');
|
||||||
|
const audnexService = require('./audnexService');
|
||||||
const cdRipService = require('./cdRipService');
|
const cdRipService = require('./cdRipService');
|
||||||
const audiobookService = require('./audiobookService');
|
const audiobookService = require('./audiobookService');
|
||||||
const scriptService = require('./scriptService');
|
const scriptService = require('./scriptService');
|
||||||
@@ -495,6 +496,12 @@ function withTimestampBeforeExtension(targetPath, suffix) {
|
|||||||
return path.join(dir, `${base}_${suffix}${ext}`);
|
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) {
|
function resolveOutputTemplateValues(job, fallbackJobId = null) {
|
||||||
return {
|
return {
|
||||||
title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'),
|
title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'),
|
||||||
@@ -557,10 +564,21 @@ function ensureUniqueOutputPath(outputPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ts = fileTimestamp();
|
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;
|
let i = 1;
|
||||||
while (fs.existsSync(attempt)) {
|
while (fs.existsSync(attempt)) {
|
||||||
attempt = withTimestampBeforeExtension(outputPath, `${ts}-${i}`);
|
attempt = isDirectory
|
||||||
|
? withTimestampSuffix(outputPath, `${ts}-${i}`)
|
||||||
|
: withTimestampBeforeExtension(outputPath, `${ts}-${i}`);
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
return attempt;
|
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) {
|
function removeDirectoryIfEmpty(directoryPath) {
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(directoryPath);
|
const entries = fs.readdirSync(directoryPath);
|
||||||
@@ -632,7 +668,7 @@ function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFin
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureDir(path.dirname(targetPath));
|
ensureDir(path.dirname(targetPath));
|
||||||
moveFileWithFallback(sourcePath, targetPath);
|
movePathWithFallback(sourcePath, targetPath);
|
||||||
removeDirectoryIfEmpty(path.dirname(sourcePath));
|
removeDirectoryIfEmpty(path.dirname(sourcePath));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -651,21 +687,34 @@ function buildAudiobookMetadataForJob(job, makemkvInfo = null, encodePlan = null
|
|||||||
? mkInfo.selectedMetadata
|
? mkInfo.selectedMetadata
|
||||||
: (mkInfo?.detectedMetadata && typeof mkInfo.detectedMetadata === 'object' ? mkInfo.detectedMetadata : {})
|
: (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 {
|
return {
|
||||||
title: String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook',
|
title,
|
||||||
author: String(metadataSource?.author || metadataSource?.artist || '').trim() || null,
|
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,
|
narrator: String(metadataSource?.narrator || '').trim() || null,
|
||||||
|
description: String(metadataSource?.description || '').trim() || null,
|
||||||
series: String(metadataSource?.series || '').trim() || null,
|
series: String(metadataSource?.series || '').trim() || null,
|
||||||
part: String(metadataSource?.part || '').trim() || null,
|
part: String(metadataSource?.part || '').trim() || null,
|
||||||
year: Number.isFinite(Number(metadataSource?.year))
|
year: Number.isFinite(Number(metadataSource?.year))
|
||||||
? Math.trunc(Number(metadataSource.year))
|
? Math.trunc(Number(metadataSource.year))
|
||||||
: (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null),
|
: (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null),
|
||||||
durationMs: Number.isFinite(Number(metadataSource?.durationMs))
|
durationMs,
|
||||||
? Number(metadataSource.durationMs)
|
chapters,
|
||||||
: 0,
|
poster: String(metadataSource?.poster || job?.poster_url || '').trim() || null
|
||||||
chapters: Array.isArray(metadataSource?.chapters)
|
|
||||||
? metadataSource.chapters
|
|
||||||
: (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : [])
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,25 +731,62 @@ function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePla
|
|||||||
settings?.output_template
|
settings?.output_template
|
||||||
|| audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE
|
|| audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE
|
||||||
).trim() || 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(
|
const outputFormat = audiobookService.normalizeOutputFormat(
|
||||||
encodePlan?.format || settings?.output_extension || 'mp3'
|
encodePlan?.format || settings?.output_extension || 'mp3'
|
||||||
);
|
);
|
||||||
const preferredFinalOutputPath = audiobookService.buildOutputPath(
|
|
||||||
metadata,
|
|
||||||
movieDir,
|
|
||||||
outputTemplate,
|
|
||||||
outputFormat
|
|
||||||
);
|
|
||||||
const numericJobId = Number(fallbackJobId || job?.id || 0);
|
const numericJobId = Number(fallbackJobId || job?.id || 0);
|
||||||
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
|
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
|
||||||
? `Incomplete_job-${numericJobId}`
|
? `Incomplete_job-${numericJobId}`
|
||||||
: 'Incomplete_job-unknown';
|
: 'Incomplete_job-unknown';
|
||||||
const incompleteOutputPath = path.join(movieDir, incompleteFolder, path.basename(preferredFinalOutputPath));
|
const incompleteBaseDir = path.join(movieDir, incompleteFolder);
|
||||||
return {
|
|
||||||
|
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,
|
||||||
|
metadata.chapters,
|
||||||
|
movieDir,
|
||||||
|
chapterOutputTemplate,
|
||||||
|
outputFormat
|
||||||
|
);
|
||||||
|
const incompleteChapterPlan = audiobookService.buildChapterOutputPlan(
|
||||||
|
metadata,
|
||||||
|
preferredChapterPlan.chapters,
|
||||||
|
incompleteBaseDir,
|
||||||
|
chapterOutputTemplate,
|
||||||
|
outputFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
chapters: preferredChapterPlan.chapters
|
||||||
|
},
|
||||||
outputFormat,
|
outputFormat,
|
||||||
preferredFinalOutputPath,
|
preferredFinalOutputPath: preferredChapterPlan.outputDir,
|
||||||
incompleteOutputPath
|
incompleteOutputPath: incompleteChapterPlan.outputDir,
|
||||||
|
preferredChapterPlan,
|
||||||
|
incompleteChapterPlan
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10800,6 +10886,7 @@ class PipelineService extends EventEmitter {
|
|||||||
);
|
);
|
||||||
const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat);
|
const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat);
|
||||||
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||||
|
const ffmpegCommand = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
|
||||||
|
|
||||||
const job = await historyService.createJob({
|
const job = await historyService.createJob({
|
||||||
discDevice: null,
|
discDevice: null,
|
||||||
@@ -10859,6 +10946,79 @@ class PipelineService extends EventEmitter {
|
|||||||
stagedRawFilePath
|
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({
|
const makemkvInfo = this.withAnalyzeContextMediaProfile({
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
source: 'aax_upload',
|
source: 'aax_upload',
|
||||||
@@ -10866,12 +11026,14 @@ class PipelineService extends EventEmitter {
|
|||||||
mediaProfile: 'audiobook',
|
mediaProfile: 'audiobook',
|
||||||
rawFileName: storagePaths.rawFileName,
|
rawFileName: storagePaths.rawFileName,
|
||||||
rawFilePath: storagePaths.rawFilePath,
|
rawFilePath: storagePaths.rawFilePath,
|
||||||
chapters: metadata.chapters,
|
chapters: resolvedMetadata.chapters,
|
||||||
detectedMetadata: metadata,
|
detectedMetadata: resolvedMetadata,
|
||||||
selectedMetadata: metadata,
|
selectedMetadata: resolvedMetadata,
|
||||||
probeSummary: {
|
probeSummary: {
|
||||||
durationMs: metadata.durationMs,
|
durationMs: resolvedMetadata.durationMs,
|
||||||
tagKeys: Object.keys(metadata.tags || {})
|
tagKeys: Object.keys(resolvedMetadata.tags || {}),
|
||||||
|
asin: detectedAsin || null,
|
||||||
|
chapterSource: resolvedMetadata.chapterSource || 'probe'
|
||||||
}
|
}
|
||||||
}, 'audiobook');
|
}, 'audiobook');
|
||||||
|
|
||||||
@@ -10885,16 +11047,16 @@ class PipelineService extends EventEmitter {
|
|||||||
rawTemplate,
|
rawTemplate,
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
encodeInputPath: storagePaths.rawFilePath,
|
encodeInputPath: storagePaths.rawFilePath,
|
||||||
metadata,
|
metadata: resolvedMetadata,
|
||||||
reviewConfirmed: true
|
reviewConfirmed: true
|
||||||
};
|
};
|
||||||
|
|
||||||
await historyService.updateJob(job.id, {
|
await historyService.updateJob(job.id, {
|
||||||
status: 'READY_TO_START',
|
status: 'READY_TO_START',
|
||||||
last_state: 'READY_TO_START',
|
last_state: 'READY_TO_START',
|
||||||
title: metadata.title || detectedTitle,
|
title: resolvedMetadata.title || detectedTitle,
|
||||||
detected_title: metadata.title || detectedTitle,
|
detected_title: resolvedMetadata.title || detectedTitle,
|
||||||
year: metadata.year ?? null,
|
year: resolvedMetadata.year ?? null,
|
||||||
raw_path: storagePaths.rawDir,
|
raw_path: storagePaths.rawDir,
|
||||||
rip_successful: 1,
|
rip_successful: 1,
|
||||||
makemkv_info_json: JSON.stringify(makemkvInfo),
|
makemkv_info_json: JSON.stringify(makemkvInfo),
|
||||||
@@ -10904,15 +11066,15 @@ class PipelineService extends EventEmitter {
|
|||||||
encode_input_path: storagePaths.rawFilePath,
|
encode_input_path: storagePaths.rawFilePath,
|
||||||
encode_review_confirmed: 1,
|
encode_review_confirmed: 1,
|
||||||
output_path: null,
|
output_path: null,
|
||||||
|
poster_url: posterUrl || null,
|
||||||
error_message: null,
|
error_message: null,
|
||||||
start_time: null,
|
|
||||||
end_time: null
|
end_time: null
|
||||||
});
|
});
|
||||||
|
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
job.id,
|
job.id,
|
||||||
'SYSTEM',
|
'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) {
|
if (!startImmediately) {
|
||||||
@@ -11004,6 +11166,29 @@ class PipelineService extends EventEmitter {
|
|||||||
config?.formatOptions || encodePlan?.formatOptions || {}
|
config?.formatOptions || encodePlan?.formatOptions || {}
|
||||||
);
|
);
|
||||||
const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan);
|
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 = {
|
const nextEncodePlan = {
|
||||||
...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
|
...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
|
||||||
@@ -11011,15 +11196,16 @@ class PipelineService extends EventEmitter {
|
|||||||
mode: 'audiobook',
|
mode: 'audiobook',
|
||||||
format,
|
format,
|
||||||
formatOptions,
|
formatOptions,
|
||||||
metadata,
|
metadata: resolvedMetadata,
|
||||||
reviewConfirmed: true
|
reviewConfirmed: true
|
||||||
};
|
};
|
||||||
|
|
||||||
await historyService.updateJob(normalizedJobId, {
|
await historyService.updateJob(normalizedJobId, {
|
||||||
status: 'READY_TO_START',
|
status: 'READY_TO_START',
|
||||||
last_state: 'READY_TO_START',
|
last_state: 'READY_TO_START',
|
||||||
title: metadata.title || job.title || job.detected_title || 'Audiobook',
|
title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook',
|
||||||
year: metadata.year ?? job.year ?? null,
|
year: resolvedMetadata.year ?? job.year ?? null,
|
||||||
|
makemkv_info_json: JSON.stringify(nextMakemkvInfo),
|
||||||
encode_plan_json: JSON.stringify(nextEncodePlan),
|
encode_plan_json: JSON.stringify(nextEncodePlan),
|
||||||
encode_review_confirmed: 1,
|
encode_review_confirmed: 1,
|
||||||
error_message: null,
|
error_message: null,
|
||||||
@@ -11030,7 +11216,7 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
normalizedJobId,
|
normalizedJobId,
|
||||||
'USER_ACTION',
|
'USER_ACTION',
|
||||||
`Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()}`
|
`Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()} | Kapitel: ${chapters.length || 0}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const startResult = await this.startPreparedJob(normalizedJobId);
|
const startResult = await this.startPreparedJob(normalizedJobId);
|
||||||
@@ -11107,13 +11293,29 @@ class PipelineService extends EventEmitter {
|
|||||||
metadata,
|
metadata,
|
||||||
outputFormat,
|
outputFormat,
|
||||||
preferredFinalOutputPath,
|
preferredFinalOutputPath,
|
||||||
incompleteOutputPath
|
incompleteOutputPath,
|
||||||
|
preferredChapterPlan,
|
||||||
|
incompleteChapterPlan
|
||||||
} = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId);
|
} = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId);
|
||||||
const formatOptions = audiobookService.normalizeFormatOptions(
|
const formatOptions = audiobookService.normalizeFormatOptions(
|
||||||
outputFormat,
|
outputFormat,
|
||||||
encodePlan?.formatOptions || {}
|
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 historyService.resetProcessLog(jobId);
|
||||||
await this.setState('ENCODING', {
|
await this.setState('ENCODING', {
|
||||||
@@ -11129,17 +11331,18 @@ class PipelineService extends EventEmitter {
|
|||||||
outputPath: incompleteOutputPath,
|
outputPath: incompleteOutputPath,
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
formatOptions,
|
formatOptions,
|
||||||
chapters: metadata.chapters,
|
chapters: activeChapters,
|
||||||
selectedMetadata: {
|
selectedMetadata: {
|
||||||
title: metadata.title || job.title || job.detected_title || null,
|
title: metadata.title || job.title || job.detected_title || null,
|
||||||
year: metadata.year ?? job.year ?? null,
|
year: metadata.year ?? job.year ?? null,
|
||||||
author: metadata.author || null,
|
author: metadata.author || null,
|
||||||
narrator: metadata.narrator || null,
|
narrator: metadata.narrator || null,
|
||||||
|
description: metadata.description || null,
|
||||||
series: metadata.series || null,
|
series: metadata.series || null,
|
||||||
part: metadata.part || null,
|
part: metadata.part || null,
|
||||||
chapters: Array.isArray(metadata.chapters) ? metadata.chapters : [],
|
chapters: activeChapters,
|
||||||
durationMs: metadata.durationMs || 0,
|
durationMs: metadata.durationMs || 0,
|
||||||
poster: job.poster_url || null
|
poster: metadata.poster || job.poster_url || null
|
||||||
},
|
},
|
||||||
audiobookConfig: {
|
audiobookConfig: {
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
@@ -11164,7 +11367,9 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
'SYSTEM',
|
'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', {
|
void this.notifyPushover('encoding_started', {
|
||||||
@@ -11172,30 +11377,161 @@ class PipelineService extends EventEmitter {
|
|||||||
message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let temporaryChapterMetadataPath = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ffmpegConfig = audiobookService.buildEncodeCommand(
|
let ffmpegRunInfo = null;
|
||||||
settings?.ffmpeg_command || 'ffmpeg',
|
if (isSplitOutput) {
|
||||||
inputPath,
|
const outputFiles = Array.isArray(incompleteChapterPlan?.outputFiles)
|
||||||
incompleteOutputPath,
|
? incompleteChapterPlan.outputFiles
|
||||||
outputFormat,
|
: [];
|
||||||
formatOptions
|
if (outputFiles.length === 0) {
|
||||||
);
|
throw new Error('Keine Audiobook-Kapitel für den Encode verfügbar.');
|
||||||
logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
|
}
|
||||||
const ffmpegRunInfo = await this.runCommand({
|
|
||||||
jobId,
|
const chapterRunInfos = [];
|
||||||
stage: 'ENCODING',
|
for (let index = 0; index < outputFiles.length; index += 1) {
|
||||||
source: 'FFMPEG',
|
const entry = outputFiles[index];
|
||||||
cmd: ffmpegConfig.cmd,
|
const chapter = entry?.chapter || {};
|
||||||
args: ffmpegConfig.args,
|
const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`;
|
||||||
parser: audiobookService.buildProgressParser(metadata.durationMs)
|
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(
|
const outputFinalization = finalizeOutputPathForCompletedEncode(
|
||||||
incompleteOutputPath,
|
incompleteOutputPath,
|
||||||
preferredFinalOutputPath
|
preferredFinalOutputPath
|
||||||
);
|
);
|
||||||
const finalizedOutputPath = outputFinalization.outputPath;
|
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) {
|
if (outputFinalization.outputPathWithTimestamp) {
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
@@ -11211,14 +11547,27 @@ class PipelineService extends EventEmitter {
|
|||||||
`Audiobook-Output finalisiert: ${finalizedOutputPath}`
|
`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 = {
|
const ffmpegInfo = {
|
||||||
...ffmpegRunInfo,
|
...ffmpegRunInfo,
|
||||||
mode: 'audiobook_encode',
|
mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode',
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
formatOptions,
|
formatOptions,
|
||||||
metadata,
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
chapters: activeChapters
|
||||||
|
},
|
||||||
inputPath,
|
inputPath,
|
||||||
outputPath: finalizedOutputPath
|
outputPath: finalizedOutputPath,
|
||||||
|
chapterCount: activeChapters.length,
|
||||||
|
outputFiles: finalizedOutputFiles
|
||||||
};
|
};
|
||||||
|
|
||||||
await historyService.updateJob(jobId, {
|
await historyService.updateJob(jobId, {
|
||||||
@@ -11269,11 +11618,18 @@ class PipelineService extends EventEmitter {
|
|||||||
outputPath: finalizedOutputPath
|
outputPath: finalizedOutputPath
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (temporaryChapterMetadataPath) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(temporaryChapterMetadataPath, { force: true });
|
||||||
|
} catch (_error) {
|
||||||
|
// best effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
if (error.runInfo && error.runInfo.source === 'FFMPEG') {
|
if (error.runInfo && error.runInfo.source === 'FFMPEG') {
|
||||||
await historyService.updateJob(jobId, {
|
await historyService.updateJob(jobId, {
|
||||||
handbrake_info_json: JSON.stringify({
|
handbrake_info_json: JSON.stringify({
|
||||||
...error.runInfo,
|
...error.runInfo,
|
||||||
mode: 'audiobook_encode',
|
mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode',
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
formatOptions,
|
formatOptions,
|
||||||
inputPath
|
inputPath
|
||||||
@@ -11284,6 +11640,14 @@ class PipelineService extends EventEmitter {
|
|||||||
await this.failJob(jobId, 'ENCODING', error);
|
await this.failJob(jobId, 'ENCODING', error);
|
||||||
error.jobAlreadyFailed = true;
|
error.jobAlreadyFailed = true;
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (temporaryChapterMetadataPath) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(temporaryChapterMetadataPath, { force: true });
|
||||||
|
} catch (_error) {
|
||||||
|
// best effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* Löscht Cache- und persistente Thumbnail-Datei eines Jobs.
|
||||||
* Wird beim Löschen eines Jobs aufgerufen.
|
* Wird beim Löschen eines Jobs aufgerufen.
|
||||||
@@ -232,6 +253,7 @@ module.exports = {
|
|||||||
cacheJobThumbnail,
|
cacheJobThumbnail,
|
||||||
promoteJobThumbnail,
|
promoteJobThumbnail,
|
||||||
copyThumbnail,
|
copyThumbnail,
|
||||||
|
storeLocalThumbnail,
|
||||||
deleteThumbnail,
|
deleteThumbnail,
|
||||||
getThumbnailsDir,
|
getThumbnailsDir,
|
||||||
migrateExistingThumbnails,
|
migrateExistingThumbnails,
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function App() {
|
|||||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||||
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
||||||
|
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||||
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
|
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -151,6 +152,7 @@ function App() {
|
|||||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||||
await refreshPipeline().catch(() => null);
|
await refreshPipeline().catch(() => null);
|
||||||
setDashboardJobsRefreshToken((prev) => prev + 1);
|
setDashboardJobsRefreshToken((prev) => prev + 1);
|
||||||
|
setHistoryJobsRefreshToken((prev) => prev + 1);
|
||||||
if (uploadedJobId) {
|
if (uploadedJobId) {
|
||||||
setPendingDashboardJobId(uploadedJobId);
|
setPendingDashboardJobId(uploadedJobId);
|
||||||
}
|
}
|
||||||
@@ -391,7 +393,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/history" element={<HistoryPage />} />
|
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||||
<Route path="/database" element={<DatabasePage />} />
|
<Route path="/database" element={<DatabasePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import { Slider } from 'primereact/slider';
|
import { Slider } from 'primereact/slider';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { ProgressBar } from 'primereact/progressbar';
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { InputText } from 'primereact/inputtext';
|
||||||
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
|
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
|
||||||
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
||||||
|
|
||||||
@@ -49,6 +51,35 @@ function formatChapterTime(secondsValue) {
|
|||||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateDescription(value, maxLength = 220) {
|
||||||
|
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!normalized || normalized.length <= maxLength) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return `${normalized.slice(0, maxLength).trim()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChapterTitle(value, index) {
|
||||||
|
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
return normalized || `Kapitel ${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditableChapters(chapters = []) {
|
||||||
|
const source = Array.isArray(chapters) ? chapters : [];
|
||||||
|
return source.map((chapter, index) => {
|
||||||
|
const safeIndex = Number(chapter?.index);
|
||||||
|
const resolvedIndex = Number.isFinite(safeIndex) && safeIndex > 0 ? Math.trunc(safeIndex) : index + 1;
|
||||||
|
return {
|
||||||
|
index: resolvedIndex,
|
||||||
|
title: normalizeChapterTitle(chapter?.title, resolvedIndex),
|
||||||
|
startSeconds: Number(chapter?.startSeconds || 0),
|
||||||
|
endSeconds: Number(chapter?.endSeconds || 0),
|
||||||
|
startMs: Number(chapter?.startMs || 0),
|
||||||
|
endMs: Number(chapter?.endMs || 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function FormatField({ field, value, onChange, disabled }) {
|
function FormatField({ field, value, onChange, disabled }) {
|
||||||
if (field.type === 'slider') {
|
if (field.type === 'slider') {
|
||||||
return (
|
return (
|
||||||
@@ -111,6 +142,8 @@ export default function AudiobookConfigPanel({
|
|||||||
: (Array.isArray(context?.chapters) ? context.chapters : []);
|
: (Array.isArray(context?.chapters) ? context.chapters : []);
|
||||||
const [format, setFormat] = useState(initialFormat);
|
const [format, setFormat] = useState(initialFormat);
|
||||||
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
|
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
|
||||||
|
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
|
||||||
|
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextFormat = normalizeFormat(audiobookConfig?.format);
|
const nextFormat = normalizeFormat(audiobookConfig?.format);
|
||||||
@@ -118,6 +151,10 @@ export default function AudiobookConfigPanel({
|
|||||||
setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions));
|
setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions));
|
||||||
}, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]);
|
}, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditableChapters(normalizeEditableChapters(chapters));
|
||||||
|
}, [jobId, JSON.stringify(chapters || [])]);
|
||||||
|
|
||||||
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
|
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
|
||||||
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
|
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
|
||||||
const isRunning = state === 'ENCODING';
|
const isRunning = state === 'ENCODING';
|
||||||
@@ -125,6 +162,9 @@ export default function AudiobookConfigPanel({
|
|||||||
const outputPath = String(context?.outputPath || '').trim() || null;
|
const outputPath = String(context?.outputPath || '').trim() || null;
|
||||||
const statusLabel = getStatusLabel(state);
|
const statusLabel = getStatusLabel(state);
|
||||||
const statusSeverity = getStatusSeverity(state);
|
const statusSeverity = getStatusSeverity(state);
|
||||||
|
const description = String(metadata?.description || '').trim();
|
||||||
|
const descriptionPreview = truncateDescription(description);
|
||||||
|
const posterUrl = String(metadata?.poster || '').trim() || null;
|
||||||
|
|
||||||
const visibleFields = useMemo(
|
const visibleFields = useMemo(
|
||||||
() => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []),
|
() => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []),
|
||||||
@@ -134,19 +174,45 @@ export default function AudiobookConfigPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="audiobook-config-panel">
|
<div className="audiobook-config-panel">
|
||||||
<div className="audiobook-config-head">
|
<div className="audiobook-config-head">
|
||||||
<div className="device-meta">
|
<div className="audiobook-config-summary">
|
||||||
<div><strong>Titel:</strong> {metadata?.title || '-'}</div>
|
{posterUrl ? (
|
||||||
<div><strong>Autor:</strong> {metadata?.author || '-'}</div>
|
<div className="audiobook-config-cover">
|
||||||
<div><strong>Sprecher:</strong> {metadata?.narrator || '-'}</div>
|
<img src={posterUrl} alt={metadata?.title || 'Audiobook Cover'} />
|
||||||
<div><strong>Serie:</strong> {metadata?.series || '-'}</div>
|
</div>
|
||||||
<div><strong>Teil:</strong> {metadata?.part || '-'}</div>
|
) : null}
|
||||||
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
|
|
||||||
<div><strong>Kapitel:</strong> {chapters.length || '-'}</div>
|
<div className="device-meta">
|
||||||
|
<div><strong>Titel:</strong> {metadata?.title || '-'}</div>
|
||||||
|
<div><strong>Autor:</strong> {metadata?.author || '-'}</div>
|
||||||
|
<div><strong>Sprecher:</strong> {metadata?.narrator || '-'}</div>
|
||||||
|
<div><strong>Serie:</strong> {metadata?.series || '-'}</div>
|
||||||
|
<div><strong>Teil:</strong> {metadata?.part || '-'}</div>
|
||||||
|
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
|
||||||
|
<div><strong>Kapitel:</strong> {editableChapters.length || '-'}</div>
|
||||||
|
{descriptionPreview ? (
|
||||||
|
<div className="audiobook-description-preview">
|
||||||
|
<strong>Beschreibung:</strong>
|
||||||
|
<span>{descriptionPreview}</span>
|
||||||
|
{description.length > descriptionPreview.length ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
label="Vollständig anzeigen"
|
||||||
|
icon="pi pi-external-link"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
onClick={() => setDescriptionDialogVisible(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="audiobook-config-tags">
|
<div className="audiobook-config-tags">
|
||||||
<Tag value={statusLabel} severity={statusSeverity} />
|
<Tag value={statusLabel} severity={statusSeverity} />
|
||||||
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
|
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
|
||||||
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
|
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
|
||||||
|
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,23 +250,36 @@ export default function AudiobookConfigPanel({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<small>
|
<small>
|
||||||
Metadaten und Kapitel werden aus der AAX-Datei gelesen. Erst nach Klick auf Start wird `ffmpeg` ausgeführt.
|
<code>m4b</code> erzeugt eine Datei mit bearbeitbaren Kapiteln. <code>mp3</code> und <code>flac</code> werden kapitelweise als einzelne Dateien erzeugt.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="audiobook-config-chapters">
|
<div className="audiobook-config-chapters">
|
||||||
<h4>Kapitelvorschau</h4>
|
<h4>Kapitel</h4>
|
||||||
{chapters.length === 0 ? (
|
{editableChapters.length === 0 ? (
|
||||||
<small>Keine Kapitel in der Quelle erkannt.</small>
|
<small>Keine Kapitel in der Quelle erkannt.</small>
|
||||||
) : (
|
) : (
|
||||||
<div className="audiobook-chapter-list">
|
<div className="audiobook-chapter-list">
|
||||||
{chapters.map((chapter, index) => (
|
{editableChapters.map((chapter, index) => (
|
||||||
<div key={`${chapter?.index || index}-${chapter?.title || ''}`} className="audiobook-chapter-row">
|
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
|
||||||
<strong>#{chapter?.index || index + 1}</strong>
|
<div className="audiobook-chapter-row-head">
|
||||||
<span>{chapter?.title || `Kapitel ${index + 1}`}</span>
|
<strong>#{chapter.index || index + 1}</strong>
|
||||||
<small>
|
<small>
|
||||||
{formatChapterTime(chapter?.startSeconds)} - {formatChapterTime(chapter?.endSeconds)}
|
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
|
||||||
</small>
|
</small>
|
||||||
|
</div>
|
||||||
|
<InputText
|
||||||
|
value={chapter.title}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextTitle = event.target.value;
|
||||||
|
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
|
||||||
|
entryIndex === index
|
||||||
|
? { ...entry, title: nextTitle }
|
||||||
|
: entry
|
||||||
|
)));
|
||||||
|
}}
|
||||||
|
disabled={busy || isRunning}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +306,18 @@ export default function AudiobookConfigPanel({
|
|||||||
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
|
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
|
||||||
icon="pi pi-play"
|
icon="pi pi-play"
|
||||||
severity="success"
|
severity="success"
|
||||||
onClick={() => onStart?.({ format, formatOptions })}
|
onClick={() => onStart?.({
|
||||||
|
format,
|
||||||
|
formatOptions,
|
||||||
|
chapters: editableChapters.map((chapter, index) => ({
|
||||||
|
index: chapter.index || index + 1,
|
||||||
|
title: normalizeChapterTitle(chapter.title, chapter.index || index + 1),
|
||||||
|
startSeconds: chapter.startSeconds,
|
||||||
|
endSeconds: chapter.endSeconds,
|
||||||
|
startMs: chapter.startMs,
|
||||||
|
endMs: chapter.endMs
|
||||||
|
}))
|
||||||
|
})}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
disabled={!jobId}
|
disabled={!jobId}
|
||||||
/>
|
/>
|
||||||
@@ -256,6 +346,17 @@ export default function AudiobookConfigPanel({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
header="Beschreibung"
|
||||||
|
visible={descriptionDialogVisible}
|
||||||
|
style={{ width: 'min(48rem, 92vw)' }}
|
||||||
|
onHide={() => setDescriptionDialogVisible(false)}
|
||||||
|
>
|
||||||
|
<div className="audiobook-description-dialog">
|
||||||
|
<p>{description || 'Keine Beschreibung vorhanden.'}</p>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ function buildToolSections(settings) {
|
|||||||
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
||||||
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
||||||
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
|
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
|
||||||
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'audiobook_raw_template'];
|
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
|
||||||
const LOG_PATH_KEYS = ['log_dir'];
|
const LOG_PATH_KEYS = ['log_dir'];
|
||||||
|
|
||||||
function buildSectionsForCategory(categoryName, settings) {
|
function buildSectionsForCategory(categoryName, settings) {
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ function resolveMediaType(job) {
|
|||||||
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
|
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
|
||||||
return 'audiobook';
|
return 'audiobook';
|
||||||
}
|
}
|
||||||
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||||
@@ -320,9 +320,12 @@ function resolveCdDetails(job) {
|
|||||||
function resolveAudiobookDetails(job) {
|
function resolveAudiobookDetails(job) {
|
||||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
||||||
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||||
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
const selectedMetadata = {
|
||||||
? makemkvInfo.selectedMetadata
|
...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
|
? makemkvInfo.selectedMetadata
|
||||||
|
: {}),
|
||||||
|
...(encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {})
|
||||||
|
};
|
||||||
const chapters = Array.isArray(selectedMetadata?.chapters)
|
const chapters = Array.isArray(selectedMetadata?.chapters)
|
||||||
? selectedMetadata.chapters
|
? selectedMetadata.chapters
|
||||||
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
|
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
|
||||||
@@ -713,7 +716,7 @@ export default function JobDetailDialog({
|
|||||||
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>{isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? 'Audiobook-Datei vorhanden:' : 'Movie Datei vorhanden:')}</strong> <BoolState value={job.outputStatus?.exists} />
|
<strong>{isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? (job.outputStatus?.isDirectory ? 'Audiobook-Dateien vorhanden:' : 'Audiobook-Datei vorhanden:') : 'Movie Datei vorhanden:')}</strong> <BoolState value={job.outputStatus?.exists} />
|
||||||
</div>
|
</div>
|
||||||
{isCd ? (
|
{isCd ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ function resolveMediaType(job) {
|
|||||||
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
|
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
|
||||||
return 'audiobook';
|
return 'audiobook';
|
||||||
}
|
}
|
||||||
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||||
@@ -591,20 +591,26 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
|
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
|
||||||
: '<temp>/trackNN.cdda.wav';
|
: '<temp>/trackNN.cdda.wav';
|
||||||
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
||||||
const audiobookSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
const audiobookSelectedMeta = {
|
||||||
? makemkvInfo.selectedMetadata
|
...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
|
? makemkvInfo.selectedMetadata
|
||||||
|
: {}),
|
||||||
|
...(encodePlan?.metadata && typeof encodePlan.metadata === 'object'
|
||||||
|
? encodePlan.metadata
|
||||||
|
: {})
|
||||||
|
};
|
||||||
const selectedMetadata = resolvedMediaType === 'audiobook'
|
const selectedMetadata = resolvedMediaType === 'audiobook'
|
||||||
? {
|
? {
|
||||||
title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null,
|
title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null,
|
||||||
author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
|
author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
|
||||||
narrator: audiobookSelectedMeta?.narrator || null,
|
narrator: audiobookSelectedMeta?.narrator || null,
|
||||||
|
description: audiobookSelectedMeta?.description || null,
|
||||||
series: audiobookSelectedMeta?.series || null,
|
series: audiobookSelectedMeta?.series || null,
|
||||||
part: audiobookSelectedMeta?.part || null,
|
part: audiobookSelectedMeta?.part || null,
|
||||||
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
|
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
|
||||||
chapters: Array.isArray(audiobookSelectedMeta?.chapters) ? audiobookSelectedMeta.chapters : [],
|
chapters: Array.isArray(audiobookSelectedMeta?.chapters) ? audiobookSelectedMeta.chapters : [],
|
||||||
durationMs: audiobookSelectedMeta?.durationMs || 0,
|
durationMs: audiobookSelectedMeta?.durationMs || 0,
|
||||||
poster: job?.poster_url || null
|
poster: audiobookSelectedMeta?.poster || job?.poster_url || null
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function resolveMediaType(row) {
|
|||||||
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
|
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
if (String(row?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
|
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(row?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
|
||||||
return 'audiobook';
|
return 'audiobook';
|
||||||
}
|
}
|
||||||
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||||
@@ -347,7 +347,7 @@ function formatDateTime(value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HistoryPage() {
|
export default function HistoryPage({ refreshToken = 0 }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [jobs, setJobs] = useState([]);
|
const [jobs, setJobs] = useState([]);
|
||||||
@@ -437,7 +437,7 @@ export default function HistoryPage() {
|
|||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [search, status]);
|
}, [search, status, refreshToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
|
|||||||
@@ -3538,6 +3538,31 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audiobook-config-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audiobook-config-cover {
|
||||||
|
width: 120px;
|
||||||
|
min-width: 120px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--surface-border, #d8d3c6);
|
||||||
|
background: var(--surface-ground, #f6f1e8);
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audiobook-config-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.audiobook-config-tags {
|
.audiobook-config-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -3557,6 +3582,17 @@ body {
|
|||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audiobook-description-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audiobook-description-preview .p-button {
|
||||||
|
justify-self: flex-start;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.audiobook-config-chapters h4 {
|
.audiobook-config-chapters h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -3582,6 +3618,23 @@ body {
|
|||||||
color: var(--rip-muted, #666);
|
color: var(--rip-muted, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audiobook-chapter-row-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audiobook-chapter-row-editable {
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audiobook-description-dialog p {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.audiobook-output-path {
|
.audiobook-output-path {
|
||||||
padding: 0.75rem 0.85rem;
|
padding: 0.75rem 0.85rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -3591,6 +3644,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.audiobook-config-summary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audiobook-config-cover {
|
||||||
|
width: 96px;
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
.audiobook-config-grid {
|
.audiobook-config-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.0-7",
|
"version": "0.10.0-8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user