0.10.0-5 AudioBooks Frontend
This commit is contained in:
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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(
|
router.post(
|
||||||
'/select-metadata',
|
'/select-metadata',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']);
|
|||||||
const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']);
|
const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']);
|
||||||
const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})';
|
const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})';
|
||||||
const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{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) {
|
function normalizeText(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
@@ -32,6 +43,50 @@ function normalizeOutputFormat(value) {
|
|||||||
return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3';
|
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) {
|
function normalizeInputExtension(filePath) {
|
||||||
return path.extname(String(filePath || '')).trim().toLowerCase();
|
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 cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
|
||||||
const format = normalizeOutputFormat(outputFormat);
|
const format = normalizeOutputFormat(outputFormat);
|
||||||
const codecArgs = format === 'm4b'
|
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
|
||||||
? ['-codec', 'copy']
|
const commonArgs = [
|
||||||
: (format === 'flac'
|
'-y',
|
||||||
? ['-codec:a', 'flac']
|
'-i', inputPath,
|
||||||
: ['-codec:a', 'libmp3lame']);
|
'-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 {
|
return {
|
||||||
cmd,
|
cmd,
|
||||||
args: ['-y', '-i', inputPath, ...codecArgs, outputPath]
|
args: [...commonArgs, ...codecArgs, outputPath],
|
||||||
|
formatOptions: normalizedOptions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +368,10 @@ module.exports = {
|
|||||||
SUPPORTED_OUTPUT_FORMATS,
|
SUPPORTED_OUTPUT_FORMATS,
|
||||||
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
|
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
|
||||||
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
||||||
|
AUDIOBOOK_FORMAT_DEFAULTS,
|
||||||
normalizeOutputFormat,
|
normalizeOutputFormat,
|
||||||
|
getDefaultFormatOptions,
|
||||||
|
normalizeFormatOptions,
|
||||||
isSupportedInputFile,
|
isSupportedInputFile,
|
||||||
buildMetadataFromProbe,
|
buildMetadataFromProbe,
|
||||||
buildRawStoragePaths,
|
buildRawStoragePaths,
|
||||||
|
|||||||
@@ -9605,12 +9605,13 @@ class PipelineService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (isAudiobookRetry) {
|
} else if (isAudiobookRetry) {
|
||||||
const startResult = await this.startPreparedJob(retryJobId);
|
|
||||||
return {
|
return {
|
||||||
sourceJobId: Number(jobId),
|
|
||||||
jobId: retryJobId,
|
jobId: retryJobId,
|
||||||
|
sourceJobId: Number(jobId),
|
||||||
replacedSourceJob: true,
|
replacedSourceJob: true,
|
||||||
...(startResult && typeof startResult === 'object' ? startResult : {})
|
started: false,
|
||||||
|
queued: false,
|
||||||
|
stage: 'READY_TO_START'
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.startRipEncode(retryJobId).catch((error) => {
|
this.startRipEncode(retryJobId).catch((error) => {
|
||||||
@@ -10763,7 +10764,7 @@ class PipelineService extends EventEmitter {
|
|||||||
const detectedTitle = path.basename(originalName, path.extname(originalName)) || 'Audiobook';
|
const detectedTitle = path.basename(originalName, path.extname(originalName)) || 'Audiobook';
|
||||||
const requestedFormat = String(options?.format || '').trim().toLowerCase() || null;
|
const requestedFormat = String(options?.format || '').trim().toLowerCase() || null;
|
||||||
const startImmediately = options?.startImmediately === undefined
|
const startImmediately = options?.startImmediately === undefined
|
||||||
? true
|
? false
|
||||||
: !['0', 'false', 'no', 'off'].includes(String(options.startImmediately).trim().toLowerCase());
|
: !['0', 'false', 'no', 'off'].includes(String(options.startImmediately).trim().toLowerCase());
|
||||||
|
|
||||||
if (!tempFilePath || !fs.existsSync(tempFilePath)) {
|
if (!tempFilePath || !fs.existsSync(tempFilePath)) {
|
||||||
@@ -10790,6 +10791,7 @@ class PipelineService extends EventEmitter {
|
|||||||
const outputFormat = audiobookService.normalizeOutputFormat(
|
const outputFormat = audiobookService.normalizeOutputFormat(
|
||||||
requestedFormat || settings?.output_extension || 'mp3'
|
requestedFormat || settings?.output_extension || 'mp3'
|
||||||
);
|
);
|
||||||
|
const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat);
|
||||||
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||||
|
|
||||||
const job = await historyService.createJob({
|
const job = await historyService.createJob({
|
||||||
@@ -10872,6 +10874,7 @@ class PipelineService extends EventEmitter {
|
|||||||
sourceType: 'upload',
|
sourceType: 'upload',
|
||||||
uploadedAt: nowIso(),
|
uploadedAt: nowIso(),
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
|
formatOptions,
|
||||||
rawTemplate,
|
rawTemplate,
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
encodeInputPath: storagePaths.rawFilePath,
|
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 = {}) {
|
async startAudiobookEncode(jobId, options = {}) {
|
||||||
const immediate = Boolean(options?.immediate);
|
const immediate = Boolean(options?.immediate);
|
||||||
if (!immediate) {
|
if (!immediate) {
|
||||||
@@ -11027,6 +11102,10 @@ class PipelineService extends EventEmitter {
|
|||||||
preferredFinalOutputPath,
|
preferredFinalOutputPath,
|
||||||
incompleteOutputPath
|
incompleteOutputPath
|
||||||
} = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId);
|
} = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId);
|
||||||
|
const formatOptions = audiobookService.normalizeFormatOptions(
|
||||||
|
outputFormat,
|
||||||
|
encodePlan?.formatOptions || {}
|
||||||
|
);
|
||||||
ensureDir(path.dirname(incompleteOutputPath));
|
ensureDir(path.dirname(incompleteOutputPath));
|
||||||
|
|
||||||
await historyService.resetProcessLog(jobId);
|
await historyService.resetProcessLog(jobId);
|
||||||
@@ -11042,14 +11121,23 @@ class PipelineService extends EventEmitter {
|
|||||||
inputPath,
|
inputPath,
|
||||||
outputPath: incompleteOutputPath,
|
outputPath: incompleteOutputPath,
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
|
formatOptions,
|
||||||
chapters: metadata.chapters,
|
chapters: metadata.chapters,
|
||||||
selectedMetadata: {
|
selectedMetadata: {
|
||||||
title: metadata.title || job.title || job.detected_title || null,
|
title: metadata.title || job.title || job.detected_title || null,
|
||||||
year: metadata.year ?? job.year ?? null,
|
year: metadata.year ?? job.year ?? null,
|
||||||
author: metadata.author || null,
|
author: metadata.author || null,
|
||||||
narrator: metadata.narrator || 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
|
poster: job.poster_url || null
|
||||||
},
|
},
|
||||||
|
audiobookConfig: {
|
||||||
|
format: outputFormat,
|
||||||
|
formatOptions
|
||||||
|
},
|
||||||
canRestartEncodeFromLastSettings: false,
|
canRestartEncodeFromLastSettings: false,
|
||||||
canRestartReviewFromRaw: false
|
canRestartReviewFromRaw: false
|
||||||
}
|
}
|
||||||
@@ -11082,7 +11170,8 @@ class PipelineService extends EventEmitter {
|
|||||||
settings?.ffmpeg_command || 'ffmpeg',
|
settings?.ffmpeg_command || 'ffmpeg',
|
||||||
inputPath,
|
inputPath,
|
||||||
incompleteOutputPath,
|
incompleteOutputPath,
|
||||||
outputFormat
|
outputFormat,
|
||||||
|
formatOptions
|
||||||
);
|
);
|
||||||
logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
|
logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
|
||||||
const ffmpegRunInfo = await this.runCommand({
|
const ffmpegRunInfo = await this.runCommand({
|
||||||
@@ -11119,6 +11208,7 @@ class PipelineService extends EventEmitter {
|
|||||||
...ffmpegRunInfo,
|
...ffmpegRunInfo,
|
||||||
mode: 'audiobook_encode',
|
mode: 'audiobook_encode',
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
|
formatOptions,
|
||||||
metadata,
|
metadata,
|
||||||
inputPath,
|
inputPath,
|
||||||
outputPath: finalizedOutputPath
|
outputPath: finalizedOutputPath
|
||||||
@@ -11178,6 +11268,7 @@ class PipelineService extends EventEmitter {
|
|||||||
...error.runInfo,
|
...error.runInfo,
|
||||||
mode: 'audiobook_encode',
|
mode: 'audiobook_encode',
|
||||||
format: outputFormat,
|
format: outputFormat,
|
||||||
|
formatOptions,
|
||||||
inputPath
|
inputPath
|
||||||
})
|
})
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -321,6 +321,14 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
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) {
|
async selectMetadata(payload) {
|
||||||
const result = await request('/pipeline/select-metadata', {
|
const result = await request('/pipeline/select-metadata', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
261
frontend/src/components/AudiobookConfigPanel.jsx
Normal file
261
frontend/src/components/AudiobookConfigPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -327,13 +327,26 @@ function resolveAudiobookDetails(job) {
|
|||||||
? selectedMetadata.chapters
|
? selectedMetadata.chapters
|
||||||
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
|
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
|
||||||
const format = String(job?.handbrakeInfo?.format || encodePlan?.format || '').trim().toLowerCase() || null;
|
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 {
|
return {
|
||||||
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
|
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
|
||||||
narrator: String(selectedMetadata?.narrator || '').trim() || null,
|
narrator: String(selectedMetadata?.narrator || '').trim() || null,
|
||||||
series: String(selectedMetadata?.series || '').trim() || null,
|
series: String(selectedMetadata?.series || '').trim() || null,
|
||||||
part: String(selectedMetadata?.part || '').trim() || null,
|
part: String(selectedMetadata?.part || '').trim() || null,
|
||||||
chapterCount: chapters.length,
|
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.poster_url && job.poster_url !== 'N/A' ? (
|
||||||
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
|
<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">
|
<div className="job-film-info-grid">
|
||||||
@@ -603,6 +616,10 @@ export default function JobDetailDialog({
|
|||||||
<strong>Format:</strong>
|
<strong>Format:</strong>
|
||||||
<span>{audiobookDetails?.formatLabel || '-'}</span>
|
<span>{audiobookDetails?.formatLabel || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Qualität:</strong>
|
||||||
|
<span>{audiobookDetails?.qualityLabel || '-'}</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
80
frontend/src/config/audiobookFormatSchemas.js
Normal file
80
frontend/src/config/audiobookFormatSchemas.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import PipelineStatusCard from '../components/PipelineStatusCard';
|
|||||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
import CdMetadataDialog from '../components/CdMetadataDialog';
|
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||||
import CdRipConfigPanel from '../components/CdRipConfigPanel';
|
import CdRipConfigPanel from '../components/CdRipConfigPanel';
|
||||||
|
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.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,
|
title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null,
|
||||||
author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
|
author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
|
||||||
narrator: audiobookSelectedMeta?.narrator || null,
|
narrator: audiobookSelectedMeta?.narrator || null,
|
||||||
|
series: audiobookSelectedMeta?.series || null,
|
||||||
|
part: audiobookSelectedMeta?.part || null,
|
||||||
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
|
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
|
||||||
|
chapters: Array.isArray(audiobookSelectedMeta?.chapters) ? audiobookSelectedMeta.chapters : [],
|
||||||
|
durationMs: audiobookSelectedMeta?.durationMs || 0,
|
||||||
poster: job?.poster_url || null
|
poster: job?.poster_url || null
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -667,6 +672,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
mode,
|
mode,
|
||||||
sourceJobId: encodePlan?.sourceJobId || null,
|
sourceJobId: encodePlan?.sourceJobId || null,
|
||||||
selectedMetadata,
|
selectedMetadata,
|
||||||
|
audiobookConfig: resolvedMediaType === 'audiobook'
|
||||||
|
? {
|
||||||
|
format: String(encodePlan?.format || '').trim().toLowerCase() || 'mp3',
|
||||||
|
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
|
||||||
|
? encodePlan.formatOptions
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
mediaInfoReview: encodePlan,
|
mediaInfoReview: encodePlan,
|
||||||
playlistAnalysis: analyzeContext.playlistAnalysis || null,
|
playlistAnalysis: analyzeContext.playlistAnalysis || null,
|
||||||
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
|
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
|
||||||
@@ -1324,7 +1337,7 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
setAudiobookUploadBusy(true);
|
setAudiobookUploadBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: true });
|
const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: false });
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||||
await refreshPipeline();
|
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 (
|
const handleConfirmReview = async (
|
||||||
jobId,
|
jobId,
|
||||||
selectedEncodeTitleId = null,
|
selectedEncodeTitleId = null,
|
||||||
@@ -2008,7 +2044,7 @@ export default function DashboardPage({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, ins RAW-Verzeichnis übernehmen und direkt mit dem in den Settings gewählten Zielformat starten.">
|
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, analysieren und danach Format/Qualität vor dem Start auswählen.">
|
||||||
<div className="actions-row">
|
<div className="actions-row">
|
||||||
<input
|
<input
|
||||||
key={audiobookUploadFile ? `${audiobookUploadFile.name}-${audiobookUploadFile.size}` : 'audiobook-upload-input'}
|
key={audiobookUploadFile ? `${audiobookUploadFile.name}-${audiobookUploadFile.size}` : 'audiobook-upload-input'}
|
||||||
@@ -2033,7 +2069,7 @@ export default function DashboardPage({
|
|||||||
<small>
|
<small>
|
||||||
{audiobookUploadFile
|
{audiobookUploadFile
|
||||||
? `Ausgewählt: ${audiobookUploadFile.name}`
|
? `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.'}
|
||||||
</small>
|
</small>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -2374,11 +2410,14 @@ export default function DashboardPage({
|
|||||||
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
|
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
|
||||||
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
||||||
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
|
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
|
||||||
const isResumable = normalizedStatus === 'READY_TO_ENCODE' && !isCurrentSession;
|
|
||||||
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
|
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
|
||||||
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
||||||
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||||
const mediaIndicator = mediaIndicatorMeta(job);
|
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 mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
||||||
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||||
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
||||||
@@ -2388,6 +2427,9 @@ export default function DashboardPage({
|
|||||||
|| mediaProfile === 'cd'
|
|| mediaProfile === 'cd'
|
||||||
|| mediaIndicator.mediaType === 'cd'
|
|| mediaIndicator.mediaType === 'cd'
|
||||||
|| pipelineStatusText.includes('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 rawProgress = Number(pipelineForJob?.progress ?? 0);
|
||||||
const clampedProgress = Number.isFinite(rawProgress)
|
const clampedProgress = Number.isFinite(rawProgress)
|
||||||
? Math.max(0, Math.min(100, rawProgress))
|
? Math.max(0, Math.min(100, rawProgress))
|
||||||
@@ -2395,14 +2437,19 @@ export default function DashboardPage({
|
|||||||
const progressLabel = `${Math.round(clampedProgress)}%`;
|
const progressLabel = `${Math.round(clampedProgress)}%`;
|
||||||
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
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) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
<div key={jobId} className="dashboard-job-expanded">
|
<div key={jobId} className="dashboard-job-expanded">
|
||||||
<div className="dashboard-job-expanded-head">
|
<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" />
|
<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">
|
<div className="dashboard-job-expanded-title">
|
||||||
<strong className="dashboard-job-title-line">
|
<strong className="dashboard-job-title-line">
|
||||||
@@ -2456,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;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
{!isCdJob ? (
|
{!isCdJob && !isAudiobookJob ? (
|
||||||
<PipelineStatusCard
|
<PipelineStatusCard
|
||||||
pipeline={pipelineForJob}
|
pipeline={pipelineForJob}
|
||||||
onAnalyze={handleAnalyze}
|
onAnalyze={handleAnalyze}
|
||||||
@@ -2492,7 +2550,7 @@ export default function DashboardPage({
|
|||||||
{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" />
|
<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-content">
|
||||||
<div className="dashboard-job-row-main">
|
<div className="dashboard-job-row-main">
|
||||||
@@ -2507,8 +2565,16 @@ export default function DashboardPage({
|
|||||||
</strong>
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
#{jobId}
|
#{jobId}
|
||||||
{job?.year ? ` | ${job.year}` : ''}
|
{isAudiobookJob
|
||||||
{job?.imdb_id ? ` | ${job.imdb_id}` : ''}
|
? (
|
||||||
|
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
|
||||||
|
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
|
||||||
|
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
`${job?.year ? ` | ${job.year}` : ''}`
|
||||||
|
+ `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`
|
||||||
|
)}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="dashboard-job-badges">
|
<div className="dashboard-job-badges">
|
||||||
|
|||||||
@@ -3405,3 +3405,74 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.0-4",
|
"version": "0.10.0-5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user