From e56cff43a951cfe3394f9253c09f86dbe4719e10 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Sat, 14 Mar 2026 13:35:23 +0000 Subject: [PATCH] 0.10.0 Audbile Prototype --- .gitignore | 7 - backend/package-lock.json | 71 +- backend/package.json | 3 +- backend/src/config.js | 4 +- backend/src/db/database.js | 58 +- backend/src/routes/pipelineRoutes.js | 28 + backend/src/services/audiobookService.js | 310 ++++++++ backend/src/services/historyService.js | 48 +- backend/src/services/pipelineService.js | 714 +++++++++++++++++- backend/src/services/settingsService.js | 58 +- db/schema.sql | 38 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.js | 28 +- .../src/components/DynamicSettingsForm.jsx | 41 +- frontend/src/components/JobDetailDialog.jsx | 167 ++-- .../src/components/PipelineStatusCard.jsx | 17 +- frontend/src/pages/DashboardPage.jsx | 115 ++- frontend/src/pages/DatabasePage.jsx | 11 +- frontend/src/pages/HistoryPage.jsx | 85 ++- package-lock.json | 4 +- package.json | 2 +- 22 files changed, 1667 insertions(+), 148 deletions(-) create mode 100644 backend/src/services/audiobookService.js diff --git a/.gitignore b/.gitignore index 20372e5..a03b0e4 100644 --- a/.gitignore +++ b/.gitignore @@ -80,12 +80,5 @@ Thumbs.db # Scripts # ---------------------------- /scripts/ -/deploy-ripster.sh -/setup.sh -/install.sh -/install-dev.sh -/build-handbrake-nvdec.sh -/gitea_setup.sh -/gitea_install.sh /release.sh /Audible_Tool \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index fb1ed2a..d9925d4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,16 +1,17 @@ { "name": "ripster-backend", - "version": "0.9.1-6", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.9.1-6", + "version": "0.10.0", "dependencies": { "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "multer": "^2.1.1", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "ws": "^8.18.0" @@ -158,6 +159,11 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -311,6 +317,22 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -431,6 +453,20 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "optional": true }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -1507,6 +1543,24 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -2236,6 +2290,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2398,6 +2460,11 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/backend/package.json b/backend/package.json index 1ede744..3c3fd14 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.9.1-6", + "version": "0.10.0", "private": true, "type": "commonjs", "scripts": { @@ -11,6 +11,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", + "multer": "^2.1.1", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "ws": "^8.18.0" diff --git a/backend/src/config.js b/backend/src/config.js index 08195d3..bbd6e10 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -23,5 +23,7 @@ module.exports = { logLevel: process.env.LOG_LEVEL || 'info', defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'), defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'), - defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd') + defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'), + defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'), + defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks') }; diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 7733456..ef5d22f 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -826,7 +826,9 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [ const SETTINGS_CATEGORY_MOVES = [ { key: 'cd_output_template', 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: 'audiobook_raw_template', category: 'Pfade' } ]; async function migrateSettingsSchemaMetadata(db) { @@ -890,6 +892,60 @@ async function migrateSettingsSchemaMetadata(db) { VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)` ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`); + + 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_extension_audiobook', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Audiobook-Datei.', 'mp3', '[{"label":"M4B","value":"m4b"},{"label":"MP3","value":"mp3"},{"label":"FLAC","value":"flac"}]', '{}', 730)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_audiobook', 'mp3')`); + + 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_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735)` + ); + 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 ('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)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{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 ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`); } async function getDb() { diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 3dc9e83..29a962f 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -1,4 +1,7 @@ const express = require('express'); +const os = require('os'); +const path = require('path'); +const multer = require('multer'); const asyncHandler = require('../middleware/asyncHandler'); const pipelineService = require('../services/pipelineService'); const diskDetectionService = require('../services/diskDetectionService'); @@ -6,6 +9,9 @@ const hardwareMonitorService = require('../services/hardwareMonitorService'); const logger = require('../services/logger').child('PIPELINE_ROUTE'); const router = express.Router(); +const audiobookUpload = multer({ + dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads') +}); router.get( '/state', @@ -125,6 +131,28 @@ router.post( }) ); +router.post( + '/audiobook/upload', + audiobookUpload.single('file'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('Upload-Datei fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:audiobook:upload', { + reqId: req.reqId, + originalName: req.file.originalname, + sizeBytes: Number(req.file.size || 0) + }); + const result = await pipelineService.uploadAudiobookFile(req.file, { + format: req.body?.format, + startImmediately: req.body?.startImmediately + }); + res.json({ result }); + }) +); + router.post( '/select-metadata', asyncHandler(async (req, res) => { diff --git a/backend/src/services/audiobookService.js b/backend/src/services/audiobookService.js new file mode 100644 index 0000000..06bf1b1 --- /dev/null +++ b/backend/src/services/audiobookService.js @@ -0,0 +1,310 @@ +const path = require('path'); +const { sanitizeFileName } = require('../utils/files'); + +const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']); +const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']); +const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})'; +const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})'; + +function normalizeText(value) { + return String(value || '') + .normalize('NFC') + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function parseOptionalYear(value) { + const text = normalizeText(value); + if (!text) { + return null; + } + const match = text.match(/\b(19|20)\d{2}\b/); + if (!match) { + return null; + } + return Number(match[0]); +} + +function normalizeOutputFormat(value) { + const format = String(value || '').trim().toLowerCase(); + return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3'; +} + +function normalizeInputExtension(filePath) { + return path.extname(String(filePath || '')).trim().toLowerCase(); +} + +function isSupportedInputFile(filePath) { + return SUPPORTED_INPUT_EXTENSIONS.has(normalizeInputExtension(filePath)); +} + +function normalizeTagMap(tags = null) { + const source = tags && typeof tags === 'object' ? tags : {}; + const result = {}; + for (const [key, value] of Object.entries(source)) { + const normalizedKey = String(key || '').trim().toLowerCase(); + if (!normalizedKey) { + continue; + } + const normalizedValue = normalizeText(value); + if (!normalizedValue) { + continue; + } + result[normalizedKey] = normalizedValue; + } + return result; +} + +function pickTag(tags, keys = []) { + const normalized = normalizeTagMap(tags); + for (const key of keys) { + const value = normalized[String(key || '').trim().toLowerCase()]; + if (value) { + return value; + } + } + return null; +} + +function buildChapterList(probe = null) { + const chapters = Array.isArray(probe?.chapters) ? probe.chapters : []; + return chapters.map((chapter, index) => { + 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}`; + return { + index: index + 1, + title, + startSeconds: Number.isFinite(startSeconds) ? startSeconds : 0, + endSeconds: Number.isFinite(endSeconds) ? endSeconds : 0 + }; + }); +} + +function parseProbeOutput(rawOutput) { + if (!rawOutput) { + return null; + } + try { + return JSON.parse(rawOutput); + } catch (_error) { + return null; + } +} + +function buildMetadataFromProbe(probe = null, originalName = null) { + const format = probe?.format && typeof probe.format === 'object' ? probe.format : {}; + const tags = normalizeTagMap(format.tags); + const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || ''))); + const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook'; + const title = pickTag(tags, ['title', 'album']) || fallbackTitle; + const author = pickTag(tags, ['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 durationSeconds = Number(format.duration || 0); + const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0 + ? Math.round(durationSeconds * 1000) + : 0; + const chapters = buildChapterList(probe); + return { + title, + author, + narrator, + series, + part, + year, + album: title, + artist: author, + durationMs, + chapters, + tags + }; +} + +function normalizeTemplateTokenKey(rawKey) { + const key = String(rawKey || '').trim().toLowerCase(); + if (!key) { + return ''; + } + if (key === 'artist') { + return 'author'; + } + return key; +} + +function cleanupRenderedTemplate(value) { + return String(value || '') + .replace(/\(\s*\)/g, '') + .replace(/\[\s*]/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +function renderTemplate(template, values) { + const source = String(template || DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE).trim() + || DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE; + const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => { + const normalizedKey = normalizeTemplateTokenKey(keyA || keyB); + const rawValue = values?.[normalizedKey]; + if (rawValue === undefined || rawValue === null || rawValue === '') { + return ''; + } + return String(rawValue); + }); + return cleanupRenderedTemplate(rendered); +} + +function buildTemplateValues(metadata = {}, format = null) { + 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'); + const year = metadata.year ? String(metadata.year) : ''; + return { + author, + title, + narrator: narrator === 'unknown' ? '' : narrator, + series: series === 'unknown' ? '' : series, + part: part === 'unknown' ? '' : part, + year, + format: format ? String(format).trim().toLowerCase() : '' + }; +} + +function splitRenderedPath(value) { + return String(value || '') + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((segment) => sanitizeFileName(segment)) + .filter(Boolean); +} + +function resolveTemplatePathParts(template, values, fallbackBaseName) { + const rendered = renderTemplate(template, values); + const parts = splitRenderedPath(rendered); + if (parts.length === 0) { + return { + folderParts: [], + baseName: sanitizeFileName(fallbackBaseName || 'untitled') + }; + } + return { + folderParts: parts.slice(0, -1), + baseName: parts[parts.length - 1] + }; +} + +function buildRawStoragePaths(metadata, jobId, rawBaseDir, rawTemplate = DEFAULT_AUDIOBOOK_RAW_TEMPLATE, inputFileName = 'input.aax') { + const ext = normalizeInputExtension(inputFileName) || '.aax'; + const values = buildTemplateValues(metadata); + const fallbackBaseName = path.basename(String(inputFileName || 'input.aax'), ext); + const { folderParts, baseName } = resolveTemplatePathParts(rawTemplate, values, fallbackBaseName); + const rawDirName = `${baseName} - RAW - job-${jobId}`; + const rawDir = path.join(String(rawBaseDir || ''), ...folderParts, rawDirName); + const rawFilePath = path.join(rawDir, `${baseName}${ext}`); + return { + rawDir, + rawFilePath, + rawFileName: `${baseName}${ext}`, + rawDirName + }; +} + +function buildOutputPath(metadata, movieBaseDir, outputTemplate = DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, outputFormat = 'mp3') { + const normalizedFormat = normalizeOutputFormat(outputFormat); + const values = buildTemplateValues(metadata, normalizedFormat); + const fallbackBaseName = values.title || 'audiobook'; + const { folderParts, baseName } = resolveTemplatePathParts(outputTemplate, values, fallbackBaseName); + return path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`); +} + +function buildProbeCommand(ffprobeCommand, inputPath) { + const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe'; + return { + cmd, + args: [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + '-show_streams', + '-show_chapters', + inputPath + ] + }; +} + +function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3') { + const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg'; + const format = normalizeOutputFormat(outputFormat); + const codecArgs = format === 'm4b' + ? ['-codec', 'copy'] + : (format === 'flac' + ? ['-codec:a', 'flac'] + : ['-codec:a', 'libmp3lame']); + return { + cmd, + args: ['-y', '-i', inputPath, ...codecArgs, outputPath] + }; +} + +function parseFfmpegTimestampToMs(rawValue) { + const value = String(rawValue || '').trim(); + const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/); + if (!match) { + return null; + } + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const fraction = match[4] ? Number(`0.${match[4]}`) : 0; + if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) { + return null; + } + return Math.round((((hours * 60) + minutes) * 60 + seconds + fraction) * 1000); +} + +function buildProgressParser(totalDurationMs) { + const durationMs = Number(totalDurationMs || 0); + if (!Number.isFinite(durationMs) || durationMs <= 0) { + return null; + } + return (line) => { + const match = String(line || '').match(/time=(\d+:\d{2}:\d{2}(?:\.\d+)?)/i); + if (!match) { + return null; + } + const currentMs = parseFfmpegTimestampToMs(match[1]); + if (!Number.isFinite(currentMs)) { + return null; + } + const percent = Math.max(0, Math.min(100, Number(((currentMs / durationMs) * 100).toFixed(2)))); + return { + percent, + eta: null + }; + }; +} + +module.exports = { + SUPPORTED_INPUT_EXTENSIONS, + SUPPORTED_OUTPUT_FORMATS, + DEFAULT_AUDIOBOOK_RAW_TEMPLATE, + DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, + normalizeOutputFormat, + isSupportedInputFile, + buildMetadataFromProbe, + buildRawStoragePaths, + buildOutputPath, + buildProbeCommand, + parseProbeOutput, + buildEncodeCommand, + buildProgressParser +}; diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 97fc73d..d8785d2 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -21,7 +21,7 @@ function parseJsonSafe(raw, fallback = null) { const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024; const processLogStreams = new Map(); -const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'other']; +const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'other']; const RAW_INCOMPLETE_PREFIX = 'Incomplete_'; const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_'; @@ -183,6 +183,30 @@ function hasCdStructure(rawPath) { } } +function hasAudiobookStructure(rawPath) { + const basePath = String(rawPath || '').trim(); + if (!basePath) { + return false; + } + + try { + if (!fs.existsSync(basePath)) { + return false; + } + const stat = fs.statSync(basePath); + if (stat.isFile()) { + return path.extname(basePath).toLowerCase() === '.aax'; + } + if (!stat.isDirectory()) { + return false; + } + const entries = fs.readdirSync(basePath); + return entries.some((entry) => path.extname(entry).toLowerCase() === '.aax'); + } catch (_error) { + return false; + } +} + function detectOrphanMediaType(rawPath) { if (hasBlurayStructure(rawPath)) { return 'bluray'; @@ -193,6 +217,9 @@ function detectOrphanMediaType(rawPath) { if (hasCdStructure(rawPath)) { return 'cd'; } + if (hasAudiobookStructure(rawPath)) { + return 'audiobook'; + } return 'other'; } @@ -269,6 +296,9 @@ function normalizeMediaTypeValue(value) { if (raw === 'cd' || raw === 'audio_cd') { return 'cd'; } + if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') { + return 'audiobook'; + } return null; } @@ -288,7 +318,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn || job?.mediaType ); - if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd') { + if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd' || profileHint === 'audiobook') { return profileHint; } @@ -312,6 +342,15 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn if (Array.isArray(mkInfo?.tracks) && mkInfo.tracks.length > 0) { return 'cd'; } + if (hasAudiobookStructure(rawPath) || hasAudiobookStructure(encodeInputPath)) { + return 'audiobook'; + } + if (String(hbInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') { + return 'audiobook'; + } + if (String(plan?.mode || '').trim().toLowerCase() === 'audiobook') { + return 'audiobook'; + } if (hasBlurayStructure(rawPath)) { return 'bluray'; @@ -1383,7 +1422,7 @@ class HistoryService { const settings = await settingsService.getSettingsMap(); const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir'); if (rawDirs.length === 0) { - const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,other}).'); + const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,cd,audiobook,other}).'); error.statusCode = 400; throw error; } @@ -1457,6 +1496,7 @@ class HistoryService { hasBlurayStructure: detectedMediaType === 'bluray', hasDvdStructure: detectedMediaType === 'dvd', hasCdStructure: detectedMediaType === 'cd', + hasAudiobookStructure: detectedMediaType === 'audiobook', lastModifiedAt: stat.mtime.toISOString() }); seenOrphanPaths.add(normalizedPath); @@ -1483,7 +1523,7 @@ class HistoryService { } if (rawDirs.length === 0) { - const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,other}).'); + const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,cd,audiobook,other}).'); error.statusCode = 400; throw error; } diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index cdc68f1..e919e19 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -1,12 +1,14 @@ const fs = require('fs'); const path = require('path'); const { EventEmitter } = require('events'); +const { execFile } = require('child_process'); const { getDb } = require('../db/database'); const settingsService = require('./settingsService'); const historyService = require('./historyService'); const omdbService = require('./omdbService'); const musicBrainzService = require('./musicBrainzService'); const cdRipService = require('./cdRipService'); +const audiobookService = require('./audiobookService'); const scriptService = require('./scriptService'); const scriptChainService = require('./scriptChainService'); const runtimeActivityService = require('./runtimeActivityService'); @@ -249,11 +251,14 @@ function normalizeMediaProfile(value) { if (raw === 'cd' || raw === 'audio_cd') { return 'cd'; } + if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') { + return 'audiobook'; + } return null; } function isSpecificMediaProfile(value) { - return value === 'bluray' || value === 'dvd' || value === 'cd'; + return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook'; } function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { @@ -365,6 +370,9 @@ function inferMediaProfileFromRawPath(rawPath) { try { const sourceStat = fs.statSync(source); if (sourceStat.isFile()) { + if (path.extname(source).toLowerCase() === '.aax') { + return 'audiobook'; + } if (isLikelyExtensionlessDvdImageFile(source, sourceStat.size)) { return 'dvd'; } @@ -393,6 +401,15 @@ function inferMediaProfileFromRawPath(rawPath) { // ignore fs errors } + try { + const audiobookFiles = findMediaFiles(source, ['.aax']); + if (audiobookFiles.length > 0) { + return 'audiobook'; + } + } catch (_error) { + // ignore fs errors + } + if (listTopLevelExtensionlessDvdImages(source).length > 0) { return 'dvd'; } @@ -624,6 +641,69 @@ function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFin }; } +function buildAudiobookMetadataForJob(job, makemkvInfo = null, encodePlan = null) { + const mkInfo = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const metadataSource = plan?.metadata && typeof plan.metadata === 'object' + ? plan.metadata + : ( + mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' + ? mkInfo.selectedMetadata + : (mkInfo?.detectedMetadata && typeof mkInfo.detectedMetadata === 'object' ? mkInfo.detectedMetadata : {}) + ); + return { + title: String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook', + author: String(metadataSource?.author || metadataSource?.artist || '').trim() || null, + narrator: String(metadataSource?.narrator || '').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 : []) + }; +} + +function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePlan = null, fallbackJobId = null) { + const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan); + const movieDir = String( + settings?.movie_dir + || settings?.raw_dir + || settingsService.DEFAULT_AUDIOBOOK_DIR + || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR + || '' + ).trim(); + const outputTemplate = String( + settings?.output_template + || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_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 { + metadata, + outputFormat, + preferredFinalOutputPath, + incompleteOutputPath + }; +} + function truncateLine(value, max = 180) { const raw = String(value || '').replace(/\s+/g, ' ').trim(); if (raw.length <= max) { @@ -3177,7 +3257,9 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP if (sourceStat.isFile()) { const ext = path.extname(sourcePath).toLowerCase(); if ( - ext === '.mkv' + ext === '.aax' + || ext === '.m4b' + || ext === '.mkv' || ext === '.mp4' || isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size) ) { @@ -3206,11 +3288,11 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP }; } - const primary = findMediaFiles(sourcePath, ['.mkv', '.mp4']); + const primary = findMediaFiles(sourcePath, ['.aax', '.m4b', '.mkv', '.mp4']); if (primary.length > 0) { return { mediaFiles: primary, - source: 'mkv' + source: path.extname(primary[0]?.path || '').toLowerCase() === '.aax' ? 'audiobook' : 'mkv' }; } @@ -3700,6 +3782,8 @@ class PipelineService extends EventEmitter { const effectiveSettings = settingsService.resolveEffectiveToolSettings(sourceMap, normalizedMediaProfile); const preferredDefaultRawDir = normalizedMediaProfile === 'cd' ? settingsService.DEFAULT_CD_DIR + : normalizedMediaProfile === 'audiobook' + ? settingsService.DEFAULT_AUDIOBOOK_RAW_DIR : settingsService.DEFAULT_RAW_DIR; const uniqueRawDirs = Array.from( new Set( @@ -3709,9 +3793,11 @@ class PipelineService extends EventEmitter { sourceMap?.raw_dir_bluray, sourceMap?.raw_dir_dvd, sourceMap?.raw_dir_cd, + sourceMap?.raw_dir_audiobook, preferredDefaultRawDir, settingsService.DEFAULT_RAW_DIR, - settingsService.DEFAULT_CD_DIR + settingsService.DEFAULT_CD_DIR, + settingsService.DEFAULT_AUDIOBOOK_RAW_DIR ] .map((item) => String(item || '').trim()) .filter(Boolean) @@ -3741,7 +3827,9 @@ class PipelineService extends EventEmitter { settings?.raw_dir_bluray, settings?.raw_dir_dvd, settings?.raw_dir_cd, - settingsService.DEFAULT_CD_DIR + settings?.raw_dir_audiobook, + settingsService.DEFAULT_CD_DIR, + settingsService.DEFAULT_AUDIOBOOK_RAW_DIR ].map((d) => String(d || '').trim()).filter(Boolean); const allRawDirs = [rawBaseDir, settingsService.DEFAULT_RAW_DIR, ...rawExtraDirs] .filter((d, i, arr) => arr.indexOf(d) === i && d && fs.existsSync(d)); @@ -5331,6 +5419,29 @@ class PipelineService extends EventEmitter { }; } + async runCapturedCommand(cmd, args = []) { + const command = String(cmd || '').trim(); + const argv = Array.isArray(args) ? args.map((item) => String(item)) : []; + if (!command) { + throw new Error('Kommando fehlt.'); + } + + return new Promise((resolve, reject) => { + execFile(command, argv, { maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + return; + } + resolve({ + stdout: String(stdout || ''), + stderr: String(stderr || '') + }); + }); + }); + } + async ensureMakeMKVRegistration(jobId, stage) { const registrationConfig = await settingsService.buildMakeMKVRegisterConfig(); if (!registrationConfig) { @@ -7021,6 +7132,16 @@ class PipelineService extends EventEmitter { throw error; } + const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json); + const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); + const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, { + makemkvInfo: preloadedMakemkvInfo, + encodePlan: preloadedEncodePlan + }); + if (preloadedMediaProfile === 'audiobook') { + return this.startAudiobookEncode(jobId, { ...options, preloadedJob }); + } + const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE'; if (isReadyToEncode) { // Check whether this confirmed job will rip first (pre_rip mode) or encode directly. @@ -7061,10 +7182,6 @@ class PipelineService extends EventEmitter { return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }); } - this.ensureNotBusy('startPreparedJob', jobId); - logger.info('startPreparedJob:requested', { jobId }); - this.cancelRequestedByJob.delete(Number(jobId)); - const job = options?.preloadedJob || await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); @@ -7072,6 +7189,20 @@ class PipelineService extends EventEmitter { throw error; } + const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json); + const jobEncodePlan = this.safeParseJson(job.encode_plan_json); + const jobMediaProfile = this.resolveMediaProfileForJob(job, { + makemkvInfo: jobMakemkvInfo, + encodePlan: jobEncodePlan + }); + if (jobMediaProfile === 'audiobook') { + return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job }); + } + + this.ensureNotBusy('startPreparedJob', jobId); + logger.info('startPreparedJob:requested', { jobId }); + this.cancelRequestedByJob.delete(Number(jobId)); + if (!job.title && !job.detected_title) { const error = new Error('Start nicht möglich: keine Metadaten vorhanden.'); error.statusCode = 400; @@ -7489,6 +7620,63 @@ class PipelineService extends EventEmitter { } const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); + const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); + const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, { + makemkvInfo: mkInfo, + encodePlan: sourceEncodePlan, + rawPath: sourceJob.raw_path + }); + if (reencodeMediaProfile === 'audiobook') { + const reencodeSettings = await settingsService.getSettingsMap(); + const resolvedAudiobookRawPath = this.resolveCurrentRawPathForSettings( + reencodeSettings, + reencodeMediaProfile, + sourceJob.raw_path + ); + if (!resolvedAudiobookRawPath) { + const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + + const rawInput = findPreferredRawInput(resolvedAudiobookRawPath); + if (!rawInput) { + const error = new Error('Re-Encode nicht möglich: keine AAX-Datei im RAW-Pfad gefunden.'); + error.statusCode = 400; + throw error; + } + + const refreshedPlan = { + ...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}), + mediaProfile: 'audiobook', + mode: 'audiobook', + encodeInputPath: rawInput.path + }; + + await historyService.resetProcessLog(sourceJobId); + await historyService.updateJob(sourceJobId, { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + start_time: null, + end_time: null, + error_message: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: JSON.stringify(refreshedPlan), + encode_input_path: rawInput.path, + encode_review_confirmed: 1, + raw_path: resolvedAudiobookRawPath + }); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `Audiobook-Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input: ${rawInput.path}` + ); + + return this.startPreparedJob(sourceJobId); + } + const ripSuccessful = this.isRipSuccessful(sourceJob); if (!ripSuccessful) { const error = new Error( @@ -7497,11 +7685,6 @@ class PipelineService extends EventEmitter { error.statusCode = 400; throw error; } - - const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, { - makemkvInfo: mkInfo, - rawPath: sourceJob.raw_path - }); const reencodeSettings = await settingsService.getSettingsMap(); const resolvedReencodeRawPath = this.resolveCurrentRawPathForSettings( reencodeSettings, @@ -9226,6 +9409,7 @@ class PipelineService extends EventEmitter { encodePlan: sourceEncodePlan }); const isCdRetry = mediaProfile === 'cd'; + const isAudiobookRetry = mediaProfile === 'audiobook'; let cdRetryConfig = null; if (isCdRetry) { @@ -9283,7 +9467,7 @@ class PipelineService extends EventEmitter { selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []), selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || []) }; - } else { + } else if (!isAudiobookRetry) { const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig( retrySettings, @@ -9353,7 +9537,7 @@ class PipelineService extends EventEmitter { const retryJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, - status: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING', + status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), detectedTitle: sourceJob.detected_title || sourceJob.title || null }); const retryJobId = Number(retryJob?.id || 0); @@ -9370,19 +9554,27 @@ class PipelineService extends EventEmitter { omdb_json: sourceJob.omdb_json || null, selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), makemkv_info_json: sourceJob.makemkv_info_json || null, - rip_successful: 0, + rip_successful: isAudiobookRetry ? 1 : 0, error_message: null, end_time: null, handbrake_info_json: null, mediainfo_info_json: null, - encode_plan_json: isCdRetry + encode_plan_json: (isCdRetry || isAudiobookRetry) ? (sourceJob.encode_plan_json || null) : null, - encode_input_path: null, - encode_review_confirmed: 0, + encode_input_path: isAudiobookRetry + ? ( + sourceJob.encode_input_path + || sourceEncodePlan?.encodeInputPath + || sourceMakemkvInfo?.rawFilePath + || null + ) + : null, + encode_review_confirmed: isAudiobookRetry ? 1 : 0, output_path: null, - status: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING', - last_state: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING' + raw_path: isAudiobookRetry ? (sourceJob.raw_path || null) : null, + status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), + last_state: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING') }; await historyService.updateJob(retryJobId, retryUpdatePayload); @@ -9397,10 +9589,10 @@ class PipelineService extends EventEmitter { await historyService.appendLog( retryJobId, 'USER_ACTION', - `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : 'Disc'}).` + `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : 'Disc')}).` ); await historyService.retireJobInFavorOf(jobId, retryJobId, { - reason: isCdRetry ? 'cd_retry' : 'retry' + reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : 'retry') }); this.cancelRequestedByJob.delete(retryJobId); @@ -9412,6 +9604,14 @@ class PipelineService extends EventEmitter { error: errorToMeta(error) }); }); + } else if (isAudiobookRetry) { + const startResult = await this.startPreparedJob(retryJobId); + return { + sourceJobId: Number(jobId), + jobId: retryJobId, + replacedSourceJob: true, + ...(startResult && typeof startResult === 'object' ? startResult : {}) + }; } else { this.startRipEncode(retryJobId).catch((error) => { logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) }); @@ -10545,7 +10745,7 @@ class PipelineService extends EventEmitter { cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null } : {}), canRestartEncodeFromLastSettings: hasConfirmedPlan, - canRestartReviewFromRaw: hasRawPath + canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath } }); this.cancelRequestedByJob.delete(Number(jobId)); @@ -10556,6 +10756,410 @@ class PipelineService extends EventEmitter { }); } + async uploadAudiobookFile(file, options = {}) { + const tempFilePath = String(file?.path || '').trim(); + const originalName = String(file?.originalname || file?.originalName || '').trim() + || path.basename(tempFilePath || 'upload.aax'); + const detectedTitle = path.basename(originalName, path.extname(originalName)) || 'Audiobook'; + const requestedFormat = String(options?.format || '').trim().toLowerCase() || null; + const startImmediately = options?.startImmediately === undefined + ? true + : !['0', 'false', 'no', 'off'].includes(String(options.startImmediately).trim().toLowerCase()); + + if (!tempFilePath || !fs.existsSync(tempFilePath)) { + const error = new Error('Upload-Datei fehlt.'); + error.statusCode = 400; + throw error; + } + if (!audiobookService.isSupportedInputFile(originalName)) { + const error = new Error('Nur AAX-Dateien werden für Audiobooks unterstützt.'); + error.statusCode = 400; + throw error; + } + + const settings = await settingsService.getEffectiveSettingsMap('audiobook'); + const rawBaseDir = String( + settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR || settingsService.DEFAULT_RAW_DIR || '' + ).trim(); + const rawTemplate = String( + settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE; + const outputTemplate = String( + settings?.output_template || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE; + const outputFormat = audiobookService.normalizeOutputFormat( + requestedFormat || settings?.output_extension || 'mp3' + ); + const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + + const job = await historyService.createJob({ + discDevice: null, + status: 'ANALYZING', + detectedTitle + }); + + let stagedRawDir = null; + let stagedRawFilePath = null; + + try { + await historyService.resetProcessLog(job.id); + await historyService.appendLog(job.id, 'SYSTEM', `AAX-Upload empfangen: ${originalName}`); + + const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, tempFilePath); + const captured = await this.runCapturedCommand(probeConfig.cmd, probeConfig.args); + const probe = audiobookService.parseProbeOutput(captured.stdout); + if (!probe) { + const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden.'); + error.statusCode = 500; + throw error; + } + + const metadata = audiobookService.buildMetadataFromProbe(probe, originalName); + const storagePaths = audiobookService.buildRawStoragePaths( + metadata, + job.id, + rawBaseDir, + rawTemplate, + originalName + ); + + ensureDir(storagePaths.rawDir); + fs.renameSync(tempFilePath, storagePaths.rawFilePath); + stagedRawDir = storagePaths.rawDir; + stagedRawFilePath = storagePaths.rawFilePath; + chownRecursive(storagePaths.rawDir, settings?.raw_dir_owner); + + const makemkvInfo = this.withAnalyzeContextMediaProfile({ + status: 'SUCCESS', + source: 'aax_upload', + importedAt: nowIso(), + mediaProfile: 'audiobook', + rawFileName: storagePaths.rawFileName, + rawFilePath: storagePaths.rawFilePath, + chapters: metadata.chapters, + detectedMetadata: metadata, + selectedMetadata: metadata, + probeSummary: { + durationMs: metadata.durationMs, + tagKeys: Object.keys(metadata.tags || {}) + } + }, 'audiobook'); + + const encodePlan = { + mediaProfile: 'audiobook', + mode: 'audiobook', + sourceType: 'upload', + uploadedAt: nowIso(), + format: outputFormat, + rawTemplate, + outputTemplate, + encodeInputPath: storagePaths.rawFilePath, + metadata, + 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, + raw_path: storagePaths.rawDir, + rip_successful: 1, + makemkv_info_json: JSON.stringify(makemkvInfo), + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: JSON.stringify(encodePlan), + encode_input_path: storagePaths.rawFilePath, + encode_review_confirmed: 1, + output_path: 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()}` + ); + + if (!startImmediately) { + return { + jobId: job.id, + started: false, + queued: false, + stage: 'READY_TO_START' + }; + } + + const startResult = await this.startPreparedJob(job.id); + return { + jobId: job.id, + ...(startResult && typeof startResult === 'object' ? startResult : {}) + }; + } catch (error) { + const updatePayload = { + status: 'ERROR', + last_state: 'ERROR', + end_time: nowIso(), + error_message: error?.message || 'Audiobook-Upload fehlgeschlagen.' + }; + if (stagedRawDir) { + updatePayload.raw_path = stagedRawDir; + } + if (stagedRawFilePath) { + updatePayload.encode_input_path = stagedRawFilePath; + } + await historyService.updateJob(job.id, updatePayload).catch(() => {}); + await historyService.appendLog( + job.id, + 'SYSTEM', + `Audiobook-Upload fehlgeschlagen: ${error?.message || 'unknown'}` + ).catch(() => {}); + throw error; + } finally { + if (tempFilePath && fs.existsSync(tempFilePath)) { + try { + fs.rmSync(tempFilePath, { force: true }); + } catch (_error) { + // best effort cleanup + } + } + } + } + + async startAudiobookEncode(jobId, options = {}) { + const immediate = Boolean(options?.immediate); + if (!immediate) { + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + jobId, + () => this.startAudiobookEncode(jobId, { ...options, immediate: true }) + ); + } + + this.ensureNotBusy('startAudiobookEncode', jobId); + logger.info('audiobook:encode:start', { jobId }); + this.cancelRequestedByJob.delete(Number(jobId)); + + const job = options?.preloadedJob || await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json); + const makemkvInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo, + mediaProfile: 'audiobook' + }); + if (mediaProfile !== 'audiobook') { + const error = new Error(`Job ${jobId} ist kein Audiobook-Job.`); + error.statusCode = 400; + throw error; + } + + const settings = await settingsService.getEffectiveSettingsMap('audiobook'); + const resolvedRawPath = this.resolveCurrentRawPathForSettings( + settings, + 'audiobook', + job.raw_path + ) || String(job.raw_path || '').trim() || null; + + let inputPath = String( + job.encode_input_path + || encodePlan?.encodeInputPath + || makemkvInfo?.rawFilePath + || '' + ).trim(); + + if ((!inputPath || !fs.existsSync(inputPath)) && resolvedRawPath) { + inputPath = findPreferredRawInput(resolvedRawPath)?.path || ''; + } + + if (!inputPath) { + const error = new Error('Audiobook-Encode nicht möglich: keine Input-Datei gefunden.'); + error.statusCode = 400; + throw error; + } + if (!fs.existsSync(inputPath)) { + const error = new Error(`Audiobook-Encode nicht möglich: Input-Datei fehlt (${inputPath}).`); + error.statusCode = 400; + throw error; + } + + const { + metadata, + outputFormat, + preferredFinalOutputPath, + incompleteOutputPath + } = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId); + ensureDir(path.dirname(incompleteOutputPath)); + + await historyService.resetProcessLog(jobId); + await this.setState('ENCODING', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: `Audiobook-Encoding (${outputFormat.toUpperCase()})`, + context: { + jobId, + mode: 'audiobook', + mediaProfile: 'audiobook', + inputPath, + outputPath: incompleteOutputPath, + format: outputFormat, + chapters: metadata.chapters, + selectedMetadata: { + title: metadata.title || job.title || job.detected_title || null, + year: metadata.year ?? job.year ?? null, + author: metadata.author || null, + narrator: metadata.narrator || null, + poster: job.poster_url || null + }, + canRestartEncodeFromLastSettings: false, + canRestartReviewFromRaw: false + } + }); + + await historyService.updateJob(jobId, { + status: 'ENCODING', + last_state: 'ENCODING', + start_time: nowIso(), + end_time: null, + error_message: null, + raw_path: resolvedRawPath || job.raw_path || null, + output_path: incompleteOutputPath, + encode_input_path: inputPath + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}` + ); + + void this.notifyPushover('encoding_started', { + title: 'Ripster - Audiobook-Encoding gestartet', + message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` + }); + + try { + const ffmpegConfig = audiobookService.buildEncodeCommand( + settings?.ffmpeg_command || 'ffmpeg', + inputPath, + incompleteOutputPath, + outputFormat + ); + 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) + }); + + const outputFinalization = finalizeOutputPathForCompletedEncode( + incompleteOutputPath, + preferredFinalOutputPath + ); + const finalizedOutputPath = outputFinalization.outputPath; + chownRecursive(path.dirname(finalizedOutputPath), settings?.movie_dir_owner); + + if (outputFinalization.outputPathWithTimestamp) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Finaler Audiobook-Output existierte bereits. Zielpfad mit Timestamp verwendet: ${finalizedOutputPath}` + ); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Audiobook-Output finalisiert: ${finalizedOutputPath}` + ); + + const ffmpegInfo = { + ...ffmpegRunInfo, + mode: 'audiobook_encode', + format: outputFormat, + metadata, + inputPath, + outputPath: finalizedOutputPath + }; + + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify(ffmpegInfo), + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + rip_successful: 1, + raw_path: resolvedRawPath || job.raw_path || null, + output_path: finalizedOutputPath, + error_message: null + }); + + await this.setState('FINISHED', { + activeJobId: jobId, + progress: 100, + eta: null, + statusText: 'Audiobook abgeschlossen', + context: { + jobId, + mode: 'audiobook', + mediaProfile: 'audiobook', + outputPath: finalizedOutputPath + } + }); + + void this.notifyPushover('job_finished', { + title: 'Ripster - Audiobook abgeschlossen', + message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` + }); + + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { + await this.setState('IDLE', { + finishingJobId: jobId, + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + }, 3000); + + return { + started: true, + stage: 'ENCODING', + outputPath: finalizedOutputPath + }; + } catch (error) { + if (error.runInfo && error.runInfo.source === 'FFMPEG') { + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify({ + ...error.runInfo, + mode: 'audiobook_encode', + format: outputFormat, + inputPath + }) + }).catch(() => {}); + } + logger.error('audiobook:encode:failed', { jobId, error: errorToMeta(error) }); + await this.failJob(jobId, 'ENCODING', error); + error.jobAlreadyFailed = true; + throw error; + } + } + // ── CD Pipeline ───────────────────────────────────────────────────────────── async analyzeCd(device) { @@ -10775,24 +11379,54 @@ class PipelineService extends EventEmitter { const renamed = []; const mediaProfile = this.resolveMediaProfileForJob(job); const isCd = mediaProfile === 'cd'; + const isAudiobook = mediaProfile === 'audiobook'; const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const mkInfo = this.safeParseJson(job.makemkv_info_json) || {}; + const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; // Rename raw folder const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null; if (currentRawPath && fs.existsSync(currentRawPath)) { - const rawBaseDir = path.dirname(currentRawPath); - const newMetadataBase = buildRawMetadataBase({ - title: job.title || job.detected_title || null, - year: job.year || null - }, jobId); - const currentState = resolveRawFolderStateFromPath(currentRawPath); - const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState }); - const newRawPath = path.join(rawBaseDir, newRawDirName); + let newRawPath; + if (isAudiobook) { + const audiobookMeta = buildAudiobookMetadataForJob(job, mkInfo, encodePlan); + const rawTemplate = String( + settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE; + const currentInputPath = findPreferredRawInput(currentRawPath)?.path + || String(job.encode_input_path || mkInfo?.rawFilePath || '').trim() + || 'input.aax'; + const nextRaw = audiobookService.buildRawStoragePaths( + audiobookMeta, + jobId, + settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR, + rawTemplate, + path.basename(currentInputPath) + ); + newRawPath = nextRaw.rawDir; + } else { + const rawBaseDir = path.dirname(currentRawPath); + const newMetadataBase = buildRawMetadataBase({ + title: job.title || job.detected_title || null, + year: job.year || null + }, jobId); + const currentState = resolveRawFolderStateFromPath(currentRawPath); + const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState }); + newRawPath = path.join(rawBaseDir, newRawDirName); + } if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(newRawPath) && !fs.existsSync(newRawPath)) { try { + fs.mkdirSync(path.dirname(newRawPath), { recursive: true }); fs.renameSync(currentRawPath, newRawPath); - await historyService.updateJob(jobId, { raw_path: newRawPath }); + const updatePayload = { raw_path: newRawPath }; + if (isAudiobook) { + const previousInputPath = String(job.encode_input_path || mkInfo?.rawFilePath || '').trim(); + if (previousInputPath && previousInputPath.startsWith(`${currentRawPath}${path.sep}`)) { + updatePayload.encode_input_path = path.join(newRawPath, path.basename(previousInputPath)); + } + } + await historyService.updateJob(jobId, updatePayload); renamed.push({ type: 'raw', from: currentRawPath, to: newRawPath }); logger.info('rename-job-folders:raw', { jobId, from: currentRawPath, to: newRawPath }); } catch (err) { @@ -10828,7 +11462,9 @@ class PipelineService extends EventEmitter { } } } else { - const newOutputPath = buildFinalOutputPathFromJob(settings, job, jobId); + const newOutputPath = isAudiobook + ? buildAudiobookOutputConfig(settings, job, mkInfo, encodePlan, jobId).preferredFinalOutputPath + : buildFinalOutputPathFromJob(settings, job, jobId); if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newOutputPath) && !fs.existsSync(newOutputPath)) { fs.mkdirSync(path.dirname(newOutputPath), { recursive: true }); moveFileWithFallback(currentOutputPath, newOutputPath); @@ -10840,7 +11476,11 @@ class PipelineService extends EventEmitter { } catch (_ignoreErr) {} await historyService.updateJob(jobId, { output_path: newOutputPath }); renamed.push({ type: 'output', from: currentOutputPath, to: newOutputPath }); - logger.info('rename-job-folders:film-output', { jobId, from: currentOutputPath, to: newOutputPath }); + logger.info(isAudiobook ? 'rename-job-folders:audiobook-output' : 'rename-job-folders:film-output', { + jobId, + from: currentOutputPath, + to: newOutputPath + }); } } } catch (err) { diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index dab897b..a821e08 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -13,7 +13,13 @@ const { const { splitArgs } = require('../utils/commandLine'); const { setLogRootDir } = require('./logPathService'); -const { defaultRawDir: DEFAULT_RAW_DIR, defaultMovieDir: DEFAULT_MOVIE_DIR, defaultCdDir: DEFAULT_CD_DIR } = require('../config'); +const { + defaultRawDir: DEFAULT_RAW_DIR, + defaultMovieDir: DEFAULT_MOVIE_DIR, + defaultCdDir: DEFAULT_CD_DIR, + defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR, + defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR +} = require('../config'); const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac']; const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000; @@ -38,27 +44,31 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']); const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']); const LOG_DIR_SETTING_KEY = 'log_dir'; -const MEDIA_PROFILES = ['bluray', 'dvd', 'cd']; +const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook']; const PROFILED_SETTINGS = { raw_dir: { bluray: 'raw_dir_bluray', dvd: 'raw_dir_dvd', - cd: 'raw_dir_cd' + cd: 'raw_dir_cd', + audiobook: 'raw_dir_audiobook' }, raw_dir_owner: { bluray: 'raw_dir_bluray_owner', dvd: 'raw_dir_dvd_owner', - cd: 'raw_dir_cd_owner' + cd: 'raw_dir_cd_owner', + audiobook: 'raw_dir_audiobook_owner' }, movie_dir: { bluray: 'movie_dir_bluray', dvd: 'movie_dir_dvd', - cd: 'movie_dir_cd' + cd: 'movie_dir_cd', + audiobook: 'movie_dir_audiobook' }, movie_dir_owner: { bluray: 'movie_dir_bluray_owner', dvd: 'movie_dir_dvd_owner', - cd: 'movie_dir_cd_owner' + cd: 'movie_dir_cd_owner', + audiobook: 'movie_dir_audiobook_owner' }, mediainfo_extra_args: { bluray: 'mediainfo_extra_args_bluray', @@ -86,11 +96,13 @@ const PROFILED_SETTINGS = { }, output_extension: { bluray: 'output_extension_bluray', - dvd: 'output_extension_dvd' + dvd: 'output_extension_dvd', + audiobook: 'output_extension_audiobook' }, output_template: { bluray: 'output_template_bluray', - dvd: 'output_template_dvd' + dvd: 'output_template_dvd', + audiobook: 'output_template_audiobook' } }; const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([ @@ -372,11 +384,17 @@ function normalizeMediaProfileValue(value) { if (raw === 'cd' || raw === 'audio_cd') { return 'cd'; } + if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') { + return 'audiobook'; + } return null; } function resolveProfileFallbackOrder(profile) { const normalized = normalizeMediaProfileValue(profile); + if (normalized === 'audiobook') { + return ['audiobook']; + } if (normalized === 'bluray') { return ['bluray', 'dvd']; } @@ -690,9 +708,21 @@ class SettingsService { // Fallback to hardcoded install defaults when no setting value is configured if (!hasUsableProfileSpecificValue(resolvedValue)) { if (legacyKey === 'raw_dir') { - resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR; + if (normalizedRequestedProfile === 'cd') { + resolvedValue = DEFAULT_CD_DIR; + } else if (normalizedRequestedProfile === 'audiobook') { + resolvedValue = DEFAULT_AUDIOBOOK_RAW_DIR; + } else { + resolvedValue = DEFAULT_RAW_DIR; + } } else if (legacyKey === 'movie_dir') { - resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_MOVIE_DIR; + if (normalizedRequestedProfile === 'cd') { + resolvedValue = DEFAULT_CD_DIR; + } else if (normalizedRequestedProfile === 'audiobook') { + resolvedValue = DEFAULT_AUDIOBOOK_DIR; + } else { + resolvedValue = DEFAULT_MOVIE_DIR; + } } } effective[legacyKey] = resolvedValue; @@ -724,14 +754,18 @@ class SettingsService { const bluray = this.resolveEffectiveToolSettings(map, 'bluray'); const dvd = this.resolveEffectiveToolSettings(map, 'dvd'); const cd = this.resolveEffectiveToolSettings(map, 'cd'); + const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook'); return { bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir }, dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir }, cd: { raw: cd.raw_dir, movies: cd.movie_dir }, + audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir }, defaults: { raw: DEFAULT_RAW_DIR, movies: DEFAULT_MOVIE_DIR, - cd: DEFAULT_CD_DIR + cd: DEFAULT_CD_DIR, + audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR, + audiobookMovies: DEFAULT_AUDIOBOOK_DIR } }; } @@ -1480,4 +1514,6 @@ const settingsServiceInstance = new SettingsService(); settingsServiceInstance.DEFAULT_RAW_DIR = DEFAULT_RAW_DIR; settingsServiceInstance.DEFAULT_MOVIE_DIR = DEFAULT_MOVIE_DIR; settingsServiceInstance.DEFAULT_CD_DIR = DEFAULT_CD_DIR; +settingsServiceInstance.DEFAULT_AUDIOBOOK_RAW_DIR = DEFAULT_AUDIOBOOK_RAW_DIR; +settingsServiceInstance.DEFAULT_AUDIOBOOK_DIR = DEFAULT_AUDIOBOOK_DIR; module.exports = settingsServiceInstance; diff --git a/db/schema.sql b/db/schema.sql index 284eda6..713a4b6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -355,6 +355,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', 'cdparanoia'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe'); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ( 'cd_output_template', @@ -371,6 +379,19 @@ VALUES ( INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}'); +-- Tools – Audiobook +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_extension_audiobook', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Audiobook-Datei.', 'mp3', '[{"label":"M4B","value":"m4b"},{"label":"MP3","value":"mp3"},{"label":"FLAC","value":"flac"}]', '{}', 730); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_audiobook', 'mp3'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})'); + +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); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})'); + -- Pfade – CD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104); @@ -388,6 +409,23 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL); +-- Pfade – Audiobook +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL); + -- Metadaten INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 108ec9b..eb77cca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.9.1-6", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.9.1-6", + "version": "0.10.0", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index e724b31..32fe01b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.9.1-6", + "version": "0.10.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 213a6e0..e7f2e46 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -78,11 +78,13 @@ function afterMutationInvalidate(prefixes = []) { } async function request(path, options = {}) { + const isFormDataBody = typeof FormData !== 'undefined' && options?.body instanceof FormData; + const mergedHeaders = { + ...(isFormDataBody ? {} : { 'Content-Type': 'application/json' }), + ...(options.headers || {}) + }; const response = await fetch(`${API_BASE}${path}`, { - headers: { - 'Content-Type': 'application/json', - ...(options.headers || {}) - }, + headers: mergedHeaders, ...options }); @@ -301,6 +303,24 @@ export const api = { afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, + async uploadAudiobook(file, payload = {}) { + const formData = new FormData(); + if (file) { + formData.append('file', file); + } + if (payload?.format) { + formData.append('format', String(payload.format)); + } + if (payload?.startImmediately !== undefined) { + formData.append('startImmediately', String(payload.startImmediately)); + } + const result = await request('/pipeline/audiobook/upload', { + method: 'POST', + body: formData + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, async selectMetadata(payload) { const result = await request('/pipeline/select-metadata', { method: 'POST', diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index 2caa0c0..a110a58 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -20,6 +20,8 @@ const GENERAL_TOOL_KEYS = new Set([ 'makemkv_min_length_minutes', 'mediainfo_command', 'handbrake_command', + 'ffmpeg_command', + 'ffprobe_command', 'handbrake_restart_delete_incomplete_output', 'script_test_timeout_ms' ]); @@ -122,6 +124,12 @@ function buildToolSections(settings) { description: 'Profil-spezifische Settings für DVD.', settings: [] }; + const audiobookBucket = { + id: 'audiobook', + title: 'Audiobook', + description: 'Profil-spezifische Settings für Audiobooks.', + settings: [] + }; const fallbackBucket = { id: 'other', title: 'Weitere Tool-Settings', @@ -143,13 +151,18 @@ function buildToolSections(settings) { dvdBucket.settings.push(setting); continue; } + if (key.endsWith('_audiobook')) { + audiobookBucket.settings.push(setting); + continue; + } fallbackBucket.settings.push(setting); } const sections = [ generalBucket, blurayBucket, - dvdBucket + dvdBucket, + audiobookBucket ].filter((item) => item.settings.length > 0); if (fallbackBucket.settings.length > 0) { sections.push(fallbackBucket); @@ -161,6 +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 LOG_PATH_KEYS = ['log_dir']; function buildSectionsForCategory(categoryName, settings) { @@ -369,11 +383,14 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect const bluraySettings = list.filter((s) => BLURAY_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && BLURAY_PATH_KEYS.includes(s.key.replace('_owner', '')))); const dvdSettings = list.filter((s) => DVD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DVD_PATH_KEYS.includes(s.key.replace('_owner', '')))); const cdSettings = list.filter((s) => CD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && CD_PATH_KEYS.includes(s.key.replace('_owner', '')))); + const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', '')))); const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key)); const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw'; const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies'; const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd'; + const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw'; + const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks'; const ep = effectivePaths || {}; const blurayRaw = ep.bluray?.raw || defaultRaw; @@ -382,6 +399,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect const dvdMovies = ep.dvd?.movies || defaultMovies; const cdRaw = ep.cd?.raw || defaultCd; const cdMovies = ep.cd?.movies || cdRaw; + const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw; + const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies; const isDefault = (path, def) => path === def; @@ -435,6 +454,17 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect {isDefault(cdMovies, cdRaw) && Standard} + + Audiobook + + {audiobookRaw} + {isDefault(audiobookRaw, defaultAudiobookRaw) && Standard} + + + {audiobookMovies} + {isDefault(audiobookMovies, defaultAudiobookMovies) && Standard} + + @@ -468,6 +498,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect dirtyKeys={dirtyKeys} onChange={onChange} /> + {/* Log-Ordner */} diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 1cfc7bd..1193578 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -231,6 +231,9 @@ function resolveMediaType(job) { if (['cd', 'audio_cd', 'audio cd'].includes(raw)) { return 'cd'; } + if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) { + return 'audiobook'; + } } const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState]; if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) { @@ -247,6 +250,12 @@ 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') { + return 'audiobook'; + } + if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') { + return 'audiobook'; + } return 'other'; } @@ -308,6 +317,26 @@ 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 chapters = Array.isArray(selectedMetadata?.chapters) + ? selectedMetadata.chapters + : (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []); + const format = String(job?.handbrakeInfo?.format || encodePlan?.format || '').trim().toLowerCase() || null; + return { + author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null, + narrator: String(selectedMetadata?.narrator || '').trim() || null, + series: String(selectedMetadata?.series || '').trim() || null, + part: String(selectedMetadata?.part || '').trim() || null, + chapterCount: chapters.length, + formatLabel: format ? format.toUpperCase() : null + }; +} + function statusBadgeMeta(status, queued = false) { const normalized = String(status || '').trim().toUpperCase(); const label = getStatusLabel(normalized, { queued }); @@ -404,6 +433,9 @@ export default function JobDetailDialog({ && !running && typeof onResumeReady === 'function' ); + const mediaType = resolveMediaType(job); + const isCd = mediaType === 'cd'; + const isAudiobook = mediaType === 'audiobook'; const hasConfirmedPlan = Boolean( job?.encodePlan && Array.isArray(job?.encodePlan?.titles) @@ -416,6 +448,7 @@ export default function JobDetailDialog({ job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && !running + && mediaType !== 'audiobook' && typeof onRestartReview === 'function' ); const canDeleteEntry = !running && typeof onDeleteEntry === 'function'; @@ -424,9 +457,8 @@ export default function JobDetailDialog({ const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null; const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log); const logTruncated = Boolean(logMeta?.truncated); - const mediaType = resolveMediaType(job); - const isCd = mediaType === 'cd'; const cdDetails = isCd ? resolveCdDetails(job) : null; + const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null; const canRetry = isCd && !running && typeof onRetry === 'function'; const mediaTypeLabel = mediaType === 'bluray' ? 'Blu-ray' @@ -434,7 +466,7 @@ export default function JobDetailDialog({ ? 'DVD' : isCd ? 'Audio CD' - : 'Sonstiges Medium'; + : (isAudiobook ? 'Audiobook' : 'Sonstiges Medium'); const mediaTypeIcon = mediaType === 'bluray' ? blurayIndicatorIcon : mediaType === 'dvd' @@ -535,7 +567,7 @@ export default function JobDetailDialog({ ) : ( <>
-

