diff --git a/backend/package-lock.json b/backend/package-lock.json index a7298fa..2111643 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.10.0-7", + "version": "0.10.0-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.10.0-7", + "version": "0.10.0-8", "dependencies": { "cors": "^2.8.5", "dotenv": "^16.4.7", diff --git a/backend/package.json b/backend/package.json index a9bddab..48b8739 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.10.0-7", + "version": "0.10.0-8", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index ef5d22f..22f8b24 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -828,6 +828,7 @@ const SETTINGS_CATEGORY_MOVES = [ { key: 'output_template_bluray', category: 'Pfade' }, { key: 'output_template_dvd', category: 'Pfade' }, { key: 'output_template_audiobook', category: 'Pfade' }, + { key: 'output_chapter_template_audiobook', category: 'Pfade' }, { key: 'audiobook_raw_template', category: 'Pfade' } ]; @@ -917,6 +918,12 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})')`); + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_chapter_template_audiobook', 'Pfade', 'Kapitel Template (Audiobook)', 'string', 1, 'Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}', '[]', '{"minLength":1}', 7355)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_chapter_template_audiobook', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}')`); + await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736)` diff --git a/backend/src/services/audiobookService.js b/backend/src/services/audiobookService.js index 3713d4b..fb25682 100644 --- a/backend/src/services/audiobookService.js +++ b/backend/src/services/audiobookService.js @@ -5,6 +5,7 @@ const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']); const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']); const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})'; const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})'; +const DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}'; const AUDIOBOOK_FORMAT_DEFAULTS = { m4b: {}, flac: { @@ -38,6 +39,43 @@ function parseOptionalYear(value) { return Number(match[0]); } +function parseOptionalNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseTimebaseToSeconds(value) { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + if (/^\d+\/\d+$/u.test(raw)) { + const [num, den] = raw.split('/').map(Number); + if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) { + return num / den; + } + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function secondsToMs(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return Math.max(0, Math.round(parsed * 1000)); +} + +function ticksToMs(value, timebase) { + const ticks = Number(value); + const factor = parseTimebaseToSeconds(timebase); + if (!Number.isFinite(ticks) || ticks < 0 || !Number.isFinite(factor) || factor <= 0) { + return null; + } + return Math.max(0, Math.round(ticks * factor * 1000)); +} + function normalizeOutputFormat(value) { const format = String(value || '').trim().toLowerCase(); return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3'; @@ -123,22 +161,141 @@ function pickTag(tags, keys = []) { return null; } +function sanitizeTemplateValue(value, fallback = '') { + const normalized = normalizeText(value); + if (!normalized) { + return fallback; + } + return sanitizeFileName(normalized); +} + +function normalizeChapterTitle(value, index) { + const normalized = normalizeText(value); + return normalized || `Kapitel ${index}`; +} + function buildChapterList(probe = null) { const chapters = Array.isArray(probe?.chapters) ? probe.chapters : []; return chapters.map((chapter, index) => { + const chapterIndex = index + 1; const tags = normalizeTagMap(chapter?.tags); - const startSeconds = Number(chapter?.start_time || chapter?.start || 0); - const endSeconds = Number(chapter?.end_time || chapter?.end || 0); - const title = tags.title || tags.chapter || `Kapitel ${index + 1}`; + const startSeconds = parseOptionalNumber(chapter?.start_time); + const endSeconds = parseOptionalNumber(chapter?.end_time); + const startMs = secondsToMs(startSeconds) ?? ticksToMs(chapter?.start, chapter?.time_base) ?? 0; + const endMs = secondsToMs(endSeconds) ?? ticksToMs(chapter?.end, chapter?.time_base) ?? 0; + const title = normalizeChapterTitle(tags.title || tags.chapter, chapterIndex); return { - index: index + 1, + index: chapterIndex, title, - startSeconds: Number.isFinite(startSeconds) ? startSeconds : 0, - endSeconds: Number.isFinite(endSeconds) ? endSeconds : 0 + startSeconds: Number((startMs / 1000).toFixed(3)), + endSeconds: Number((endMs / 1000).toFixed(3)), + startMs, + endMs, + timeBase: String(chapter?.time_base || '').trim() || null }; }); } +function normalizeChapterList(chapters = [], options = {}) { + const source = Array.isArray(chapters) ? chapters : []; + const durationMs = Number(options?.durationMs || 0); + const fallbackTitle = normalizeText(options?.fallbackTitle || ''); + const createFallback = options?.createFallback === true; + + const normalized = source.map((chapter, index) => { + const chapterIndex = Number(chapter?.index); + const safeIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 + ? Math.trunc(chapterIndex) + : index + 1; + const rawStartMs = parseOptionalNumber(chapter?.startMs) + ?? secondsToMs(chapter?.startSeconds) + ?? ticksToMs(chapter?.start, chapter?.timeBase || chapter?.time_base) + ?? 0; + const rawEndMs = parseOptionalNumber(chapter?.endMs) + ?? secondsToMs(chapter?.endSeconds) + ?? ticksToMs(chapter?.end, chapter?.timeBase || chapter?.time_base) + ?? 0; + return { + index: safeIndex, + title: normalizeChapterTitle(chapter?.title, safeIndex), + startMs: Math.max(0, rawStartMs), + endMs: Math.max(0, rawEndMs) + }; + }); + + const repaired = normalized.map((chapter, index) => { + const nextStartMs = normalized[index + 1]?.startMs ?? null; + let endMs = chapter.endMs; + if (!(endMs > chapter.startMs)) { + if (Number.isFinite(nextStartMs) && nextStartMs > chapter.startMs) { + endMs = nextStartMs; + } else if (durationMs > chapter.startMs) { + endMs = durationMs; + } else { + endMs = chapter.startMs; + } + } + return { + ...chapter, + endMs, + startSeconds: Number((chapter.startMs / 1000).toFixed(3)), + endSeconds: Number((endMs / 1000).toFixed(3)), + durationMs: Math.max(0, endMs - chapter.startMs) + }; + }).filter((chapter) => chapter.endMs > chapter.startMs || normalized.length === 1); + + if (repaired.length > 0) { + return repaired; + } + + if (createFallback && durationMs > 0) { + return [{ + index: 1, + title: fallbackTitle || 'Kapitel 1', + startMs: 0, + endMs: durationMs, + startSeconds: 0, + endSeconds: Number((durationMs / 1000).toFixed(3)), + durationMs + }]; + } + + return []; +} + +function looksLikeDescription(value) { + const normalized = normalizeText(value); + if (!normalized) { + return false; + } + return normalized.length >= 120 || /[.!?]\s/u.test(normalized); +} + +function detectCoverStream(probe = null) { + const streams = Array.isArray(probe?.streams) ? probe.streams : []; + for (const stream of streams) { + const codecType = String(stream?.codec_type || '').trim().toLowerCase(); + const codecName = String(stream?.codec_name || '').trim().toLowerCase(); + const dispositionAttachedPic = Number(stream?.disposition?.attached_pic || 0) === 1; + const mimetype = String(stream?.tags?.mimetype || '').trim().toLowerCase(); + const looksLikeImageStream = codecType === 'video' + && (dispositionAttachedPic || mimetype.startsWith('image/') || ['jpeg', 'jpg', 'png', 'mjpeg'].includes(codecName)); + + if (!looksLikeImageStream) { + continue; + } + + const streamIndex = Number(stream?.index); + return { + streamIndex: Number.isFinite(streamIndex) ? Math.trunc(streamIndex) : 0, + codecName: codecName || null, + mimetype: mimetype || null, + attachedPic: dispositionAttachedPic + }; + } + return null; +} + function parseProbeOutput(rawOutput) { if (!rawOutput) { return null; @@ -156,20 +313,39 @@ function buildMetadataFromProbe(probe = null, originalName = null) { const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || ''))); const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook'; const title = pickTag(tags, ['title', 'album']) || fallbackTitle; - const author = pickTag(tags, ['artist', 'album_artist', 'composer']) || 'Unknown Author'; - const narrator = pickTag(tags, ['narrator', 'performer', 'comment']) || null; - const series = pickTag(tags, ['series', 'grouping']) || null; - const part = pickTag(tags, ['part', 'disc', 'track']) || null; - const year = parseOptionalYear(pickTag(tags, ['date', 'year'])); + const author = pickTag(tags, ['author', 'artist', 'writer', 'album_artist', 'composer']) || 'Unknown Author'; + const description = pickTag(tags, [ + 'description', + 'synopsis', + 'summary', + 'long_description', + 'longdescription', + 'publisher_summary', + 'publishersummary', + 'comment' + ]) || null; + let narrator = pickTag(tags, ['narrator', 'performer', 'album_artist']) || null; + if (narrator && (narrator === author || narrator === description || looksLikeDescription(narrator))) { + narrator = null; + } + const series = pickTag(tags, ['series', 'grouping', 'series_title', 'show']) || null; + const part = pickTag(tags, ['part', 'part_number', 'disc', 'discnumber', 'volume']) || null; + const year = parseOptionalYear(pickTag(tags, ['date', 'year', 'creation_time'])); const durationSeconds = Number(format.duration || 0); const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0 ? Math.round(durationSeconds * 1000) : 0; - const chapters = buildChapterList(probe); + const chapters = normalizeChapterList(buildChapterList(probe), { + durationMs, + fallbackTitle: title, + createFallback: false + }); + const cover = detectCoverStream(probe); return { title, author, narrator, + description, series, part, year, @@ -177,6 +353,8 @@ function buildMetadataFromProbe(probe = null, originalName = null) { artist: author, durationMs, chapters, + cover, + hasEmbeddedCover: Boolean(cover), tags }; } @@ -189,6 +367,15 @@ function normalizeTemplateTokenKey(rawKey) { if (key === 'artist') { return 'author'; } + if (key === 'chapternr' || key === 'chapternumberpadded' || key === 'chapternopadded') { + return 'chapterNr'; + } + if (key === 'chapterno' || key === 'chapternumber' || key === 'chapternum') { + return 'chapterNo'; + } + if (key === 'chaptertitle') { + return 'chapterTitle'; + } return key; } @@ -214,21 +401,27 @@ function renderTemplate(template, values) { return cleanupRenderedTemplate(rendered); } -function buildTemplateValues(metadata = {}, format = null) { - const author = sanitizeFileName(normalizeText(metadata.author || metadata.artist || 'Unknown Author')); - const title = sanitizeFileName(normalizeText(metadata.title || metadata.album || 'Unknown Audiobook')); - const narrator = sanitizeFileName(normalizeText(metadata.narrator || ''), 'unknown'); - const series = sanitizeFileName(normalizeText(metadata.series || ''), 'unknown'); - const part = sanitizeFileName(normalizeText(metadata.part || ''), 'unknown'); +function buildTemplateValues(metadata = {}, format = null, chapter = null) { + const chapterIndex = Number(chapter?.index || chapter?.chapterNo || 0); + const safeChapterIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 ? Math.trunc(chapterIndex) : 1; + const author = sanitizeTemplateValue(metadata.author || metadata.artist || 'Unknown Author', 'Unknown Author'); + const title = sanitizeTemplateValue(metadata.title || metadata.album || 'Unknown Audiobook', 'Unknown Audiobook'); + const narrator = sanitizeTemplateValue(metadata.narrator || ''); + const series = sanitizeTemplateValue(metadata.series || ''); + const part = sanitizeTemplateValue(metadata.part || ''); + const chapterTitle = sanitizeTemplateValue(chapter?.title || `Kapitel ${safeChapterIndex}`, `Kapitel ${safeChapterIndex}`); const year = metadata.year ? String(metadata.year) : ''; return { author, title, - narrator: narrator === 'unknown' ? '' : narrator, - series: series === 'unknown' ? '' : series, - part: part === 'unknown' ? '' : part, + narrator, + series, + part, year, - format: format ? String(format).trim().toLowerCase() : '' + format: format ? String(format).trim().toLowerCase() : '', + chapterNr: String(safeChapterIndex).padStart(2, '0'), + chapterNo: String(safeChapterIndex), + chapterTitle }; } @@ -281,6 +474,71 @@ function buildOutputPath(metadata, movieBaseDir, outputTemplate = DEFAULT_AUDIOB return path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`); } +function findCommonDirectory(paths = []) { + const segmentsList = (Array.isArray(paths) ? paths : []) + .map((entry) => String(entry || '').trim()) + .filter(Boolean) + .map((entry) => path.resolve(entry).split(path.sep).filter((segment, index, list) => !(index === 0 && list[0] === ''))); + + if (segmentsList.length === 0) { + return null; + } + + const common = [...segmentsList[0]]; + for (let index = 1; index < segmentsList.length; index += 1) { + const next = segmentsList[index]; + let matchLength = 0; + while (matchLength < common.length && matchLength < next.length && common[matchLength] === next[matchLength]) { + matchLength += 1; + } + common.length = matchLength; + if (common.length === 0) { + break; + } + } + + if (common.length === 0) { + return null; + } + + return path.join(path.sep, ...common); +} + +function buildChapterOutputPlan( + metadata, + chapters, + movieBaseDir, + chapterTemplate = DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE, + outputFormat = 'mp3' +) { + const normalizedFormat = normalizeOutputFormat(outputFormat); + const normalizedChapters = normalizeChapterList(chapters, { + durationMs: metadata?.durationMs, + fallbackTitle: metadata?.title || metadata?.album || 'Audiobook', + createFallback: true + }); + const outputFiles = normalizedChapters.map((chapter, index) => { + const values = buildTemplateValues(metadata, normalizedFormat, chapter); + const fallbackBaseName = `${values.chapterNr} ${values.chapterTitle}`.trim() || `Kapitel ${index + 1}`; + const { folderParts, baseName } = resolveTemplatePathParts(chapterTemplate, values, fallbackBaseName); + const outputPath = path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`); + return { + chapter, + outputPath + }; + }); + const outputDir = findCommonDirectory(outputFiles.map((entry) => path.dirname(entry.outputPath))) + || String(movieBaseDir || '').trim() + || '.'; + + return { + outputDir, + outputFiles, + chapters: normalizedChapters, + format: normalizedFormat + }; +} + function buildProbeCommand(ffprobeCommand, inputPath) { const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe'; return { @@ -296,35 +554,203 @@ function buildProbeCommand(ffprobeCommand, inputPath) { }; } -function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}) { +function pushMetadataArg(args, key, value) { + const normalizedKey = String(key || '').trim(); + const normalizedValue = normalizeText(value); + if (!normalizedKey || !normalizedValue) { + return; + } + args.push('-metadata', `${normalizedKey}=${normalizedValue}`); +} + +function buildMetadataArgs(metadata = {}, options = {}) { + const source = metadata && typeof metadata === 'object' ? metadata : {}; + const titleOverride = normalizeText(options?.title || ''); + const albumOverride = normalizeText(options?.album || ''); + const trackNo = Number(options?.trackNo || 0); + const trackTotal = Number(options?.trackTotal || 0); + const args = []; + const bookTitle = normalizeText(source.title || source.album || ''); + const author = normalizeText(source.author || source.artist || ''); + + pushMetadataArg(args, 'title', titleOverride || bookTitle); + pushMetadataArg(args, 'album', albumOverride || bookTitle); + pushMetadataArg(args, 'artist', author); + pushMetadataArg(args, 'album_artist', author); + pushMetadataArg(args, 'author', author); + pushMetadataArg(args, 'narrator', source.narrator); + pushMetadataArg(args, 'performer', source.narrator); + pushMetadataArg(args, 'grouping', source.series); + pushMetadataArg(args, 'series', source.series); + pushMetadataArg(args, 'disc', source.part); + pushMetadataArg(args, 'description', source.description); + pushMetadataArg(args, 'comment', source.description); + if (source.year) { + pushMetadataArg(args, 'date', String(source.year)); + pushMetadataArg(args, 'year', String(source.year)); + } + if (Number.isFinite(trackNo) && trackNo > 0) { + const formattedTrack = Number.isFinite(trackTotal) && trackTotal > 0 + ? `${Math.trunc(trackNo)}/${Math.trunc(trackTotal)}` + : String(Math.trunc(trackNo)); + pushMetadataArg(args, 'track', formattedTrack); + } + return args; +} + +function buildCodecArgs(format, normalizedOptions) { + if (format === 'm4b') { + return ['-c:a', 'copy']; + } + if (format === 'flac') { + return ['-codec:a', 'flac', '-compression_level', String(normalizedOptions.flacCompression)]; + } + if (normalizedOptions.mp3Mode === 'vbr') { + return ['-codec:a', 'libmp3lame', '-q:a', String(normalizedOptions.mp3Quality)]; + } + return ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`]; +} + +function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}, options = {}) { const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg'; const format = normalizeOutputFormat(outputFormat); const normalizedOptions = normalizeFormatOptions(format, formatOptions); + const extra = options && typeof options === 'object' ? options : {}; const commonArgs = [ '-y', - '-i', inputPath, + '-i', inputPath + ]; + if (extra.chapterMetadataPath) { + commonArgs.push('-f', 'ffmetadata', '-i', extra.chapterMetadataPath); + } + commonArgs.push( '-map', '0:a:0?', '-map_metadata', '0', - '-map_chapters', '0', + '-map_chapters', extra.chapterMetadataPath ? '1' : '0', '-vn', '-sn', '-dn' - ]; - let codecArgs = ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`]; - if (format === 'm4b') { - codecArgs = ['-c:a', 'copy']; - } else if (format === 'flac') { - codecArgs = ['-codec:a', 'flac', '-compression_level', String(normalizedOptions.flacCompression)]; - } else if (normalizedOptions.mp3Mode === 'vbr') { - codecArgs = ['-codec:a', 'libmp3lame', '-q:a', String(normalizedOptions.mp3Quality)]; - } + ); + const metadataArgs = buildMetadataArgs(extra.metadata, extra.metadataOptions); + const codecArgs = buildCodecArgs(format, normalizedOptions); return { cmd, - args: [...commonArgs, ...codecArgs, outputPath], + args: [...commonArgs, ...codecArgs, ...metadataArgs, outputPath], + metadataArgs, formatOptions: normalizedOptions }; } +function formatSecondsArg(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return '0'; + } + return parsed.toFixed(3).replace(/\.?0+$/u, ''); +} + +function buildChapterEncodeCommand( + ffmpegCommand, + inputPath, + outputPath, + outputFormat = 'mp3', + formatOptions = {}, + metadata = {}, + chapter = {}, + chapterTotal = 1 +) { + const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg'; + const format = normalizeOutputFormat(outputFormat); + const normalizedOptions = normalizeFormatOptions(format, formatOptions); + const safeChapter = normalizeChapterList([chapter], { + durationMs: metadata?.durationMs, + fallbackTitle: metadata?.title || 'Kapitel', + createFallback: true + })[0]; + const durationSeconds = Number(((safeChapter?.durationMs || 0) / 1000).toFixed(3)); + const metadataArgs = buildMetadataArgs(metadata, { + title: safeChapter?.title, + album: metadata?.title || metadata?.album || null, + trackNo: safeChapter?.index || 1, + trackTotal: chapterTotal + }); + const codecArgs = buildCodecArgs(format, normalizedOptions); + return { + cmd, + args: [ + '-y', + '-i', inputPath, + '-ss', formatSecondsArg(safeChapter?.startSeconds), + '-t', formatSecondsArg(durationSeconds), + '-map', '0:a:0?', + '-map_metadata', '-1', + '-map_chapters', '-1', + '-vn', + '-sn', + '-dn', + ...codecArgs, + ...metadataArgs, + outputPath + ], + metadataArgs, + formatOptions: normalizedOptions + }; +} + +function escapeFfmetadataValue(value) { + return String(value == null ? '' : value) + .replace(/\\/g, '\\\\') + .replace(/=/g, '\\=') + .replace(/;/g, '\\;') + .replace(/#/g, '\\#') + .replace(/\r?\n/g, ' '); +} + +function buildChapterMetadataContent(chapters = [], metadata = {}) { + const normalizedChapters = normalizeChapterList(chapters, { + durationMs: metadata?.durationMs, + fallbackTitle: metadata?.title || metadata?.album || 'Audiobook', + createFallback: true + }); + + const chapterBlocks = normalizedChapters.map((chapter) => { + const startMs = Math.max(0, Math.round(chapter.startMs || 0)); + const endMs = Math.max(startMs, Math.round(chapter.endMs || startMs)); + return [ + '[CHAPTER]', + 'TIMEBASE=1/1000', + `START=${startMs}`, + `END=${endMs}`, + `title=${escapeFfmetadataValue(chapter.title || `Kapitel ${chapter.index || 1}`)}` + ].join('\n'); + }).join('\n\n'); + + return `;FFMETADATA1\n\n${chapterBlocks}`; +} + +function buildCoverExtractionCommand(ffmpegCommand, inputPath, outputPath, cover = null) { + const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg'; + const streamIndex = Number(cover?.streamIndex); + const streamSpecifier = Number.isFinite(streamIndex) && streamIndex >= 0 + ? `0:${Math.trunc(streamIndex)}` + : '0:v:0'; + return { + cmd, + args: [ + '-y', + '-i', inputPath, + '-map', streamSpecifier, + '-an', + '-sn', + '-dn', + '-frames:v', '1', + '-c:v', 'mjpeg', + '-q:v', '2', + outputPath + ] + }; +} + function parseFfmpegTimestampToMs(rawValue) { const value = String(rawValue || '').trim(); const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/); @@ -368,16 +794,22 @@ module.exports = { SUPPORTED_OUTPUT_FORMATS, DEFAULT_AUDIOBOOK_RAW_TEMPLATE, DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, + DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE, AUDIOBOOK_FORMAT_DEFAULTS, normalizeOutputFormat, getDefaultFormatOptions, normalizeFormatOptions, isSupportedInputFile, buildMetadataFromProbe, + normalizeChapterList, buildRawStoragePaths, buildOutputPath, + buildChapterOutputPlan, buildProbeCommand, parseProbeOutput, buildEncodeCommand, + buildChapterEncodeCommand, + buildChapterMetadataContent, + buildCoverExtractionCommand, buildProgressParser }; diff --git a/backend/src/services/audnexService.js b/backend/src/services/audnexService.js new file mode 100644 index 0000000..78bc53a --- /dev/null +++ b/backend/src/services/audnexService.js @@ -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 +}; diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 8c7efec..e68fb68 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -345,7 +345,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn if (hasAudiobookStructure(rawPath) || hasAudiobookStructure(encodeInputPath)) { return 'audiobook'; } - if (String(hbInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') { + if (['audiobook_encode', 'audiobook_encode_split'].includes(String(hbInfo?.mode || '').trim().toLowerCase())) { return 'audiobook'; } if (String(plan?.mode || '').trim().toLowerCase() === 'audiobook') { @@ -504,12 +504,28 @@ function getConfiguredMediaPathList(settings = {}, baseKey) { return unique; } +function isDirectoryLikeOutput(mediaType, encodePlan = null, handbrakeInfo = null) { + if (mediaType === 'cd') { + return true; + } + if (mediaType !== 'audiobook') { + return false; + } + const hbMode = String(handbrakeInfo?.mode || '').trim().toLowerCase(); + if (hbMode === 'audiobook_encode_split') { + return true; + } + const format = String(encodePlan?.format || '').trim().toLowerCase(); + return Boolean(format && format !== 'm4b'); +} + function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = {}) { const mkInfo = parsed?.makemkvInfo || parseJsonSafe(job?.makemkv_info_json, null); const miInfo = parsed?.mediainfoInfo || parseJsonSafe(job?.mediainfo_info_json, null); const plan = parsed?.encodePlan || parseJsonSafe(job?.encode_plan_json, null); const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null); const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo); + const directoryLikeOutput = isDirectoryLikeOutput(mediaType, plan, handbrakeInfo); const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType); const rawDir = String(effectiveSettings?.raw_dir || '').trim(); const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim(); @@ -519,13 +535,13 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = const effectiveRawPath = job?.raw_path ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) : (job?.raw_path || null); - // For CD, output_path is a directory (album folder) — skip path-relocation heuristic - const effectiveOutputPath = (mediaType !== 'cd' && configuredMovieDir && job?.output_path) + const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path) ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) : (job?.output_path || null); return { mediaType, + directoryLikeOutput, rawDir, movieDir, effectiveRawPath, @@ -561,11 +577,12 @@ function enrichJobRow(job, settings = null, options = {}) { const omdbInfo = parseJsonSafe(job.omdb_json, null); const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); const handbrakeInfo = resolvedPaths.handbrakeInfo; + const directoryLikeOutput = Boolean(resolvedPaths.directoryLikeOutput); const outputStatus = includeFsChecks - ? (resolvedPaths.mediaType === 'cd' + ? (directoryLikeOutput ? inspectDirectory(resolvedPaths.effectiveOutputPath) : inspectOutputFile(resolvedPaths.effectiveOutputPath)) - : (resolvedPaths.mediaType === 'cd' + : (directoryLikeOutput ? buildUnknownDirectoryStatus(resolvedPaths.effectiveOutputPath) : buildUnknownFileStatus(resolvedPaths.effectiveOutputPath)); const rawStatus = includeFsChecks @@ -582,7 +599,7 @@ function enrichJobRow(job, settings = null, options = {}) { const ripSuccessful = Number(job?.rip_successful || 0) === 1 || String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; const backupSuccess = ripSuccessful; - const encodeSuccess = mediaType === 'cd' + const encodeSuccess = directoryLikeOutput ? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists)) : String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 72b01bf..ac158dc 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -7,6 +7,7 @@ const settingsService = require('./settingsService'); const historyService = require('./historyService'); const omdbService = require('./omdbService'); const musicBrainzService = require('./musicBrainzService'); +const audnexService = require('./audnexService'); const cdRipService = require('./cdRipService'); const audiobookService = require('./audiobookService'); const scriptService = require('./scriptService'); @@ -495,6 +496,12 @@ function withTimestampBeforeExtension(targetPath, suffix) { return path.join(dir, `${base}_${suffix}${ext}`); } +function withTimestampSuffix(targetPath, suffix) { + const dir = path.dirname(targetPath); + const base = path.basename(targetPath); + return path.join(dir, `${base}_${suffix}`); +} + function resolveOutputTemplateValues(job, fallbackJobId = null) { return { title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'), @@ -557,10 +564,21 @@ function ensureUniqueOutputPath(outputPath) { } const ts = fileTimestamp(); - let attempt = withTimestampBeforeExtension(outputPath, ts); + let stat = null; + try { + stat = fs.statSync(outputPath); + } catch (_error) { + stat = null; + } + const isDirectory = Boolean(stat?.isDirectory?.()); + let attempt = isDirectory + ? withTimestampSuffix(outputPath, ts) + : withTimestampBeforeExtension(outputPath, ts); let i = 1; while (fs.existsSync(attempt)) { - attempt = withTimestampBeforeExtension(outputPath, `${ts}-${i}`); + attempt = isDirectory + ? withTimestampSuffix(outputPath, `${ts}-${i}`) + : withTimestampBeforeExtension(outputPath, `${ts}-${i}`); i += 1; } return attempt; @@ -594,6 +612,24 @@ function moveFileWithFallback(sourcePath, targetPath) { } } +function movePathWithFallback(sourcePath, targetPath) { + try { + fs.renameSync(sourcePath, targetPath); + } catch (error) { + if (error?.code !== 'EXDEV') { + throw error; + } + const stat = fs.statSync(sourcePath); + if (stat.isDirectory()) { + fs.cpSync(sourcePath, targetPath, { recursive: true }); + fs.rmSync(sourcePath, { recursive: true, force: true }); + return; + } + fs.copyFileSync(sourcePath, targetPath); + fs.unlinkSync(sourcePath); + } +} + function removeDirectoryIfEmpty(directoryPath) { try { const entries = fs.readdirSync(directoryPath); @@ -632,7 +668,7 @@ function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFin } ensureDir(path.dirname(targetPath)); - moveFileWithFallback(sourcePath, targetPath); + movePathWithFallback(sourcePath, targetPath); removeDirectoryIfEmpty(path.dirname(sourcePath)); return { @@ -651,21 +687,34 @@ function buildAudiobookMetadataForJob(job, makemkvInfo = null, encodePlan = null ? mkInfo.selectedMetadata : (mkInfo?.detectedMetadata && typeof mkInfo.detectedMetadata === 'object' ? mkInfo.detectedMetadata : {}) ); + const title = String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook'; + const durationMs = Number.isFinite(Number(metadataSource?.durationMs)) + ? Number(metadataSource.durationMs) + : 0; + const chaptersSource = Array.isArray(metadataSource?.chapters) + ? metadataSource.chapters + : (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : []); + const chapters = audiobookService.normalizeChapterList(chaptersSource, { + durationMs, + fallbackTitle: title, + createFallback: false + }); + return { - title: String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook', + title, author: String(metadataSource?.author || metadataSource?.artist || '').trim() || null, + asin: String(metadataSource?.asin || '').trim() || null, + chapterSource: String(metadataSource?.chapterSource || '').trim() || null, narrator: String(metadataSource?.narrator || '').trim() || null, + description: String(metadataSource?.description || '').trim() || null, series: String(metadataSource?.series || '').trim() || null, part: String(metadataSource?.part || '').trim() || null, year: Number.isFinite(Number(metadataSource?.year)) ? Math.trunc(Number(metadataSource.year)) : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null), - durationMs: Number.isFinite(Number(metadataSource?.durationMs)) - ? Number(metadataSource.durationMs) - : 0, - chapters: Array.isArray(metadataSource?.chapters) - ? metadataSource.chapters - : (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : []) + durationMs, + chapters, + poster: String(metadataSource?.poster || job?.poster_url || '').trim() || null }; } @@ -682,25 +731,62 @@ function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePla settings?.output_template || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE ).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE; + const chapterOutputTemplate = String( + settings?.output_chapter_template_audiobook + || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE; const outputFormat = audiobookService.normalizeOutputFormat( encodePlan?.format || settings?.output_extension || 'mp3' ); - const preferredFinalOutputPath = audiobookService.buildOutputPath( - metadata, - movieDir, - outputTemplate, - outputFormat - ); const numericJobId = Number(fallbackJobId || job?.id || 0); const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 ? `Incomplete_job-${numericJobId}` : 'Incomplete_job-unknown'; - const incompleteOutputPath = path.join(movieDir, incompleteFolder, path.basename(preferredFinalOutputPath)); - return { + const incompleteBaseDir = path.join(movieDir, incompleteFolder); + + if (outputFormat === 'm4b') { + const preferredFinalOutputPath = audiobookService.buildOutputPath( + metadata, + movieDir, + outputTemplate, + outputFormat + ); + const incompleteOutputPath = path.join(incompleteBaseDir, path.basename(preferredFinalOutputPath)); + return { + metadata, + outputFormat, + preferredFinalOutputPath, + incompleteOutputPath, + preferredChapterPlan: null, + incompleteChapterPlan: null + }; + } + + const preferredChapterPlan = audiobookService.buildChapterOutputPlan( metadata, + metadata.chapters, + movieDir, + chapterOutputTemplate, + outputFormat + ); + const incompleteChapterPlan = audiobookService.buildChapterOutputPlan( + metadata, + preferredChapterPlan.chapters, + incompleteBaseDir, + chapterOutputTemplate, + outputFormat + ); + + return { + metadata: { + ...metadata, + chapters: preferredChapterPlan.chapters + }, outputFormat, - preferredFinalOutputPath, - incompleteOutputPath + preferredFinalOutputPath: preferredChapterPlan.outputDir, + incompleteOutputPath: incompleteChapterPlan.outputDir, + preferredChapterPlan, + incompleteChapterPlan }; } @@ -10800,6 +10886,7 @@ class PipelineService extends EventEmitter { ); const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat); const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + const ffmpegCommand = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg'; const job = await historyService.createJob({ discDevice: null, @@ -10859,6 +10946,79 @@ class PipelineService extends EventEmitter { stagedRawFilePath }); + let detectedAsin = null; + let audnexChapters = []; + try { + detectedAsin = await audnexService.extractAsinFromAaxFile(storagePaths.rawFilePath); + if (detectedAsin) { + await historyService.appendLog(job.id, 'SYSTEM', `ASIN erkannt: ${detectedAsin}`); + audnexChapters = await audnexService.fetchChaptersByAsin(detectedAsin, 'de'); + if (audnexChapters.length > 0) { + await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Kapitel geladen: ${audnexChapters.length}`); + } else { + await historyService.appendLog(job.id, 'SYSTEM', `Keine Audnex-Kapitel fuer ASIN ${detectedAsin} gefunden.`); + } + } else { + await historyService.appendLog(job.id, 'SYSTEM', 'Keine ASIN in der AAX-Datei gefunden, verwende eingebettete Kapitel.'); + } + } catch (audnexError) { + logger.warn('audiobook:upload:audnex-chapters-failed', { + jobId: job.id, + stagedRawFilePath: storagePaths.rawFilePath, + asin: detectedAsin, + error: errorToMeta(audnexError) + }); + await historyService.appendLog( + job.id, + 'SYSTEM', + `Audnex-Kapitel konnten nicht geladen werden: ${audnexError?.message || 'unknown'}` + ).catch(() => {}); + } + + let posterUrl = null; + if (metadata?.hasEmbeddedCover && metadata?.cover) { + const coverTempPath = path.join(storagePaths.rawDir, `.job-${job.id}-cover.jpg`); + try { + const coverCommand = audiobookService.buildCoverExtractionCommand( + ffmpegCommand, + storagePaths.rawFilePath, + coverTempPath, + metadata.cover + ); + await this.runCapturedCommand(coverCommand.cmd, coverCommand.args); + posterUrl = thumbnailService.storeLocalThumbnail(job.id, coverTempPath); + if (posterUrl) { + await historyService.appendLog(job.id, 'SYSTEM', 'Eingebettetes AAX-Cover erkannt und gespeichert.'); + } + } catch (coverError) { + logger.warn('audiobook:upload:cover-extract-failed', { + jobId: job.id, + stagedRawFilePath: storagePaths.rawFilePath, + error: errorToMeta(coverError) + }); + } finally { + try { + fs.rmSync(coverTempPath, { force: true }); + } catch (_error) { + // best effort cleanup + } + } + } + + const resolvedMetadata = { + ...metadata, + asin: detectedAsin || null, + chapterSource: audnexChapters.length > 0 ? 'audnex' : 'probe', + chapters: audnexChapters.length > 0 + ? audiobookService.normalizeChapterList(audnexChapters, { + durationMs: metadata.durationMs, + fallbackTitle: metadata.title, + createFallback: false + }) + : metadata.chapters, + poster: posterUrl || null + }; + const makemkvInfo = this.withAnalyzeContextMediaProfile({ status: 'SUCCESS', source: 'aax_upload', @@ -10866,12 +11026,14 @@ class PipelineService extends EventEmitter { mediaProfile: 'audiobook', rawFileName: storagePaths.rawFileName, rawFilePath: storagePaths.rawFilePath, - chapters: metadata.chapters, - detectedMetadata: metadata, - selectedMetadata: metadata, + chapters: resolvedMetadata.chapters, + detectedMetadata: resolvedMetadata, + selectedMetadata: resolvedMetadata, probeSummary: { - durationMs: metadata.durationMs, - tagKeys: Object.keys(metadata.tags || {}) + durationMs: resolvedMetadata.durationMs, + tagKeys: Object.keys(resolvedMetadata.tags || {}), + asin: detectedAsin || null, + chapterSource: resolvedMetadata.chapterSource || 'probe' } }, 'audiobook'); @@ -10885,16 +11047,16 @@ class PipelineService extends EventEmitter { rawTemplate, outputTemplate, encodeInputPath: storagePaths.rawFilePath, - metadata, + metadata: resolvedMetadata, reviewConfirmed: true }; await historyService.updateJob(job.id, { status: 'READY_TO_START', last_state: 'READY_TO_START', - title: metadata.title || detectedTitle, - detected_title: metadata.title || detectedTitle, - year: metadata.year ?? null, + title: resolvedMetadata.title || detectedTitle, + detected_title: resolvedMetadata.title || detectedTitle, + year: resolvedMetadata.year ?? null, raw_path: storagePaths.rawDir, rip_successful: 1, makemkv_info_json: JSON.stringify(makemkvInfo), @@ -10904,15 +11066,15 @@ class PipelineService extends EventEmitter { encode_input_path: storagePaths.rawFilePath, encode_review_confirmed: 1, output_path: null, + poster_url: posterUrl || null, error_message: null, - start_time: null, end_time: null }); await historyService.appendLog( job.id, 'SYSTEM', - `Audiobook analysiert: ${metadata.title || detectedTitle} | Autor: ${metadata.author || '-'} | Format: ${outputFormat.toUpperCase()}` + `Audiobook analysiert: ${resolvedMetadata.title || detectedTitle} | Autor: ${resolvedMetadata.author || '-'} | Format: ${outputFormat.toUpperCase()}` ); if (!startImmediately) { @@ -11004,6 +11166,29 @@ class PipelineService extends EventEmitter { config?.formatOptions || encodePlan?.formatOptions || {} ); const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan); + const chapters = audiobookService.normalizeChapterList( + Array.isArray(config?.chapters) ? config.chapters : metadata.chapters, + { + durationMs: metadata.durationMs, + fallbackTitle: metadata.title, + createFallback: false + } + ); + const resolvedMetadata = { + ...metadata, + chapters + }; + const nextMakemkvInfo = { + ...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}), + chapters, + selectedMetadata: { + ...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}), + ...resolvedMetadata, + poster: metadata.poster || job.poster_url || null + } + }; const nextEncodePlan = { ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}), @@ -11011,15 +11196,16 @@ class PipelineService extends EventEmitter { mode: 'audiobook', format, formatOptions, - metadata, + metadata: resolvedMetadata, reviewConfirmed: true }; await historyService.updateJob(normalizedJobId, { status: 'READY_TO_START', last_state: 'READY_TO_START', - title: metadata.title || job.title || job.detected_title || 'Audiobook', - year: metadata.year ?? job.year ?? null, + title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook', + year: resolvedMetadata.year ?? job.year ?? null, + makemkv_info_json: JSON.stringify(nextMakemkvInfo), encode_plan_json: JSON.stringify(nextEncodePlan), encode_review_confirmed: 1, error_message: null, @@ -11030,7 +11216,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( normalizedJobId, 'USER_ACTION', - `Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()}` + `Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()} | Kapitel: ${chapters.length || 0}` ); const startResult = await this.startPreparedJob(normalizedJobId); @@ -11107,13 +11293,29 @@ class PipelineService extends EventEmitter { metadata, outputFormat, preferredFinalOutputPath, - incompleteOutputPath + incompleteOutputPath, + preferredChapterPlan, + incompleteChapterPlan } = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId); const formatOptions = audiobookService.normalizeFormatOptions( outputFormat, encodePlan?.formatOptions || {} ); - ensureDir(path.dirname(incompleteOutputPath)); + const isSplitOutput = outputFormat !== 'm4b'; + const activeChapters = isSplitOutput + ? (Array.isArray(incompleteChapterPlan?.chapters) ? incompleteChapterPlan.chapters : []) + : (Array.isArray(metadata.chapters) ? metadata.chapters : []); + + if (isSplitOutput) { + try { + fs.rmSync(incompleteOutputPath, { recursive: true, force: true }); + } catch (_error) { + // best effort cleanup + } + ensureDir(incompleteOutputPath); + } else { + ensureDir(path.dirname(incompleteOutputPath)); + } await historyService.resetProcessLog(jobId); await this.setState('ENCODING', { @@ -11129,17 +11331,18 @@ class PipelineService extends EventEmitter { outputPath: incompleteOutputPath, format: outputFormat, formatOptions, - chapters: metadata.chapters, + chapters: activeChapters, selectedMetadata: { title: metadata.title || job.title || job.detected_title || null, year: metadata.year ?? job.year ?? null, author: metadata.author || null, narrator: metadata.narrator || null, + description: metadata.description || null, series: metadata.series || null, part: metadata.part || null, - chapters: Array.isArray(metadata.chapters) ? metadata.chapters : [], + chapters: activeChapters, durationMs: metadata.durationMs || 0, - poster: job.poster_url || null + poster: metadata.poster || job.poster_url || null }, audiobookConfig: { format: outputFormat, @@ -11164,7 +11367,9 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}` + isSplitOutput + ? `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()} | Kapitel-Dateien: ${activeChapters.length || 0}` + : `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}` ); void this.notifyPushover('encoding_started', { @@ -11172,30 +11377,161 @@ class PipelineService extends EventEmitter { message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); + let temporaryChapterMetadataPath = null; + try { - const ffmpegConfig = audiobookService.buildEncodeCommand( - settings?.ffmpeg_command || 'ffmpeg', - inputPath, - incompleteOutputPath, - outputFormat, - formatOptions - ); - logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args }); - const ffmpegRunInfo = await this.runCommand({ - jobId, - stage: 'ENCODING', - source: 'FFMPEG', - cmd: ffmpegConfig.cmd, - args: ffmpegConfig.args, - parser: audiobookService.buildProgressParser(metadata.durationMs) - }); + let ffmpegRunInfo = null; + if (isSplitOutput) { + const outputFiles = Array.isArray(incompleteChapterPlan?.outputFiles) + ? incompleteChapterPlan.outputFiles + : []; + if (outputFiles.length === 0) { + throw new Error('Keine Audiobook-Kapitel für den Encode verfügbar.'); + } + + const chapterRunInfos = []; + for (let index = 0; index < outputFiles.length; index += 1) { + const entry = outputFiles[index]; + const chapter = entry?.chapter || {}; + const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`; + const startPercent = Number(((index / outputFiles.length) * 100).toFixed(2)); + const endPercent = Number((((index + 1) / outputFiles.length) * 100).toFixed(2)); + + ensureDir(path.dirname(entry.outputPath)); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle} -> ${path.basename(entry.outputPath)}` + ); + await this.updateProgress( + 'ENCODING', + startPercent, + null, + `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`, + jobId, + { + contextPatch: { + outputPath: incompleteOutputPath, + currentChapter: { + index: index + 1, + total: outputFiles.length, + title: chapterTitle + } + } + } + ); + + const ffmpegConfig = audiobookService.buildChapterEncodeCommand( + settings?.ffmpeg_command || 'ffmpeg', + inputPath, + entry.outputPath, + outputFormat, + formatOptions, + metadata, + chapter, + outputFiles.length + ); + const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0); + const scaledParser = baseParser + ? (line) => { + const progress = baseParser(line); + if (!progress || progress.percent == null) { + return null; + } + const scaledPercent = startPercent + ((endPercent - startPercent) * (progress.percent / 100)); + return { + percent: Number(scaledPercent.toFixed(2)), + eta: null + }; + } + : null; + + logger.info('audiobook:encode:chapter-command', { + jobId, + chapterIndex: index + 1, + cmd: ffmpegConfig.cmd, + args: ffmpegConfig.args + }); + const chapterRunInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'FFMPEG', + cmd: ffmpegConfig.cmd, + args: ffmpegConfig.args, + parser: scaledParser + }); + + chapterRunInfos.push({ + ...chapterRunInfo, + chapterIndex: index + 1, + chapterTitle, + outputPath: entry.outputPath + }); + } + + ffmpegRunInfo = { + source: 'FFMPEG', + stage: 'ENCODING', + cmd: String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg', + args: [''], + startedAt: chapterRunInfos[0]?.startedAt || nowIso(), + endedAt: chapterRunInfos[chapterRunInfos.length - 1]?.endedAt || nowIso(), + durationMs: chapterRunInfos.reduce((sum, item) => sum + Number(item?.durationMs || 0), 0), + status: 'SUCCESS', + exitCode: 0, + stdoutLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stdoutLines || 0), 0), + stderrLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stderrLines || 0), 0), + lastProgress: 100, + eta: null, + lastDetail: `${chapterRunInfos.length} Kapitel abgeschlossen`, + highlights: chapterRunInfos.flatMap((item) => (Array.isArray(item?.highlights) ? item.highlights : [])).slice(0, 120), + steps: chapterRunInfos + }; + } else { + temporaryChapterMetadataPath = path.join(path.dirname(inputPath), `.job-${jobId}-chapters.ffmeta`); + fs.writeFileSync( + temporaryChapterMetadataPath, + audiobookService.buildChapterMetadataContent(activeChapters, metadata), + 'utf8' + ); + + const ffmpegConfig = audiobookService.buildEncodeCommand( + settings?.ffmpeg_command || 'ffmpeg', + inputPath, + incompleteOutputPath, + outputFormat, + formatOptions, + { + chapterMetadataPath: temporaryChapterMetadataPath, + metadata + } + ); + logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args }); + ffmpegRunInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'FFMPEG', + cmd: ffmpegConfig.cmd, + args: ffmpegConfig.args, + parser: audiobookService.buildProgressParser(metadata.durationMs) + }); + } const outputFinalization = finalizeOutputPathForCompletedEncode( incompleteOutputPath, preferredFinalOutputPath ); const finalizedOutputPath = outputFinalization.outputPath; - chownRecursive(path.dirname(finalizedOutputPath), settings?.movie_dir_owner); + let ownershipTarget = path.dirname(finalizedOutputPath); + try { + const finalizedStat = fs.statSync(finalizedOutputPath); + if (finalizedStat.isDirectory()) { + ownershipTarget = finalizedOutputPath; + } + } catch (_error) { + ownershipTarget = path.dirname(finalizedOutputPath); + } + chownRecursive(ownershipTarget, settings?.movie_dir_owner); if (outputFinalization.outputPathWithTimestamp) { await historyService.appendLog( @@ -11211,14 +11547,27 @@ class PipelineService extends EventEmitter { `Audiobook-Output finalisiert: ${finalizedOutputPath}` ); + const finalizedOutputFiles = isSplitOutput + ? (Array.isArray(preferredChapterPlan?.outputFiles) + ? preferredChapterPlan.outputFiles.map((entry) => { + const relativePath = path.relative(preferredFinalOutputPath, entry.outputPath); + return path.join(finalizedOutputPath, relativePath); + }) + : []) + : null; const ffmpegInfo = { ...ffmpegRunInfo, - mode: 'audiobook_encode', + mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode', format: outputFormat, formatOptions, - metadata, + metadata: { + ...metadata, + chapters: activeChapters + }, inputPath, - outputPath: finalizedOutputPath + outputPath: finalizedOutputPath, + chapterCount: activeChapters.length, + outputFiles: finalizedOutputFiles }; await historyService.updateJob(jobId, { @@ -11269,11 +11618,18 @@ class PipelineService extends EventEmitter { outputPath: finalizedOutputPath }; } catch (error) { + if (temporaryChapterMetadataPath) { + try { + fs.rmSync(temporaryChapterMetadataPath, { force: true }); + } catch (_error) { + // best effort cleanup + } + } if (error.runInfo && error.runInfo.source === 'FFMPEG') { await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify({ ...error.runInfo, - mode: 'audiobook_encode', + mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode', format: outputFormat, formatOptions, inputPath @@ -11284,6 +11640,14 @@ class PipelineService extends EventEmitter { await this.failJob(jobId, 'ENCODING', error); error.jobAlreadyFailed = true; throw error; + } finally { + if (temporaryChapterMetadataPath) { + try { + fs.rmSync(temporaryChapterMetadataPath, { force: true }); + } catch (_error) { + // best effort cleanup + } + } } } diff --git a/backend/src/services/thumbnailService.js b/backend/src/services/thumbnailService.js index c7854ca..8e3c23c 100644 --- a/backend/src/services/thumbnailService.js +++ b/backend/src/services/thumbnailService.js @@ -156,6 +156,27 @@ function copyThumbnail(sourceJobId, targetJobId) { } } +/** + * Speichert ein lokal extrahiertes Bild als persistentes Job-Thumbnail. + * @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null + */ +function storeLocalThumbnail(jobId, sourcePath) { + try { + const src = String(sourcePath || '').trim(); + if (!src || !fs.existsSync(src)) { + return null; + } + ensureDirs(); + const dest = persistentFilePath(jobId); + fs.copyFileSync(src, dest); + logger.info('thumbnail:stored-local', { jobId, sourcePath: src, dest }); + return localUrl(jobId); + } catch (err) { + logger.warn('thumbnail:store-local:failed', { jobId, sourcePath, error: err.message }); + return null; + } +} + /** * Löscht Cache- und persistente Thumbnail-Datei eines Jobs. * Wird beim Löschen eines Jobs aufgerufen. @@ -232,6 +253,7 @@ module.exports = { cacheJobThumbnail, promoteJobThumbnail, copyThumbnail, + storeLocalThumbnail, deleteThumbnail, getThumbnailsDir, migrateExistingThumbnails, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 679ee7b..6fa92ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.10.0-7", + "version": "0.10.0-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.10.0-7", + "version": "0.10.0-8", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 4eaa543..351b90a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.10.0-7", + "version": "0.10.0-8", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 929f7d7..9e06222 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -84,6 +84,7 @@ function App() { const [lastDiscEvent, setLastDiscEvent] = useState(null); const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState()); const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0); + const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0); const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null); const location = useLocation(); const navigate = useNavigate(); @@ -151,6 +152,7 @@ function App() { const uploadedJobId = normalizeJobId(response?.result?.jobId); await refreshPipeline().catch(() => null); setDashboardJobsRefreshToken((prev) => prev + 1); + setHistoryJobsRefreshToken((prev) => prev + 1); if (uploadedJobId) { setPendingDashboardJobId(uploadedJobId); } @@ -391,7 +393,7 @@ function App() { } /> } /> - } /> + } /> } /> diff --git a/frontend/src/components/AudiobookConfigPanel.jsx b/frontend/src/components/AudiobookConfigPanel.jsx index 06880f6..eb28ad7 100644 --- a/frontend/src/components/AudiobookConfigPanel.jsx +++ b/frontend/src/components/AudiobookConfigPanel.jsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from 'react'; +import { Dialog } from 'primereact/dialog'; import { Dropdown } from 'primereact/dropdown'; import { Slider } from 'primereact/slider'; import { Button } from 'primereact/button'; import { ProgressBar } from 'primereact/progressbar'; import { Tag } from 'primereact/tag'; +import { InputText } from 'primereact/inputtext'; import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; @@ -49,6 +51,35 @@ function formatChapterTime(secondsValue) { 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 }) { if (field.type === 'slider') { return ( @@ -111,6 +142,8 @@ export default function AudiobookConfigPanel({ : (Array.isArray(context?.chapters) ? context.chapters : []); const [format, setFormat] = useState(initialFormat); const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions)); + const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters)); + const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false); useEffect(() => { const nextFormat = normalizeFormat(audiobookConfig?.format); @@ -118,6 +151,10 @@ export default function AudiobookConfigPanel({ setFormatOptions(buildFormatOptions(nextFormat, 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 canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED'); const isRunning = state === 'ENCODING'; @@ -125,6 +162,9 @@ export default function AudiobookConfigPanel({ const outputPath = String(context?.outputPath || '').trim() || null; const statusLabel = getStatusLabel(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( () => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []), @@ -134,19 +174,45 @@ export default function AudiobookConfigPanel({ return (
-
-
Titel: {metadata?.title || '-'}
-
Autor: {metadata?.author || '-'}
-
Sprecher: {metadata?.narrator || '-'}
-
Serie: {metadata?.series || '-'}
-
Teil: {metadata?.part || '-'}
-
Jahr: {metadata?.year || '-'}
-
Kapitel: {chapters.length || '-'}
+
+ {posterUrl ? ( +
+ {metadata?.title +
+ ) : null} + +
+
Titel: {metadata?.title || '-'}
+
Autor: {metadata?.author || '-'}
+
Sprecher: {metadata?.narrator || '-'}
+
Serie: {metadata?.series || '-'}
+
Teil: {metadata?.part || '-'}
+
Jahr: {metadata?.year || '-'}
+
Kapitel: {editableChapters.length || '-'}
+ {descriptionPreview ? ( +
+ Beschreibung: + {descriptionPreview} + {description.length > descriptionPreview.length ? ( +
+ ) : null} +
+
{metadata?.durationMs ? : null} + {posterUrl ? : null}
@@ -184,23 +250,36 @@ export default function AudiobookConfigPanel({ ))} - Metadaten und Kapitel werden aus der AAX-Datei gelesen. Erst nach Klick auf Start wird `ffmpeg` ausgeführt. + m4b erzeugt eine Datei mit bearbeitbaren Kapiteln. mp3 und flac werden kapitelweise als einzelne Dateien erzeugt.
-

Kapitelvorschau

- {chapters.length === 0 ? ( +

Kapitel

+ {editableChapters.length === 0 ? ( Keine Kapitel in der Quelle erkannt. ) : (
- {chapters.map((chapter, index) => ( -
- #{chapter?.index || index + 1} - {chapter?.title || `Kapitel ${index + 1}`} - - {formatChapterTime(chapter?.startSeconds)} - {formatChapterTime(chapter?.endSeconds)} - + {editableChapters.map((chapter, index) => ( +
+
+ #{chapter.index || index + 1} + + {formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)} + +
+ { + const nextTitle = event.target.value; + setEditableChapters((prev) => prev.map((entry, entryIndex) => ( + entryIndex === index + ? { ...entry, title: nextTitle } + : entry + ))); + }} + disabled={busy || isRunning} + />
))}
@@ -227,7 +306,18 @@ export default function AudiobookConfigPanel({ label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'} icon="pi pi-play" 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} disabled={!jobId} /> @@ -256,6 +346,17 @@ export default function AudiobookConfigPanel({ /> ) : null}
+ + setDescriptionDialogVisible(false)} + > +
+

{description || 'Keine Beschreibung vorhanden.'}

+
+
); } diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index a110a58..252a14a 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -174,7 +174,7 @@ function buildToolSections(settings) { 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 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']; function buildSectionsForCategory(categoryName, settings) { diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index fe753e1..a1044ed 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -250,7 +250,7 @@ function resolveMediaType(job) { if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) { 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'; } if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') { @@ -320,9 +320,12 @@ function resolveCdDetails(job) { function resolveAudiobookDetails(job) { const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {}; const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {}; - const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' - ? makemkvInfo.selectedMetadata - : (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {}); + const selectedMetadata = { + ...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}), + ...(encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {}) + }; const chapters = Array.isArray(selectedMetadata?.chapters) ? selectedMetadata.chapters : (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []); @@ -713,7 +716,7 @@ export default function JobDetailDialog({ RAW vorhanden:
- {isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? 'Audiobook-Datei vorhanden:' : 'Movie Datei vorhanden:')} + {isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? (job.outputStatus?.isDirectory ? 'Audiobook-Dateien vorhanden:' : 'Audiobook-Datei vorhanden:') : 'Movie Datei vorhanden:')}
{isCd ? (
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index f775d30..9a273ef 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -403,7 +403,7 @@ function resolveMediaType(job) { if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) { 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'; } 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` : '/trackNN.cdda.wav'; const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || ''} ${previewTrackPos || ''} ${previewWavPath}`; - const audiobookSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' - ? makemkvInfo.selectedMetadata - : (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {}); + const audiobookSelectedMeta = { + ...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}), + ...(encodePlan?.metadata && typeof encodePlan.metadata === 'object' + ? encodePlan.metadata + : {}) + }; const selectedMetadata = resolvedMediaType === 'audiobook' ? { title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null, author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null, narrator: audiobookSelectedMeta?.narrator || null, + description: audiobookSelectedMeta?.description || null, series: audiobookSelectedMeta?.series || null, part: audiobookSelectedMeta?.part || null, year: audiobookSelectedMeta?.year ?? job?.year ?? null, chapters: Array.isArray(audiobookSelectedMeta?.chapters) ? audiobookSelectedMeta.chapters : [], 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, diff --git a/frontend/src/pages/HistoryPage.jsx b/frontend/src/pages/HistoryPage.jsx index 21785af..1ca65c3 100644 --- a/frontend/src/pages/HistoryPage.jsx +++ b/frontend/src/pages/HistoryPage.jsx @@ -105,7 +105,7 @@ function resolveMediaType(row) { if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) { 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'; } 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 navigate = useNavigate(); const [jobs, setJobs] = useState([]); @@ -437,7 +437,7 @@ export default function HistoryPage() { }, 300); return () => clearTimeout(timer); - }, [search, status]); + }, [search, status, refreshToken]); useEffect(() => { const params = new URLSearchParams(location.search); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 02f97da..51afb47 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -3538,6 +3538,31 @@ body { 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 { display: flex; flex-wrap: wrap; @@ -3557,6 +3582,17 @@ body { 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 { margin: 0; } @@ -3582,6 +3618,23 @@ body { 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 { padding: 0.75rem 0.85rem; border-radius: 10px; @@ -3591,6 +3644,15 @@ body { } @media (max-width: 980px) { + .audiobook-config-summary { + width: 100%; + } + + .audiobook-config-cover { + width: 96px; + min-width: 96px; + } + .audiobook-config-grid { grid-template-columns: 1fr; } diff --git a/package-lock.json b/package-lock.json index 091c782..8ac1612 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.10.0-7", + "version": "0.10.0-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.10.0-7", + "version": "0.10.0-8", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 3f13249..a3cfa4e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.10.0-7", + "version": "0.10.0-8", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",