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' ? ( {job.title ) : ( -
{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') ? ( {jobTitle} ) : ( -
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",