0.10.0-5 Update

This commit is contained in:
2026-03-14 19:49:08 +00:00
29 changed files with 2554 additions and 153 deletions

2
.gitignore vendored
View File

@@ -80,5 +80,5 @@ Thumbs.db
# Scripts
# ----------------------------
/scripts/
/setup.sh
/release.sh
/Audible_Tool

View File

@@ -1,16 +1,17 @@
{
"name": "ripster-backend",
"version": "0.9.1-6",
"version": "0.10.0-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.9.1-6",
"version": "0.10.0-5",
"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",

View File

@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.9.1-6",
"version": "0.10.0-5",
"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"

View File

@@ -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')
};

View File

@@ -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() {

View File

@@ -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,48 @@ 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),
mimeType: String(req.file.mimetype || '').trim() || null,
tempPath: String(req.file.path || '').trim() || null
});
const result = await pipelineService.uploadAudiobookFile(req.file, {
format: req.body?.format,
startImmediately: req.body?.startImmediately
});
res.json({ result });
})
);
router.post(
'/audiobook/start/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const config = req.body || {};
logger.info('post:audiobook:start', {
reqId: req.reqId,
jobId,
format: config?.format,
formatOptions: config?.formatOptions && typeof config.formatOptions === 'object'
? config.formatOptions
: null
});
const result = await pipelineService.startAudiobookWithConfig(jobId, config);
res.json({ result });
})
);
router.post(
'/select-metadata',
asyncHandler(async (req, res) => {

View File

@@ -0,0 +1,383 @@
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})';
const AUDIOBOOK_FORMAT_DEFAULTS = {
m4b: {},
flac: {
flacCompression: 5
},
mp3: {
mp3Mode: 'cbr',
mp3Bitrate: 192,
mp3Quality: 4
}
};
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 clonePlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
}
function clampInteger(value, min, max, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.trunc(parsed)));
}
function getDefaultFormatOptions(format) {
const normalizedFormat = normalizeOutputFormat(format);
return clonePlainObject(AUDIOBOOK_FORMAT_DEFAULTS[normalizedFormat]);
}
function normalizeFormatOptions(format, formatOptions = {}) {
const normalizedFormat = normalizeOutputFormat(format);
const source = clonePlainObject(formatOptions);
const defaults = getDefaultFormatOptions(normalizedFormat);
if (normalizedFormat === 'flac') {
return {
flacCompression: clampInteger(source.flacCompression, 0, 8, defaults.flacCompression)
};
}
if (normalizedFormat === 'mp3') {
const mp3Mode = String(source.mp3Mode || defaults.mp3Mode || 'cbr').trim().toLowerCase() === 'vbr'
? 'vbr'
: 'cbr';
const allowedBitrates = new Set([128, 160, 192, 256, 320]);
const normalizedBitrate = clampInteger(source.mp3Bitrate, 96, 320, defaults.mp3Bitrate);
return {
mp3Mode,
mp3Bitrate: allowedBitrates.has(normalizedBitrate) ? normalizedBitrate : defaults.mp3Bitrate,
mp3Quality: clampInteger(source.mp3Quality, 0, 9, defaults.mp3Quality)
};
}
return {};
}
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', formatOptions = {}) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const format = normalizeOutputFormat(outputFormat);
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
const commonArgs = [
'-y',
'-i', inputPath,
'-map', '0:a:0?',
'-map_metadata', '0',
'-map_chapters', '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)];
}
return {
cmd,
args: [...commonArgs, ...codecArgs, outputPath],
formatOptions: normalizedOptions
};
}
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,
AUDIOBOOK_FORMAT_DEFAULTS,
normalizeOutputFormat,
getDefaultFormatOptions,
normalizeFormatOptions,
isSupportedInputFile,
buildMetadataFromProbe,
buildRawStoragePaths,
buildOutputPath,
buildProbeCommand,
parseProbeOutput,
buildEncodeCommand,
buildProgressParser
};

View File

@@ -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';
@@ -1026,7 +1065,7 @@ class HistoryService {
};
}
appendLog(jobId, source, message) {
async appendLog(jobId, source, message) {
this.appendProcessLog(jobId, source, message);
}
@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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);

View File

