diff --git a/backend/package-lock.json b/backend/package-lock.json
index be814cd..6343d42 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
- "version": "0.10.0-4",
+ "version": "0.10.0-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
- "version": "0.10.0-4",
+ "version": "0.10.0-5",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.7",
diff --git a/backend/package.json b/backend/package.json
index 4c677ae..8fea994 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
- "version": "0.10.0-4",
+ "version": "0.10.0-5",
"private": true,
"type": "commonjs",
"scripts": {
diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js
index 002d69c..0a60179 100644
--- a/backend/src/routes/pipelineRoutes.js
+++ b/backend/src/routes/pipelineRoutes.js
@@ -155,6 +155,24 @@ router.post(
})
);
+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) => {
diff --git a/backend/src/services/audiobookService.js b/backend/src/services/audiobookService.js
index 06bf1b1..3713d4b 100644
--- a/backend/src/services/audiobookService.js
+++ b/backend/src/services/audiobookService.js
@@ -5,6 +5,17 @@ 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 || '')
@@ -32,6 +43,50 @@ function normalizeOutputFormat(value) {
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();
}
@@ -241,17 +296,32 @@ function buildProbeCommand(ffprobeCommand, inputPath) {
};
}
-function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3') {
+function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const format = normalizeOutputFormat(outputFormat);
- const codecArgs = format === 'm4b'
- ? ['-codec', 'copy']
- : (format === 'flac'
- ? ['-codec:a', 'flac']
- : ['-codec:a', 'libmp3lame']);
+ 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: ['-y', '-i', inputPath, ...codecArgs, outputPath]
+ args: [...commonArgs, ...codecArgs, outputPath],
+ formatOptions: normalizedOptions
};
}
@@ -298,7 +368,10 @@ module.exports = {
SUPPORTED_OUTPUT_FORMATS,
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
+ AUDIOBOOK_FORMAT_DEFAULTS,
normalizeOutputFormat,
+ getDefaultFormatOptions,
+ normalizeFormatOptions,
isSupportedInputFile,
buildMetadataFromProbe,
buildRawStoragePaths,
diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js
index 37c93f0..68301f4 100644
--- a/backend/src/services/pipelineService.js
+++ b/backend/src/services/pipelineService.js
@@ -9605,12 +9605,13 @@ class PipelineService extends EventEmitter {
});
});
} else if (isAudiobookRetry) {
- const startResult = await this.startPreparedJob(retryJobId);
return {
- sourceJobId: Number(jobId),
jobId: retryJobId,
+ sourceJobId: Number(jobId),
replacedSourceJob: true,
- ...(startResult && typeof startResult === 'object' ? startResult : {})
+ started: false,
+ queued: false,
+ stage: 'READY_TO_START'
};
} else {
this.startRipEncode(retryJobId).catch((error) => {
@@ -10763,7 +10764,7 @@ class PipelineService extends EventEmitter {
const detectedTitle = path.basename(originalName, path.extname(originalName)) || 'Audiobook';
const requestedFormat = String(options?.format || '').trim().toLowerCase() || null;
const startImmediately = options?.startImmediately === undefined
- ? true
+ ? false
: !['0', 'false', 'no', 'off'].includes(String(options.startImmediately).trim().toLowerCase());
if (!tempFilePath || !fs.existsSync(tempFilePath)) {
@@ -10790,6 +10791,7 @@ class PipelineService extends EventEmitter {
const outputFormat = audiobookService.normalizeOutputFormat(
requestedFormat || settings?.output_extension || 'mp3'
);
+ const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat);
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
const job = await historyService.createJob({
@@ -10872,6 +10874,7 @@ class PipelineService extends EventEmitter {
sourceType: 'upload',
uploadedAt: nowIso(),
format: outputFormat,
+ formatOptions,
rawTemplate,
outputTemplate,
encodeInputPath: storagePaths.rawFilePath,
@@ -10958,6 +10961,78 @@ class PipelineService extends EventEmitter {
}
}
+ async startAudiobookWithConfig(jobId, config = {}) {
+ const normalizedJobId = Number(jobId);
+ if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
+ const error = new Error('Ungültige Job-ID für Audiobook-Start.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const job = await historyService.getJobById(normalizedJobId);
+ if (!job) {
+ const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
+ error.statusCode = 404;
+ throw error;
+ }
+
+ const encodePlan = this.safeParseJson(job.encode_plan_json);
+ const makemkvInfo = this.safeParseJson(job.makemkv_info_json);
+ const mediaProfile = this.resolveMediaProfileForJob(job, {
+ encodePlan,
+ makemkvInfo,
+ mediaProfile: 'audiobook'
+ });
+ if (mediaProfile !== 'audiobook') {
+ const error = new Error(`Job ${normalizedJobId} ist kein Audiobook-Job.`);
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const format = audiobookService.normalizeOutputFormat(
+ config?.format || encodePlan?.format || 'mp3'
+ );
+ const formatOptions = audiobookService.normalizeFormatOptions(
+ format,
+ config?.formatOptions || encodePlan?.formatOptions || {}
+ );
+ const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan);
+
+ const nextEncodePlan = {
+ ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
+ mediaProfile: 'audiobook',
+ mode: 'audiobook',
+ format,
+ formatOptions,
+ metadata,
+ reviewConfirmed: true
+ };
+
+ await historyService.updateJob(normalizedJobId, {
+ status: 'READY_TO_START',
+ last_state: 'READY_TO_START',
+ title: metadata.title || job.title || job.detected_title || 'Audiobook',
+ year: metadata.year ?? job.year ?? null,
+ encode_plan_json: JSON.stringify(nextEncodePlan),
+ encode_review_confirmed: 1,
+ error_message: null,
+ handbrake_info_json: null,
+ end_time: null
+ });
+
+ await historyService.appendLog(
+ normalizedJobId,
+ 'USER_ACTION',
+ `Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()}`
+ );
+
+ const startResult = await this.startPreparedJob(normalizedJobId);
+ return {
+ jobId: normalizedJobId,
+ ...(startResult && typeof startResult === 'object' ? startResult : {})
+ };
+ }
+
async startAudiobookEncode(jobId, options = {}) {
const immediate = Boolean(options?.immediate);
if (!immediate) {
@@ -11027,6 +11102,10 @@ class PipelineService extends EventEmitter {
preferredFinalOutputPath,
incompleteOutputPath
} = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId);
+ const formatOptions = audiobookService.normalizeFormatOptions(
+ outputFormat,
+ encodePlan?.formatOptions || {}
+ );
ensureDir(path.dirname(incompleteOutputPath));
await historyService.resetProcessLog(jobId);
@@ -11042,14 +11121,23 @@ class PipelineService extends EventEmitter {
inputPath,
outputPath: incompleteOutputPath,
format: outputFormat,
+ formatOptions,
chapters: metadata.chapters,
selectedMetadata: {
title: metadata.title || job.title || job.detected_title || null,
year: metadata.year ?? job.year ?? null,
author: metadata.author || null,
narrator: metadata.narrator || null,
+ series: metadata.series || null,
+ part: metadata.part || null,
+ chapters: Array.isArray(metadata.chapters) ? metadata.chapters : [],
+ durationMs: metadata.durationMs || 0,
poster: job.poster_url || null
},
+ audiobookConfig: {
+ format: outputFormat,
+ formatOptions
+ },
canRestartEncodeFromLastSettings: false,
canRestartReviewFromRaw: false
}
@@ -11082,7 +11170,8 @@ class PipelineService extends EventEmitter {
settings?.ffmpeg_command || 'ffmpeg',
inputPath,
incompleteOutputPath,
- outputFormat
+ outputFormat,
+ formatOptions
);
logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
const ffmpegRunInfo = await this.runCommand({
@@ -11119,6 +11208,7 @@ class PipelineService extends EventEmitter {
...ffmpegRunInfo,
mode: 'audiobook_encode',
format: outputFormat,
+ formatOptions,
metadata,
inputPath,
outputPath: finalizedOutputPath
@@ -11178,6 +11268,7 @@ class PipelineService extends EventEmitter {
...error.runInfo,
mode: 'audiobook_encode',
format: outputFormat,
+ formatOptions,
inputPath
})
}).catch(() => {});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d25df54..d593360 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
- "version": "0.10.0-4",
+ "version": "0.10.0-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
- "version": "0.10.0-4",
+ "version": "0.10.0-5",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
diff --git a/frontend/package.json b/frontend/package.json
index 1cc3404..e838879 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
- "version": "0.10.0-4",
+ "version": "0.10.0-5",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index c37387b..a6ce843 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -321,6 +321,14 @@ export const api = {
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',
diff --git a/frontend/src/components/AudiobookConfigPanel.jsx b/frontend/src/components/AudiobookConfigPanel.jsx
new file mode 100644
index 0000000..06880f6
--- /dev/null
+++ b/frontend/src/components/AudiobookConfigPanel.jsx
@@ -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 (
+
+
+ {field.description ? {field.description} : null}
+ onChange(field.key, event.value)}
+ min={field.min}
+ max={field.max}
+ step={field.step || 1}
+ disabled={disabled}
+ />
+
+ );
+ }
+
+ if (field.type === 'select') {
+ return (
+
+
+ {field.description ? {field.description} : null}
+ onChange(field.key, event.value)}
+ disabled={disabled}
+ />
+
+ );
+ }
+
+ 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 (
+
+
+
+
Titel: {metadata?.title || '-'}
+
Autor: {metadata?.author || '-'}
+
Sprecher: {metadata?.narrator || '-'}
+
Serie: {metadata?.series || '-'}
+
Teil: {metadata?.part || '-'}
+
Jahr: {metadata?.year || '-'}
+
Kapitel: {chapters.length || '-'}
+
+
+
+
+ {metadata?.durationMs ? : null}
+
+
+
+
+
+
+
+ {
+ const nextFormat = normalizeFormat(event.value);
+ setFormat(nextFormat);
+ setFormatOptions(buildFormatOptions(nextFormat, {}));
+ }}
+ disabled={busy || isRunning}
+ />
+
+
+ {visibleFields.map((field) => (
+
{
+ setFormatOptions((prev) => ({
+ ...prev,
+ [key]: nextValue
+ }));
+ }}
+ disabled={busy || isRunning}
+ />
+ ))}
+
+
+ Metadaten und Kapitel werden aus der AAX-Datei gelesen. Erst nach Klick auf Start wird `ffmpeg` ausgeführt.
+
+
+
+
+
Kapitelvorschau
+ {chapters.length === 0 ? (
+
Keine Kapitel in der Quelle erkannt.
+ ) : (
+
+ {chapters.map((chapter, index) => (
+
+ #{chapter?.index || index + 1}
+ {chapter?.title || `Kapitel ${index + 1}`}
+
+ {formatChapterTime(chapter?.startSeconds)} - {formatChapterTime(chapter?.endSeconds)}
+
+
+ ))}
+
+ )}
+
+
+
+ {isRunning ? (
+
+
+
{Math.round(progress)}%
+
+ ) : null}
+
+ {outputPath ? (
+
+ Ausgabe: {outputPath}
+
+ ) : null}
+
+
+ {canStart ? (
+
+
+ );
+}
diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx
index 1193578..fe753e1 100644
--- a/frontend/src/components/JobDetailDialog.jsx
+++ b/frontend/src/components/JobDetailDialog.jsx
@@ -327,13 +327,26 @@ function resolveAudiobookDetails(job) {
? 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
+ formatLabel: format ? format.toUpperCase() : null,
+ qualityLabel
};
}
@@ -513,7 +526,7 @@ export default function JobDetailDialog({
{job.poster_url && job.poster_url !== 'N/A' ? (
) : (
- {isCd ? 'Kein Cover' : 'Kein Poster'}
+ {isCd || isAudiobook ? 'Kein Cover' : 'Kein Poster'}
)}
@@ -603,6 +616,10 @@ export default function JobDetailDialog({
Format:
{audiobookDetails?.formatLabel || '-'}
+
+ Qualität:
+ {audiobookDetails?.qualityLabel || '-'}
+
>
) : (
<>
diff --git a/frontend/src/config/audiobookFormatSchemas.js b/frontend/src/config/audiobookFormatSchemas.js
new file mode 100644
index 0000000..0eca146
--- /dev/null
+++ b/frontend/src/config/audiobookFormatSchemas.js
@@ -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;
+}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index fc10545..d2e2500 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -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';
@@ -596,7 +597,11 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
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
}
: {
@@ -667,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),
@@ -1324,7 +1337,7 @@ export default function DashboardPage({
}
setAudiobookUploadBusy(true);
try {
- const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: true });
+ const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: false });
const result = getQueueActionResult(response);
const uploadedJobId = normalizeJobId(response?.result?.jobId);
await refreshPipeline();
@@ -1350,6 +1363,29 @@ export default function DashboardPage({
}
};
+ 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,
@@ -2008,7 +2044,7 @@ export default function DashboardPage({
)}
-
+
{audiobookUploadFile
? `Ausgewählt: ${audiobookUploadFile.name}`
- : 'Unterstützt im MVP: AAX-Upload. Das Ausgabeformat wird aus den Audiobook-Settings gelesen.'}
+ : 'Unterstützt im MVP: AAX-Upload. Danach erscheint ein eigener Audiobook-Startschritt mit Format- und Qualitätswahl.'}
@@ -2374,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();
@@ -2388,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))
@@ -2395,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 (
- {job?.poster_url && job.poster_url !== 'N/A' ? (
+ {(job?.poster_url && job.poster_url !== 'N/A') ? (

) : (
-
Kein Poster
+
{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}
)}
@@ -2456,9 +2503,20 @@ export default function DashboardPage({
>
);
}
+ if (isAudiobookJob) {
+ return (
+ handleAudiobookStart(jobId, config)}
+ onCancel={() => handleCancel(jobId, jobState)}
+ onRetry={() => handleRetry(jobId)}
+ busy={busyJobIds.has(jobId)}
+ />
+ );
+ }
return null;
})()}
- {!isCdJob ? (
+ {!isCdJob && !isAudiobookJob ? (
) : (
- Kein Poster
+ {isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}
)}
@@ -2507,8 +2565,16 @@ export default function DashboardPage({
#{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}` : ''}`
+ )}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index 8ec7762..be5b614 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -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;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 6c495ff..c178631 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster",
- "version": "0.10.0-4",
+ "version": "0.10.0-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
- "version": "0.10.0-4",
+ "version": "0.10.0-5",
"devDependencies": {
"concurrently": "^9.1.2"
}
diff --git a/package.json b/package.json
index 6161176..5885a87 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
- "version": "0.10.0-4",
+ "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",