Film-Infos

+

{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}

Titel: @@ -545,14 +577,45 @@ export default function JobDetailDialog({ Jahr: {job.year || '-'}
-
- IMDb: - {job.imdb_id || '-'} -
-
- OMDb Match: - -
+ {isAudiobook ? ( + <> +
+ Autor: + {audiobookDetails?.author || '-'} +
+
+ Sprecher: + {audiobookDetails?.narrator || '-'} +
+
+ Serie: + {audiobookDetails?.series || '-'} +
+
+ Teil: + {audiobookDetails?.part || '-'} +
+
+ Kapitel: + {audiobookDetails?.chapterCount || '-'} +
+
+ Format: + {audiobookDetails?.formatLabel || '-'} +
+ + ) : ( + <> +
+ IMDb: + {job.imdb_id || '-'} +
+
+ OMDb Match: + +
+ + )}
Medium: @@ -563,35 +626,37 @@ export default function JobDetailDialog({
-
-

OMDb Details

-
-
- Regisseur: - {omdbField(omdbInfo?.Director)} + {!isAudiobook ? ( +
+

OMDb Details

+
+
+ Regisseur: + {omdbField(omdbInfo?.Director)} +
+
+ Schauspieler: + {omdbField(omdbInfo?.Actors)} +
+
+ Laufzeit: + {omdbField(omdbInfo?.Runtime)} +
+
+ Genre: + {omdbField(omdbInfo?.Genre)} +
+
+ Rotten Tomatoes: + {omdbRottenTomatoesScore(omdbInfo)} +
+
+ imdbRating: + {omdbField(omdbInfo?.imdbRating)} +
-
- Schauspieler: - {omdbField(omdbInfo?.Actors)} -
-
- Laufzeit: - {omdbField(omdbInfo?.Runtime)} -
-
- Genre: - {omdbField(omdbInfo?.Genre)} -
-
- Rotten Tomatoes: - {omdbRottenTomatoesScore(omdbInfo)} -
-
- imdbRating: - {omdbField(omdbInfo?.imdbRating)} -
-
-
+ + ) : null} )} @@ -631,7 +696,7 @@ export default function JobDetailDialog({ RAW vorhanden:
- {isCd ? 'Audio-Dateien vorhanden:' : 'Movie Datei vorhanden:'} + {isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? 'Audiobook-Datei vorhanden:' : 'Movie Datei vorhanden:')}
{isCd ? (
@@ -640,7 +705,7 @@ export default function JobDetailDialog({ ) : ( <>
- Backup erfolgreich: + {isAudiobook ? 'Import erfolgreich:' : 'Backup erfolgreich:'}
Encode erfolgreich: @@ -653,7 +718,7 @@ export default function JobDetailDialog({
- {!isCd && (hasConfiguredSelection || encodePlanUserPreset) ? ( + {!isCd && !isAudiobook && (hasConfiguredSelection || encodePlanUserPreset) ? (

Hinterlegte Encode-Auswahl

@@ -683,13 +748,13 @@ export default function JobDetailDialog({

Ausgeführter Encode-Befehl

- HandBrakeCLI (tatsächlich gestartet): + {isAudiobook ? 'FFmpeg' : 'HandBrakeCLI'} (tatsächlich gestartet):
{executedHandBrakeCommand}
) : null} - {!isCd && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? ( + {!isCd && !isAudiobook && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (

Skripte

@@ -700,14 +765,14 @@ export default function JobDetailDialog({ ) : null}
- {!isCd ? : null} - - {!isCd ? : null} + {!isCd && !isAudiobook ? : null} + + {!isCd && !isAudiobook ? : null} - {!isCd ? : null} + {!isCd ? : null}
- {!isCd && job.encodePlan ? ( + {!isCd && !isAudiobook && job.encodePlan ? ( <>

Mediainfo-Prüfung (Auswertung)

) : ( <> - {!isCd ? ( + {!isCd && !isAudiobook ? (
+ + {audiobookUploadFile + ? `Ausgewählt: ${audiobookUploadFile.name}` + : 'Unterstützt im MVP: AAX-Upload. Das Ausgabeformat wird aus den Audiobook-Settings gelesen.'} + + +
diff --git a/frontend/src/pages/DatabasePage.jsx b/frontend/src/pages/DatabasePage.jsx index 9614384..c9f656d 100644 --- a/frontend/src/pages/DatabasePage.jsx +++ b/frontend/src/pages/DatabasePage.jsx @@ -46,6 +46,9 @@ function resolveMediaType(row) { if (['cd', 'audio_cd'].includes(raw)) { return 'cd'; } + if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) { + return 'audiobook'; + } } return 'other'; } @@ -698,13 +701,13 @@ export default function DatabasePage() { : (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon); const alt = mediaType === 'bluray' ? 'Blu-ray' - : (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium'); + : (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium')); const title = mediaType === 'bluray' ? 'Blu-ray' - : (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium'); + : (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium')); const label = mediaType === 'bluray' ? 'Blu-ray' - : (mediaType === 'dvd' ? 'DVD' : 'Sonstiges'); + : (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges')); return ( {alt} @@ -781,7 +784,7 @@ export default function DatabasePage() {