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",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.10.0-4",
|
||||
"version": "0.10.0-5",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"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(
|
||||
'/select-metadata',
|
||||
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 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,
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user