@@ -180,6 +180,7 @@ Beispiel `/etc/nginx/sites-available/ripster`:
server {
listen 80;
server_name ripster.local;
client_max_body_size 8G;
root /opt/ripster/frontend/dist;
index index.html;
@@ -212,6 +213,8 @@ sudo nginx -t
sudo systemctl reload nginx
```
Hinweis: Fuer groessere Uploads wie `.aax`-Audiobooks muss `client_max_body_size` ausreichend hoch gesetzt sein. Im mitgelieferten Beispiel sind `8G` hinterlegt.
---
## Datenbank-Backup

View File

@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.9.1-6",
"version": "0.10.0-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.9.1-6",
"version": "0.10.0-5",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",

View File

@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.9.1-6",
"version": "0.10.0-5",
"private": true,
"type": "module",
"scripts": {

View File

@@ -58,12 +58,12 @@ async function requestCachedGet(path, options = {}) {
if (!forceRefresh && current && current.value !== undefined) {
if (current.expiresAt > now) {
return current.value;
return Promise.resolve(current.value);
}
if (!current.promise) {
void refreshCachedGet(path, ttlMs);
}
return current.value;
return Promise.resolve(current.value);
}
if (!forceRefresh && current?.promise) {
@@ -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,32 @@ 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 startAudiobook(jobId, payload = {}) {
const result = await request(`/pipeline/audiobook/start/${jobId}`, {
method: 'POST',
body: JSON.stringify(payload || {})
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', {
method: 'POST',

View File

@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from 'react';
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 { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeFormat(value) {
const raw = String(value || '').trim().toLowerCase();
return AUDIOBOOK_FORMATS.some((entry) => entry.value === raw) ? raw : 'mp3';
}
function isFieldVisible(field, values) {
if (!field?.showWhen) {
return true;
}
return values?.[field.showWhen.field] === field.showWhen.value;
}
function buildFormatOptions(format, existingOptions = {}) {
return {
...getDefaultAudiobookFormatOptions(format),
...(existingOptions && typeof existingOptions === 'object' ? existingOptions : {})
};
}
function formatChapterTime(secondsValue) {
const totalSeconds = Number(secondsValue || 0);
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) {
return '-';
}
const rounded = Math.max(0, Math.round(totalSeconds));
const hours = Math.floor(rounded / 3600);
const minutes = Math.floor((rounded % 3600) / 60);
const seconds = rounded % 60;
if (hours > 0) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function FormatField({ field, value, onChange, disabled }) {
if (field.type === 'slider') {
return (
<div className="cd-format-field">
<label>
{field.label}: <strong>{value}</strong>
</label>
{field.description ? <small>{field.description}</small> : null}
<Slider
value={value}
onChange={(event) => onChange(field.key, event.value)}
min={field.min}
max={field.max}
step={field.step || 1}
disabled={disabled}
/>
</div>
);
}
if (field.type === 'select') {
return (
<div className="cd-format-field">
<label>{field.label}</label>
{field.description ? <small>{field.description}</small> : null}
<Dropdown
value={value}
options={field.options}
optionLabel="label"
optionValue="value"
onChange={(event) => onChange(field.key, event.value)}
disabled={disabled}
/>
</div>
);
}
return null;
}
export default function AudiobookConfigPanel({
pipeline,
onStart,
onCancel,
onRetry,
busy
}) {
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase() || 'IDLE';
const jobId = normalizeJobId(context?.jobId);
const metadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
? context.selectedMetadata
: {};
const audiobookConfig = context?.audiobookConfig && typeof context.audiobookConfig === 'object'
? context.audiobookConfig
: (context?.mediaInfoReview && typeof context.mediaInfoReview === 'object' ? context.mediaInfoReview : {});
const initialFormat = normalizeFormat(audiobookConfig?.format);
const chapters = Array.isArray(metadata?.chapters)
? metadata.chapters
: (Array.isArray(context?.chapters) ? context.chapters : []);
const [format, setFormat] = useState(initialFormat);
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
useEffect(() => {
const nextFormat = normalizeFormat(audiobookConfig?.format);
setFormat(nextFormat);
setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions));
}, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]);
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';
const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
const outputPath = String(context?.outputPath || '').trim() || null;
const statusLabel = getStatusLabel(state);
const statusSeverity = getStatusSeverity(state);
const visibleFields = useMemo(
() => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []),
[schema, formatOptions]
);
return (
<div className="audiobook-config-panel">
<div className="audiobook-config-head">
<div className="device-meta">
<div><strong>Titel:</strong> {metadata?.title || '-'}</div>
<div><strong>Autor:</strong> {metadata?.author || '-'}</div>
<div><strong>Sprecher:</strong> {metadata?.narrator || '-'}</div>
<div><strong>Serie:</strong> {metadata?.series || '-'}</div>
<div><strong>Teil:</strong> {metadata?.part || '-'}</div>
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
<div><strong>Kapitel:</strong> {chapters.length || '-'}</div>
</div>
<div className="audiobook-config-tags">
<Tag value={statusLabel} severity={statusSeverity} />
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
</div>
</div>
<div className="audiobook-config-grid">
<div className="audiobook-config-settings">
<div className="cd-format-field">
<label>Ausgabeformat</label>
<Dropdown
value={format}
options={AUDIOBOOK_FORMATS}
optionLabel="label"
optionValue="value"
onChange={(event) => {
const nextFormat = normalizeFormat(event.value);
setFormat(nextFormat);
setFormatOptions(buildFormatOptions(nextFormat, {}));
}}
disabled={busy || isRunning}
/>
</div>
{visibleFields.map((field) => (
<FormatField
key={`${format}-${field.key}`}
field={field}
value={formatOptions?.[field.key] ?? field.default ?? null}
onChange={(key, nextValue) => {
setFormatOptions((prev) => ({
...prev,
[key]: nextValue
}));
}}
disabled={busy || isRunning}
/>
))}
<small>
Metadaten und Kapitel werden aus der AAX-Datei gelesen. Erst nach Klick auf Start wird `ffmpeg` ausgeführt.
</small>
</div>
<div className="audiobook-config-chapters">
<h4>Kapitelvorschau</h4>
{chapters.length === 0 ? (
<small>Keine Kapitel in der Quelle erkannt.</small>
) : (
<div className="audiobook-chapter-list">
{chapters.map((chapter, index) => (
<div key={`${chapter?.index || index}-${chapter?.title || ''}`} className="audiobook-chapter-row">
<strong>#{chapter?.index || index + 1}</strong>
<span>{chapter?.title || `Kapitel ${index + 1}`}</span>
<small>
{formatChapterTime(chapter?.startSeconds)} - {formatChapterTime(chapter?.endSeconds)}
</small>
</div>
))}
</div>
)}
</div>
</div>
{isRunning ? (
<div className="dashboard-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.round(progress)}%`}>
<ProgressBar value={progress} showValue={false} />
<small>{Math.round(progress)}%</small>
</div>
) : null}
{outputPath ? (
<div className="audiobook-output-path">
<strong>Ausgabe:</strong> <code>{outputPath}</code>
</div>
) : null}
<div className="actions-row">
{canStart ? (
<Button
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart?.({ format, formatOptions })}
loading={busy}
disabled={!jobId}
/>
) : null}
{isRunning ? (
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
onClick={() => onCancel?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
{(state === 'ERROR' || state === 'CANCELLED') ? (
<Button
label="Retry-Job anlegen"
icon="pi pi-refresh"
severity="warning"
outlined
onClick={() => onRetry?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
</div>
</div>
);
}

View File

@@ -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) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
<tr>
<td><strong>Audiobook</strong></td>
<td>
<code>{audiobookRaw}</code>
{isDefault(audiobookRaw, defaultAudiobookRaw) && <span className="path-default-badge">Standard</span>}
</td>
<td>
<code>{audiobookMovies}</code>
{isDefault(audiobookMovies, defaultAudiobookMovies) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
</tbody>
</table>
</div>
@@ -468,6 +498,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
dirtyKeys={dirtyKeys}
onChange={onChange}
/>
<PathMediumCard
title="Audiobook"
pathSettings={audiobookSettings}
settingsByKey={settingsByKey}
values={values}
errors={errors}
dirtyKeys={dirtyKeys}
onChange={onChange}
/>
</div>
{/* Log-Ordner */}

View File

@@ -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,39 @@ 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;
const formatOptions = job?.handbrakeInfo?.formatOptions && typeof job.handbrakeInfo.formatOptions === 'object'
? job.handbrakeInfo.formatOptions
: (encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object' ? encodePlan.formatOptions : {});
const qualityLabel = format === 'mp3'
? (
String(formatOptions?.mp3Mode || '').trim().toLowerCase() === 'vbr'
? `VBR V${Number(formatOptions?.mp3Quality ?? 4)}`
: `CBR ${Number(formatOptions?.mp3Bitrate ?? 192)} kbps`
)
: (format === 'flac'
? `Kompression ${Number(formatOptions?.flacCompression ?? 5)}`
: (format === 'm4b' ? 'Original-Audio' : 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,
qualityLabel
};
}
function statusBadgeMeta(status, queued = false) {
const normalized = String(status || '').trim().toUpperCase();
const label = getStatusLabel(normalized, { queued });
@@ -404,6 +446,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 +461,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 +470,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 +479,7 @@ export default function JobDetailDialog({
? 'DVD'
: isCd
? 'Audio CD'
: 'Sonstiges Medium';
: (isAudiobook ? 'Audiobook' : 'Sonstiges Medium');
const mediaTypeIcon = mediaType === 'bluray'
? blurayIndicatorIcon
: mediaType === 'dvd'
@@ -481,7 +526,7 @@ export default function JobDetailDialog({
{job.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
) : (
<div className="poster-large poster-fallback">{isCd ? 'Kein Cover' : 'Kein Poster'}</div>
<div className="poster-large poster-fallback">{isCd || isAudiobook ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="job-film-info-grid">
@@ -535,7 +580,7 @@ export default function JobDetailDialog({
) : (
<>
<section className="job-meta-block job-meta-block-film">
<h4>Film-Infos</h4>
<h4>{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
@@ -545,14 +590,49 @@ export default function JobDetailDialog({
<strong>Jahr:</strong>
<span>{job.year || '-'}</span>
</div>
<div className="job-meta-item">
<strong>IMDb:</strong>
<span>{job.imdb_id || '-'}</span>
</div>
<div className="job-meta-item">
<strong>OMDb Match:</strong>
<BoolState value={job.selected_from_omdb} />
</div>
{isAudiobook ? (
<>
<div className="job-meta-item">
<strong>Autor:</strong>
<span>{audiobookDetails?.author || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Sprecher:</strong>
<span>{audiobookDetails?.narrator || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Serie:</strong>
<span>{audiobookDetails?.series || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Teil:</strong>
<span>{audiobookDetails?.part || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Kapitel:</strong>
<span>{audiobookDetails?.chapterCount || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Format:</strong>
<span>{audiobookDetails?.formatLabel || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Qualität:</strong>
<span>{audiobookDetails?.qualityLabel || '-'}</span>
</div>
</>
) : (
<>
<div className="job-meta-item">
<strong>IMDb:</strong>
<span>{job.imdb_id || '-'}</span>
</div>
<div className="job-meta-item">
<strong>OMDb Match:</strong>
<BoolState value={job.selected_from_omdb} />
</div>
</>
)}
<div className="job-meta-item">
<strong>Medium:</strong>
<span className="job-step-cell">
@@ -563,35 +643,37 @@ export default function JobDetailDialog({
</div>
</section>
<section className="job-meta-block job-meta-block-film">
<h4>OMDb Details</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(omdbInfo?.Director)}</span>
{!isAudiobook ? (
<section className="job-meta-block job-meta-block-film">
<h4>OMDb Details</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(omdbInfo?.Director)}</span>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(omdbInfo?.Actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(omdbInfo?.Runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(omdbInfo?.Genre)}</span>
</div>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(omdbInfo?.imdbRating)}</span>
</div>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(omdbInfo?.Actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(omdbInfo?.Runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(omdbInfo?.Genre)}</span>
</div>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(omdbInfo?.imdbRating)}</span>
</div>
</div>
</section>
</section>
) : null}
</>
)}
</div>
@@ -631,7 +713,7 @@ export default function JobDetailDialog({
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
</div>
<div>
<strong>{isCd ? 'Audio-Dateien vorhanden:' : 'Movie Datei vorhanden:'}</strong> <BoolState value={job.outputStatus?.exists} />
<strong>{isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? 'Audiobook-Datei vorhanden:' : 'Movie Datei vorhanden:')}</strong> <BoolState value={job.outputStatus?.exists} />
</div>
{isCd ? (
<div>
@@ -640,7 +722,7 @@ export default function JobDetailDialog({
) : (
<>
<div>
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
<strong>{isAudiobook ? 'Import erfolgreich:' : 'Backup erfolgreich:'}</strong> <BoolState value={job?.backupSuccess} />
</div>
<div>
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
@@ -653,7 +735,7 @@ export default function JobDetailDialog({
</div>
</section>
{!isCd && (hasConfiguredSelection || encodePlanUserPreset) ? (
{!isCd && !isAudiobook && (hasConfiguredSelection || encodePlanUserPreset) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Hinterlegte Encode-Auswahl</h4>
<div className="job-configured-selection-grid">
@@ -683,13 +765,13 @@ export default function JobDetailDialog({
<section className="job-meta-block job-meta-block-full">
<h4>Ausgeführter Encode-Befehl</h4>
<div className="handbrake-command-preview">
<small><strong>HandBrakeCLI (tatsächlich gestartet):</strong></small>
<small><strong>{isAudiobook ? 'FFmpeg' : 'HandBrakeCLI'} (tatsächlich gestartet):</strong></small>
<pre>{executedHandBrakeCommand}</pre>
</div>
</section>
) : null}
{!isCd && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
{!isCd && !isAudiobook && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Skripte</h4>
<div className="script-results-grid">
@@ -700,14 +782,14 @@ export default function JobDetailDialog({
) : null}
<div className="job-json-grid">
{!isCd ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : 'MakeMKV Info'} value={job.makemkvInfo} />
{!isCd ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
{!isCd && !isAudiobook ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : 'MakeMKV Info')} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
{!isCd ? <JsonView title="HandBrake Info" value={job.handbrakeInfo} /> : null}
{!isCd ? <JsonView title={isAudiobook ? 'FFmpeg Info' : 'HandBrake Info'} value={job.handbrakeInfo} /> : null}
</div>
{!isCd && job.encodePlan ? (
{!isCd && !isAudiobook && job.encodePlan ? (
<>
<h4>Mediainfo-Prüfung (Auswertung)</h4>
<MediaInfoReviewPanel
@@ -740,7 +822,7 @@ export default function JobDetailDialog({
/>
) : (
<>
{!isCd ? (
{!isCd && !isAudiobook ? (
<Button
label="OMDb neu zuordnen"
icon="pi pi-search"

View File

@@ -109,6 +109,9 @@ function normalizeMediaProfile(value) {
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
return 'other';
}
@@ -234,8 +237,8 @@ function sanitizeFileName(input) {
}
function renderTemplate(template, values) {
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
const value = values[key.trim()];
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
const value = values[(keyA || keyB || '').trim()];
if (value === undefined || value === null || value === '') {
return 'unknown';
}
@@ -248,7 +251,11 @@ function resolveProfiledSetting(settings, key, mediaProfile) {
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
return settings[profileKey];
}
const fallbackProfiles = mediaProfile === 'bluray' ? ['dvd'] : ['bluray'];
const fallbackProfiles = mediaProfile === 'bluray'
? ['dvd']
: mediaProfile === 'dvd'
? ['bluray']
: [];
for (const fb of fallbackProfiles) {
const fbKey = `${key}_${fb}`;
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
@@ -265,12 +272,14 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
}
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
const author = metadata?.author || metadata?.artist || 'unknown';
const narrator = metadata?.narrator || 'unknown';
const year = metadata?.year || new Date().getFullYear();
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})';
const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile);
const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE;
const rendered = renderTemplate(template, { title, year, imdbId });
const rendered = renderTemplate(template, { title, year, imdbId, author, narrator });
const segments = rendered
.replace(/\\/g, '/')
.replace(/\/+/g, '/')

View File

@@ -0,0 +1,80 @@
export const AUDIOBOOK_FORMATS = [
{ label: 'M4B (Original-Audio)', value: 'm4b' },
{ label: 'MP3', value: 'mp3' },
{ label: 'FLAC (verlustlos)', value: 'flac' }
];
export const AUDIOBOOK_FORMAT_SCHEMAS = {
m4b: {
fields: []
},
flac: {
fields: [
{
key: 'flacCompression',
label: 'Kompressionsstufe',
description: '0 = schnell / wenig Kompression, 8 = maximale Kompression',
type: 'slider',
min: 0,
max: 8,
step: 1,
default: 5
}
]
},
mp3: {
fields: [
{
key: 'mp3Mode',
label: 'Modus',
type: 'select',
options: [
{ label: 'CBR (Konstante Bitrate)', value: 'cbr' },
{ label: 'VBR (Variable Bitrate)', value: 'vbr' }
],
default: 'cbr'
},
{
key: 'mp3Bitrate',
label: 'Bitrate (kbps)',
type: 'select',
showWhen: { field: 'mp3Mode', value: 'cbr' },
options: [
{ label: '128 kbps', value: 128 },
{ label: '160 kbps', value: 160 },
{ label: '192 kbps', value: 192 },
{ label: '256 kbps', value: 256 },
{ label: '320 kbps', value: 320 }
],
default: 192
},
{
key: 'mp3Quality',
label: 'VBR Qualität (V0-V9)',
description: '0 = beste Qualität, 9 = kleinste Datei',
type: 'slider',
min: 0,
max: 9,
step: 1,
showWhen: { field: 'mp3Mode', value: 'vbr' },
default: 4
}
]
}
};
export function getDefaultAudiobookFormatOptions(format) {
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format];
if (!schema) {
return {};
}
const defaults = {};
for (const field of schema.fields) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
return defaults;
}

View File

@@ -11,6 +11,7 @@ import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
import CdRipConfigPanel from '../components/CdRipConfigPanel';
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
@@ -377,6 +378,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,
@@ -397,6 +401,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';
}
@@ -411,6 +421,9 @@ function mediaIndicatorMeta(job) {
if (mediaType === 'cd') {
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
}
if (mediaType === 'audiobook') {
return { mediaType, src: otherIndicatorIcon, alt: 'Audiobook', title: 'Audiobook' };
}
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
}
@@ -448,6 +461,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
const resolvedMediaType = resolveMediaType(job);
const analyzeContext = getAnalyzeContext(job);
const normalizePlanIdList = (values) => {
const list = Array.isArray(values) ? values : [];
@@ -575,15 +589,30 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
: '<temp>/trackNN.cdda.wav';
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
const selectedMetadata = {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
year: cdSelectedMeta?.year ?? job?.year ?? null,
mbId: resolvedCdMbId,
coverUrl: resolvedCdCoverUrl,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || resolvedCdCoverUrl || null
};
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,
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
}
: {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
year: cdSelectedMeta?.year ?? job?.year ?? null,
mbId: resolvedCdMbId,
coverUrl: resolvedCdCoverUrl,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || resolvedCdCoverUrl || null
};
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
const inputPath = isPreRip
@@ -623,13 +652,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const canRestartReviewFromRaw = Boolean(
job?.raw_path
&& !processingStates.includes(jobStatus)
&& resolvedMediaType !== 'audiobook'
);
const computedContext = {
jobId,
rawPath: job?.raw_path || null,
outputPath: job?.output_path || null,
detectedTitle: job?.detected_title || null,
mediaProfile: resolveMediaType(job),
mediaProfile: resolvedMediaType,
lastState,
devicePath,
cdparanoiaCmd,
@@ -642,6 +672,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
mode,
sourceJobId: encodePlan?.sourceJobId || null,
selectedMetadata,
audiobookConfig: resolvedMediaType === 'audiobook'
? {
format: String(encodePlan?.format || '').trim().toLowerCase() || 'mp3',
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions
: {}
}
: null,
mediaInfoReview: encodePlan,
playlistAnalysis: analyzeContext.playlistAnalysis || null,
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
@@ -751,6 +789,8 @@ export default function DashboardPage({
const [jobsLoading, setJobsLoading] = useState(false);
const [dashboardJobs, setDashboardJobs] = useState([]);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
const [audiobookUploadBusy, setAudiobookUploadBusy] = useState(false);
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
@@ -1245,6 +1285,8 @@ export default function DashboardPage({
}
const startOptions = options && typeof options === 'object' ? options : {};
const startJobRow = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const mediaType = resolveMediaType(startJobRow);
setJobBusy(normalizedJobId, true);
try {
if (startOptions.ensureConfirmed) {
@@ -1270,7 +1312,9 @@ export default function DashboardPage({
}
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
}
const response = await api.startJob(normalizedJobId);
const response = mediaType === 'audiobook'
? await api.startJob(normalizedJobId)
: await api.startJob(normalizedJobId);
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
@@ -1286,6 +1330,62 @@ export default function DashboardPage({
}
};
const handleAudiobookUpload = async () => {
if (!audiobookUploadFile) {
showError(new Error('Bitte zuerst eine AAX-Datei auswählen.'));
return;
}
setAudiobookUploadBusy(true);
try {
const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: false });
const result = getQueueActionResult(response);
const uploadedJobId = normalizeJobId(response?.result?.jobId);
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Audiobook', result);
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Audiobook importiert',
detail: uploadedJobId ? `Job #${uploadedJobId} wurde angelegt.` : 'Audiobook wurde importiert.',
life: 3200
});
}
if (uploadedJobId) {
setExpandedJobId(uploadedJobId);
}
setAudiobookUploadFile(null);
} catch (error) {
showError(error);
} finally {
setAudiobookUploadBusy(false);
}
};
const handleAudiobookStart = async (jobId, audiobookConfig) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setJobBusy(normalizedJobId, true);
try {
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Audiobook', result);
} else {
setExpandedJobId(normalizedJobId);
}
} catch (error) {
showError(error);
} finally {
setJobBusy(normalizedJobId, false);
}
};
const handleConfirmReview = async (
jobId,
selectedEncodeTitleId = null,
@@ -1944,6 +2044,35 @@ export default function DashboardPage({
)}
</Card>
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, analysieren und danach Format/Qualität vor dem Start auswählen.">
<div className="actions-row">
<input
key={audiobookUploadFile ? `${audiobookUploadFile.name}-${audiobookUploadFile.size}` : 'audiobook-upload-input'}
type="file"
accept=".aax"
onChange={(event) => {
const nextFile = event.target?.files?.[0] || null;
setAudiobookUploadFile(nextFile);
}}
disabled={audiobookUploadBusy}
/>
<Button
label="Audiobook hochladen"
icon="pi pi-upload"
onClick={() => {
void handleAudiobookUpload();
}}
loading={audiobookUploadBusy}
disabled={!audiobookUploadFile}
/>
</div>
<small>
{audiobookUploadFile
? `Ausgewählt: ${audiobookUploadFile.name}`
: 'Unterstützt im MVP: AAX-Upload. Danach erscheint ein eigener Audiobook-Startschritt mit Format- und Qualitätswahl.'}
</small>
</Card>
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
<div className="pipeline-queue-meta">
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
@@ -2281,11 +2410,14 @@ export default function DashboardPage({
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
const isExpanded = normalizeJobId(expandedJobId) === jobId;
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
const isResumable = normalizedStatus === 'READY_TO_ENCODE' && !isCurrentSession;
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
const mediaIndicator = mediaIndicatorMeta(job);
const isResumable = (
normalizedStatus === 'READY_TO_ENCODE'
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
) && !isCurrentSession;
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
@@ -2295,6 +2427,9 @@ export default function DashboardPage({
|| mediaProfile === 'cd'
|| mediaIndicator.mediaType === 'cd'
|| pipelineStatusText.includes('CD_');
const isAudiobookJob = mediaProfile === 'audiobook'
|| mediaIndicator.mediaType === 'audiobook'
|| String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === 'audiobook';
const rawProgress = Number(pipelineForJob?.progress ?? 0);
const clampedProgress = Number.isFinite(rawProgress)
? Math.max(0, Math.min(100, rawProgress))
@@ -2302,14 +2437,19 @@ export default function DashboardPage({
const progressLabel = `${Math.round(clampedProgress)}%`;
const etaLabel = String(pipelineForJob?.eta || '').trim();
const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
? pipelineForJob.context.selectedMetadata
: {};
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
if (isExpanded) {
return (
<div key={jobId} className="dashboard-job-expanded">
<div className="dashboard-job-expanded-head">
{job?.poster_url && job.poster_url !== 'N/A' ? (
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
) : (
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="dashboard-job-expanded-title">
<strong className="dashboard-job-title-line">
@@ -2363,9 +2503,20 @@ export default function DashboardPage({
</>
);
}
if (isAudiobookJob) {
return (
<AudiobookConfigPanel
pipeline={pipelineForJob}
onStart={(config) => handleAudiobookStart(jobId, config)}
onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)}
busy={busyJobIds.has(jobId)}
/>
);
}
return null;
})()}
{!isCdJob ? (
{!isCdJob && !isAudiobookJob ? (
<PipelineStatusCard
pipeline={pipelineForJob}
onAnalyze={handleAnalyze}
@@ -2399,7 +2550,7 @@ export default function DashboardPage({
{job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
) : (
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="dashboard-job-row-content">
<div className="dashboard-job-row-main">
@@ -2414,8 +2565,16 @@ export default function DashboardPage({
</strong>
<small>
#{jobId}
{job?.year ? ` | ${job.year}` : ''}
{job?.imdb_id ? ` | ${job.imdb_id}` : ''}
{isAudiobookJob
? (
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
)
: (
`${job?.year ? ` | ${job.year}` : ''}`
+ `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`
)}
</small>
</div>
<div className="dashboard-job-badges">

View File

@@ -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 (
<span className="job-step-cell">
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
@@ -781,7 +784,7 @@ export default function DatabasePage() {
<Card
title="RAW ohne Historie"
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,other}) ohne zugehörigen Job können hier importiert werden"
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,cd,audiobook,other}) ohne zugehörigen Job können hier importiert werden"
>
<div className="table-filters">
<Button

View File

@@ -24,6 +24,7 @@ const MEDIA_FILTER_OPTIONS = [
{ label: 'Blu-ray', value: 'bluray' },
{ label: 'DVD', value: 'dvd' },
{ label: 'Audio CD', value: 'cd' },
{ label: 'Audiobook', value: 'audiobook' },
{ label: 'Sonstiges', value: 'other' }
];
@@ -80,6 +81,9 @@ function resolveMediaType(row) {
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
return 'cd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
}
const statusCandidates = [
row?.status,
@@ -100,6 +104,12 @@ 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') {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
return 'audiobook';
}
return 'other';
}
@@ -129,6 +139,14 @@ function resolveMediaTypeMeta(row) {
alt: 'Audio CD'
};
}
if (mediaType === 'audiobook') {
return {
mediaType,
icon: otherIndicatorIcon,
label: 'Audiobook',
alt: 'Audiobook'
};
}
return {
mediaType,
icon: otherIndicatorIcon,
@@ -216,12 +234,47 @@ function resolveCdDetails(row) {
};
}
function resolveAudiobookDetails(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object'
? row.makemkvInfo.selectedMetadata
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
const chapters = Array.isArray(selectedMetadata?.chapters)
? selectedMetadata.chapters
: (Array.isArray(row?.makemkvInfo?.chapters) ? row.makemkvInfo.chapters : []);
const format = String(
row?.handbrakeInfo?.format
|| encodePlan?.format
|| ''
).trim().toLowerCase() || null;
return {
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
narrator: String(selectedMetadata?.narrator || '').trim() || null,
chapterCount: chapters.length,
formatLabel: format ? format.toUpperCase() : null
};
}
function getOutputLabelForRow(row) {
return resolveMediaType(row) === 'cd' ? 'Audio-Dateien' : 'Movie-Datei(en)';
const mediaType = resolveMediaType(row);
if (mediaType === 'cd') {
return 'Audio-Dateien';
}
if (mediaType === 'audiobook') {
return 'Audiobook-Datei(en)';
}
return 'Movie-Datei(en)';
}
function getOutputShortLabelForRow(row) {
return resolveMediaType(row) === 'cd' ? 'Audio' : 'Movie';
const mediaType = resolveMediaType(row);
if (mediaType === 'cd') {
return 'Audio';
}
if (mediaType === 'audiobook') {
return 'Audiobook';
}
return 'Movie';
}
function normalizeJobId(value) {
@@ -760,7 +813,7 @@ export default function HistoryPage() {
if (row?.poster_url && row.poster_url !== 'N/A') {
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
}
return <div className="history-dv-poster-fallback">{mediaMeta.mediaType === 'cd' ? 'Kein Cover' : 'Kein Poster'}</div>;
return <div className="history-dv-poster-fallback">{['cd', 'audiobook'].includes(mediaMeta.mediaType) ? 'Kein Cover' : 'Kein Poster'}</div>;
};
const renderPresenceChip = (label, available) => (
@@ -803,6 +856,32 @@ export default function HistoryPage() {
));
}
if (resolveMediaType(row) === 'audiobook') {
const audiobookDetails = resolveAudiobookDetails(row);
const infoItems = [];
if (audiobookDetails.author) {
infoItems.push({ key: 'author', label: 'Autor', value: audiobookDetails.author });
}
if (audiobookDetails.narrator) {
infoItems.push({ key: 'narrator', label: 'Sprecher', value: audiobookDetails.narrator });
}
if (audiobookDetails.chapterCount > 0) {
infoItems.push({ key: 'chapters', label: 'Kapitel', value: String(audiobookDetails.chapterCount) });
}
if (audiobookDetails.formatLabel) {
infoItems.push({ key: 'format', label: 'Format', value: audiobookDetails.formatLabel });
}
if (infoItems.length === 0) {
return <span className="history-dv-subtle">Keine Audiobook-Details</span>;
}
return infoItems.map((item) => (
<span key={`${row?.id}-${item.key}`} className="history-dv-rating-chip">
<strong>{item.label}</strong>
<span>{item.value}</span>
</span>
));
}
const ratings = resolveRatings(row);
if (ratings.length === 0) {
return <span className="history-dv-subtle">Keine Ratings</span>;

View File

@@ -3405,3 +3405,74 @@ body {
grid-template-columns: 1fr;
}
}
.audiobook-config-panel {
display: grid;
gap: 1rem;
}
.audiobook-config-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.audiobook-config-tags {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: flex-start;
}
.audiobook-config-grid {
display: grid;
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
gap: 1rem;
}
.audiobook-config-settings,
.audiobook-config-chapters {
display: grid;
gap: 0.85rem;
}
.audiobook-config-chapters h4 {
margin: 0;
}
.audiobook-chapter-list {
display: grid;
gap: 0.55rem;
max-height: 18rem;
overflow: auto;
padding-right: 0.25rem;
}
.audiobook-chapter-row {
display: grid;
gap: 0.15rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--surface-border, #d8d3c6);
border-radius: 10px;
background: var(--surface-card, #fff);
}
.audiobook-chapter-row small {
color: var(--rip-muted, #666);
}
.audiobook-output-path {
padding: 0.75rem 0.85rem;
border-radius: 10px;
border: 1px solid var(--surface-border, #d8d3c6);
background: var(--surface-ground, #f7f7f7);
word-break: break-word;
}
@media (max-width: 980px) {
.audiobook-config-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -677,6 +677,7 @@ apt_update
info "Installiere Basispakete..."
apt-get install -y \
curl wget git \
ffmpeg \
mediainfo \
util-linux udev \
ca-certificates gnupg \

View File

@@ -443,6 +443,7 @@ apt_update
info "Installiere Basispakete..."
apt-get install -y \
curl wget git jq \
ffmpeg \
mediainfo \
util-linux udev \
ca-certificates gnupg \

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.9.1-6",
"version": "0.10.0-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.9.1-6",
"version": "0.10.0-5",
"devDependencies": {
"concurrently": "^9.1.2"
}

View File

@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.9.1-6",
"version": "0.10.0-5",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",

154
setup.sh Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_OWNER="Mboehmlaender"
REPO_NAME="ripster"
REPO_RAW_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}"
BRANCHES_API_URL="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches?per_page=100"
usage() {
cat <<'EOF'
Verwendung:
bash setup.sh [Optionen]
Optionen (wie install.sh):
--branch <branch> Branch direkt setzen (ohne Auswahlmenue)
--dir <pfad> Installationsverzeichnis
--user <benutzer> Systembenutzer fuer den Dienst
--port <port> Backend-Port
--host <hostname> Hostname/IP fuer die Weboberflaeche
--no-makemkv MakeMKV-Installation ueberspringen
--no-handbrake HandBrake-Installation ueberspringen
--no-nginx Nginx-Einrichtung ueberspringen
--reinstall Vorhandene Installation aktualisieren
-h, --help Hilfe anzeigen
EOF
}
SELECTED_BRANCH=""
FORWARDED_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--branch)
[[ $# -ge 2 ]] || { echo "Fehlender Wert fuer --branch" >&2; exit 1; }
SELECTED_BRANCH="$2"
shift 2
;;
--dir|--user|--port|--host)
[[ $# -ge 2 ]] || { echo "Fehlender Wert fuer $1" >&2; exit 1; }
FORWARDED_ARGS+=("$1" "$2")
shift 2
;;
--no-makemkv|--no-handbrake|--no-nginx|--reinstall)
FORWARDED_ARGS+=("$1")
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unbekannter Parameter: $1" >&2
usage >&2
exit 1
;;
esac
done
fetch_url() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$url"
return
fi
echo "Weder curl noch wget gefunden. Bitte eines davon installieren." >&2
exit 1
}
download_file() {
local url="$1"
local target="$2"
fetch_url "$url" > "$target"
}
select_branch() {
local branches_json
local -a branches
local selection
branches_json="$(fetch_url "$BRANCHES_API_URL")"
mapfile -t branches < <(
printf '%s\n' "$branches_json" \
| grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \
| sed -E 's/"name"[[:space:]]*:[[:space:]]*"([^"]+)"/\1/'
)
if [[ ${#branches[@]} -eq 0 ]]; then
echo "Keine Branches gefunden oder API-Antwort ungültig." >&2
exit 1
fi
if [[ -n "$SELECTED_BRANCH" ]]; then
local found=false
for branch in "${branches[@]}"; do
if [[ "$branch" == "$SELECTED_BRANCH" ]]; then
found=true
break
fi
done
if [[ "$found" == false ]]; then
echo "Branch '$SELECTED_BRANCH' nicht gefunden." >&2
exit 1
fi
return
fi
if [[ ! -t 0 ]]; then
echo "Kein interaktives Terminal für die Branch-Auswahl verfügbar." >&2
exit 1
fi
echo "Verfügbare Branches:"
for i in "${!branches[@]}"; do
printf " %2d) %s\n" "$((i + 1))" "${branches[$i]}"
done
while true; do
read -r -p "Bitte Branch auswählen [1-${#branches[@]}]: " selection
if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#branches[@]} )); then
SELECTED_BRANCH="${branches[$((selection - 1))]}"
return
fi
echo "Ungültige Auswahl."
done
}
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
select_branch
INSTALL_SCRIPT="${TMP_DIR}/install.sh"
INSTALL_URL="${REPO_RAW_BASE}/${SELECTED_BRANCH}/install.sh"
echo "Lade install.sh aus Branch '${SELECTED_BRANCH}'..."
download_file "$INSTALL_URL" "$INSTALL_SCRIPT"
chmod +x "$INSTALL_SCRIPT"
if [[ $EUID -eq 0 ]]; then
bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
else
if ! command -v sudo >/dev/null 2>&1; then
echo "sudo nicht gefunden. Bitte als root ausführen." >&2
exit 1
fi
sudo bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
fi