0.10.0 Audbile Prototype
This commit is contained in:
71
backend/package-lock.json
generated
71
backend/package-lock.json
generated
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.9.1-6",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.9.1-6",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.1.1",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.18.0"
|
||||
@@ -158,6 +159,11 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
@@ -311,6 +317,22 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -431,6 +453,20 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
@@ -1507,6 +1543,24 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@@ -2236,6 +2290,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -2398,6 +2460,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.9.1-6",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
@@ -11,6 +11,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.1.1",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.18.0"
|
||||
|
||||
@@ -23,5 +23,7 @@ module.exports = {
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd')
|
||||
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
|
||||
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
|
||||
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks')
|
||||
};
|
||||
|
||||
@@ -826,7 +826,9 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
const SETTINGS_CATEGORY_MOVES = [
|
||||
{ key: 'cd_output_template', category: 'Pfade' },
|
||||
{ key: 'output_template_bluray', category: 'Pfade' },
|
||||
{ key: 'output_template_dvd', category: 'Pfade' }
|
||||
{ key: 'output_template_dvd', category: 'Pfade' },
|
||||
{ key: 'output_template_audiobook', category: 'Pfade' },
|
||||
{ key: 'audiobook_raw_template', category: 'Pfade' }
|
||||
];
|
||||
|
||||
async function migrateSettingsSchemaMetadata(db) {
|
||||
@@ -890,6 +892,60 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_extension_audiobook', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Audiobook-Datei.', 'mp3', '[{"label":"M4B","value":"m4b"},{"label":"MP3","value":"mp3"},{"label":"FLAC","value":"flac"}]', '{}', 730)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_audiobook', 'mp3')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);
|
||||
}
|
||||
|
||||
async function getDb() {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const express = require('express');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
@@ -6,6 +9,9 @@ const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||
|
||||
const router = express.Router();
|
||||
const audiobookUpload = multer({
|
||||
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads')
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/state',
|
||||
@@ -125,6 +131,28 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/audiobook/upload',
|
||||
audiobookUpload.single('file'),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.file) {
|
||||
const error = new Error('Upload-Datei fehlt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.info('post:audiobook:upload', {
|
||||
reqId: req.reqId,
|
||||
originalName: req.file.originalname,
|
||||
sizeBytes: Number(req.file.size || 0)
|
||||
});
|
||||
const result = await pipelineService.uploadAudiobookFile(req.file, {
|
||||
format: req.body?.format,
|
||||
startImmediately: req.body?.startImmediately
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
310
backend/src/services/audiobookService.js
Normal file
310
backend/src/services/audiobookService.js
Normal file
@@ -0,0 +1,310 @@
|
||||
const path = require('path');
|
||||
const { sanitizeFileName } = require('../utils/files');
|
||||
|
||||
const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']);
|
||||
const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']);
|
||||
const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})';
|
||||
const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})';
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFC')
|
||||
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
|
||||
.replace(/\p{C}+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseOptionalYear(value) {
|
||||
const text = normalizeText(value);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const match = text.match(/\b(19|20)\d{2}\b/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return Number(match[0]);
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(value) {
|
||||
const format = String(value || '').trim().toLowerCase();
|
||||
return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3';
|
||||
}
|
||||
|
||||
function normalizeInputExtension(filePath) {
|
||||
return path.extname(String(filePath || '')).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isSupportedInputFile(filePath) {
|
||||
return SUPPORTED_INPUT_EXTENSIONS.has(normalizeInputExtension(filePath));
|
||||
}
|
||||
|
||||
function normalizeTagMap(tags = null) {
|
||||
const source = tags && typeof tags === 'object' ? tags : {};
|
||||
const result = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const normalizedKey = String(key || '').trim().toLowerCase();
|
||||
if (!normalizedKey) {
|
||||
continue;
|
||||
}
|
||||
const normalizedValue = normalizeText(value);
|
||||
if (!normalizedValue) {
|
||||
continue;
|
||||
}
|
||||
result[normalizedKey] = normalizedValue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickTag(tags, keys = []) {
|
||||
const normalized = normalizeTagMap(tags);
|
||||
for (const key of keys) {
|
||||
const value = normalized[String(key || '').trim().toLowerCase()];
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildChapterList(probe = null) {
|
||||
const chapters = Array.isArray(probe?.chapters) ? probe.chapters : [];
|
||||
return chapters.map((chapter, index) => {
|
||||
const tags = normalizeTagMap(chapter?.tags);
|
||||
const startSeconds = Number(chapter?.start_time || chapter?.start || 0);
|
||||
const endSeconds = Number(chapter?.end_time || chapter?.end || 0);
|
||||
const title = tags.title || tags.chapter || `Kapitel ${index + 1}`;
|
||||
return {
|
||||
index: index + 1,
|
||||
title,
|
||||
startSeconds: Number.isFinite(startSeconds) ? startSeconds : 0,
|
||||
endSeconds: Number.isFinite(endSeconds) ? endSeconds : 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseProbeOutput(rawOutput) {
|
||||
if (!rawOutput) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(rawOutput);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMetadataFromProbe(probe = null, originalName = null) {
|
||||
const format = probe?.format && typeof probe.format === 'object' ? probe.format : {};
|
||||
const tags = normalizeTagMap(format.tags);
|
||||
const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || '')));
|
||||
const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook';
|
||||
const title = pickTag(tags, ['title', 'album']) || fallbackTitle;
|
||||
const author = pickTag(tags, ['artist', 'album_artist', 'composer']) || 'Unknown Author';
|
||||
const narrator = pickTag(tags, ['narrator', 'performer', 'comment']) || null;
|
||||
const series = pickTag(tags, ['series', 'grouping']) || null;
|
||||
const part = pickTag(tags, ['part', 'disc', 'track']) || null;
|
||||
const year = parseOptionalYear(pickTag(tags, ['date', 'year']));
|
||||
const durationSeconds = Number(format.duration || 0);
|
||||
const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
? Math.round(durationSeconds * 1000)
|
||||
: 0;
|
||||
const chapters = buildChapterList(probe);
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
narrator,
|
||||
series,
|
||||
part,
|
||||
year,
|
||||
album: title,
|
||||
artist: author,
|
||||
durationMs,
|
||||
chapters,
|
||||
tags
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateTokenKey(rawKey) {
|
||||
const key = String(rawKey || '').trim().toLowerCase();
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
if (key === 'artist') {
|
||||
return 'author';
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function cleanupRenderedTemplate(value) {
|
||||
return String(value || '')
|
||||
.replace(/\(\s*\)/g, '')
|
||||
.replace(/\[\s*]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
const source = String(template || DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE).trim()
|
||||
|| DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
|
||||
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
|
||||
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
|
||||
const rawValue = values?.[normalizedKey];
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
||||
return '';
|
||||
}
|
||||
return String(rawValue);
|
||||
});
|
||||
return cleanupRenderedTemplate(rendered);
|
||||
}
|
||||
|
||||
function buildTemplateValues(metadata = {}, format = null) {
|
||||
const author = sanitizeFileName(normalizeText(metadata.author || metadata.artist || 'Unknown Author'));
|
||||
const title = sanitizeFileName(normalizeText(metadata.title || metadata.album || 'Unknown Audiobook'));
|
||||
const narrator = sanitizeFileName(normalizeText(metadata.narrator || ''), 'unknown');
|
||||
const series = sanitizeFileName(normalizeText(metadata.series || ''), 'unknown');
|
||||
const part = sanitizeFileName(normalizeText(metadata.part || ''), 'unknown');
|
||||
const year = metadata.year ? String(metadata.year) : '';
|
||||
return {
|
||||
author,
|
||||
title,
|
||||
narrator: narrator === 'unknown' ? '' : narrator,
|
||||
series: series === 'unknown' ? '' : series,
|
||||
part: part === 'unknown' ? '' : part,
|
||||
year,
|
||||
format: format ? String(format).trim().toLowerCase() : ''
|
||||
};
|
||||
}
|
||||
|
||||
function splitRenderedPath(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.map((segment) => sanitizeFileName(segment))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveTemplatePathParts(template, values, fallbackBaseName) {
|
||||
const rendered = renderTemplate(template, values);
|
||||
const parts = splitRenderedPath(rendered);
|
||||
if (parts.length === 0) {
|
||||
return {
|
||||
folderParts: [],
|
||||
baseName: sanitizeFileName(fallbackBaseName || 'untitled')
|
||||
};
|
||||
}
|
||||
return {
|
||||
folderParts: parts.slice(0, -1),
|
||||
baseName: parts[parts.length - 1]
|
||||
};
|
||||
}
|
||||
|
||||
function buildRawStoragePaths(metadata, jobId, rawBaseDir, rawTemplate = DEFAULT_AUDIOBOOK_RAW_TEMPLATE, inputFileName = 'input.aax') {
|
||||
const ext = normalizeInputExtension(inputFileName) || '.aax';
|
||||
const values = buildTemplateValues(metadata);
|
||||
const fallbackBaseName = path.basename(String(inputFileName || 'input.aax'), ext);
|
||||
const { folderParts, baseName } = resolveTemplatePathParts(rawTemplate, values, fallbackBaseName);
|
||||
const rawDirName = `${baseName} - RAW - job-${jobId}`;
|
||||
const rawDir = path.join(String(rawBaseDir || ''), ...folderParts, rawDirName);
|
||||
const rawFilePath = path.join(rawDir, `${baseName}${ext}`);
|
||||
return {
|
||||
rawDir,
|
||||
rawFilePath,
|
||||
rawFileName: `${baseName}${ext}`,
|
||||
rawDirName
|
||||
};
|
||||
}
|
||||
|
||||
function buildOutputPath(metadata, movieBaseDir, outputTemplate = DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, outputFormat = 'mp3') {
|
||||
const normalizedFormat = normalizeOutputFormat(outputFormat);
|
||||
const values = buildTemplateValues(metadata, normalizedFormat);
|
||||
const fallbackBaseName = values.title || 'audiobook';
|
||||
const { folderParts, baseName } = resolveTemplatePathParts(outputTemplate, values, fallbackBaseName);
|
||||
return path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`);
|
||||
}
|
||||
|
||||
function buildProbeCommand(ffprobeCommand, inputPath) {
|
||||
const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe';
|
||||
return {
|
||||
cmd,
|
||||
args: [
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
'-show_chapters',
|
||||
inputPath
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3') {
|
||||
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']);
|
||||
return {
|
||||
cmd,
|
||||
args: ['-y', '-i', inputPath, ...codecArgs, outputPath]
|
||||
};
|
||||
}
|
||||
|
||||
function parseFfmpegTimestampToMs(rawValue) {
|
||||
const value = String(rawValue || '').trim();
|
||||
const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
const seconds = Number(match[3]);
|
||||
const fraction = match[4] ? Number(`0.${match[4]}`) : 0;
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) {
|
||||
return null;
|
||||
}
|
||||
return Math.round((((hours * 60) + minutes) * 60 + seconds + fraction) * 1000);
|
||||
}
|
||||
|
||||
function buildProgressParser(totalDurationMs) {
|
||||
const durationMs = Number(totalDurationMs || 0);
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (line) => {
|
||||
const match = String(line || '').match(/time=(\d+:\d{2}:\d{2}(?:\.\d+)?)/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const currentMs = parseFfmpegTimestampToMs(match[1]);
|
||||
if (!Number.isFinite(currentMs)) {
|
||||
return null;
|
||||
}
|
||||
const percent = Math.max(0, Math.min(100, Number(((currentMs / durationMs) * 100).toFixed(2))));
|
||||
return {
|
||||
percent,
|
||||
eta: null
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_INPUT_EXTENSIONS,
|
||||
SUPPORTED_OUTPUT_FORMATS,
|
||||
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
|
||||
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
||||
normalizeOutputFormat,
|
||||
isSupportedInputFile,
|
||||
buildMetadataFromProbe,
|
||||
buildRawStoragePaths,
|
||||
buildOutputPath,
|
||||
buildProbeCommand,
|
||||
parseProbeOutput,
|
||||
buildEncodeCommand,
|
||||
buildProgressParser
|
||||
};
|
||||
@@ -21,7 +21,7 @@ function parseJsonSafe(raw, fallback = null) {
|
||||
|
||||
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
||||
const processLogStreams = new Map();
|
||||
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'other'];
|
||||
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'other'];
|
||||
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
||||
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
||||
|
||||
@@ -183,6 +183,30 @@ function hasCdStructure(rawPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasAudiobookStructure(rawPath) {
|
||||
const basePath = String(rawPath || '').trim();
|
||||
if (!basePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(basePath)) {
|
||||
return false;
|
||||
}
|
||||
const stat = fs.statSync(basePath);
|
||||
if (stat.isFile()) {
|
||||
return path.extname(basePath).toLowerCase() === '.aax';
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
const entries = fs.readdirSync(basePath);
|
||||
return entries.some((entry) => path.extname(entry).toLowerCase() === '.aax');
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function detectOrphanMediaType(rawPath) {
|
||||
if (hasBlurayStructure(rawPath)) {
|
||||
return 'bluray';
|
||||
@@ -193,6 +217,9 @@ function detectOrphanMediaType(rawPath) {
|
||||
if (hasCdStructure(rawPath)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (hasAudiobookStructure(rawPath)) {
|
||||
return 'audiobook';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@@ -269,6 +296,9 @@ function normalizeMediaTypeValue(value) {
|
||||
if (raw === 'cd' || raw === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
||||
return 'audiobook';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -288,7 +318,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
|
||||
|| job?.mediaType
|
||||
);
|
||||
|
||||
if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd') {
|
||||
if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd' || profileHint === 'audiobook') {
|
||||
return profileHint;
|
||||
}
|
||||
|
||||
@@ -312,6 +342,15 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
|
||||
if (Array.isArray(mkInfo?.tracks) && mkInfo.tracks.length > 0) {
|
||||
return 'cd';
|
||||
}
|
||||
if (hasAudiobookStructure(rawPath) || hasAudiobookStructure(encodeInputPath)) {
|
||||
return 'audiobook';
|
||||
}
|
||||
if (String(hbInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
|
||||
return 'audiobook';
|
||||
}
|
||||
if (String(plan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||
return 'audiobook';
|
||||
}
|
||||
|
||||
if (hasBlurayStructure(rawPath)) {
|
||||
return 'bluray';
|
||||
@@ -1383,7 +1422,7 @@ class HistoryService {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir');
|
||||
if (rawDirs.length === 0) {
|
||||
const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,other}).');
|
||||
const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,cd,audiobook,other}).');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
@@ -1457,6 +1496,7 @@ class HistoryService {
|
||||
hasBlurayStructure: detectedMediaType === 'bluray',
|
||||
hasDvdStructure: detectedMediaType === 'dvd',
|
||||
hasCdStructure: detectedMediaType === 'cd',
|
||||
hasAudiobookStructure: detectedMediaType === 'audiobook',
|
||||
lastModifiedAt: stat.mtime.toISOString()
|
||||
});
|
||||
seenOrphanPaths.add(normalizedPath);
|
||||
@@ -1483,7 +1523,7 @@ class HistoryService {
|
||||
}
|
||||
|
||||
if (rawDirs.length === 0) {
|
||||
const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,other}).');
|
||||
const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,cd,audiobook,other}).');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { EventEmitter } = require('events');
|
||||
const { execFile } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const omdbService = require('./omdbService');
|
||||
const musicBrainzService = require('./musicBrainzService');
|
||||
const cdRipService = require('./cdRipService');
|
||||
const audiobookService = require('./audiobookService');
|
||||
const scriptService = require('./scriptService');
|
||||
const scriptChainService = require('./scriptChainService');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
@@ -249,11 +251,14 @@ function normalizeMediaProfile(value) {
|
||||
if (raw === 'cd' || raw === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
||||
return 'audiobook';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSpecificMediaProfile(value) {
|
||||
return value === 'bluray' || value === 'dvd' || value === 'cd';
|
||||
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook';
|
||||
}
|
||||
|
||||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||
@@ -365,6 +370,9 @@ function inferMediaProfileFromRawPath(rawPath) {
|
||||
try {
|
||||
const sourceStat = fs.statSync(source);
|
||||
if (sourceStat.isFile()) {
|
||||
if (path.extname(source).toLowerCase() === '.aax') {
|
||||
return 'audiobook';
|
||||
}
|
||||
if (isLikelyExtensionlessDvdImageFile(source, sourceStat.size)) {
|
||||
return 'dvd';
|
||||
}
|
||||
@@ -393,6 +401,15 @@ function inferMediaProfileFromRawPath(rawPath) {
|
||||
// ignore fs errors
|
||||
}
|
||||
|
||||
try {
|
||||
const audiobookFiles = findMediaFiles(source, ['.aax']);
|
||||
if (audiobookFiles.length > 0) {
|
||||
return 'audiobook';
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore fs errors
|
||||
}
|
||||
|
||||
if (listTopLevelExtensionlessDvdImages(source).length > 0) {
|
||||
return 'dvd';
|
||||
}
|
||||
@@ -624,6 +641,69 @@ function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFin
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudiobookMetadataForJob(job, makemkvInfo = null, encodePlan = null) {
|
||||
const mkInfo = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {};
|
||||
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
|
||||
const metadataSource = plan?.metadata && typeof plan.metadata === 'object'
|
||||
? plan.metadata
|
||||
: (
|
||||
mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object'
|
||||
? mkInfo.selectedMetadata
|
||||
: (mkInfo?.detectedMetadata && typeof mkInfo.detectedMetadata === 'object' ? mkInfo.detectedMetadata : {})
|
||||
);
|
||||
return {
|
||||
title: String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook',
|
||||
author: String(metadataSource?.author || metadataSource?.artist || '').trim() || null,
|
||||
narrator: String(metadataSource?.narrator || '').trim() || null,
|
||||
series: String(metadataSource?.series || '').trim() || null,
|
||||
part: String(metadataSource?.part || '').trim() || null,
|
||||
year: Number.isFinite(Number(metadataSource?.year))
|
||||
? Math.trunc(Number(metadataSource.year))
|
||||
: (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null),
|
||||
durationMs: Number.isFinite(Number(metadataSource?.durationMs))
|
||||
? Number(metadataSource.durationMs)
|
||||
: 0,
|
||||
chapters: Array.isArray(metadataSource?.chapters)
|
||||
? metadataSource.chapters
|
||||
: (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : [])
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePlan = null, fallbackJobId = null) {
|
||||
const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan);
|
||||
const movieDir = String(
|
||||
settings?.movie_dir
|
||||
|| settings?.raw_dir
|
||||
|| settingsService.DEFAULT_AUDIOBOOK_DIR
|
||||
|| settingsService.DEFAULT_AUDIOBOOK_RAW_DIR
|
||||
|| ''
|
||||
).trim();
|
||||
const outputTemplate = String(
|
||||
settings?.output_template
|
||||
|| audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE
|
||||
).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
|
||||
const outputFormat = audiobookService.normalizeOutputFormat(
|
||||
encodePlan?.format || settings?.output_extension || 'mp3'
|
||||
);
|
||||
const preferredFinalOutputPath = audiobookService.buildOutputPath(
|
||||
metadata,
|
||||
movieDir,
|
||||
outputTemplate,
|
||||
outputFormat
|
||||
);
|
||||
const numericJobId = Number(fallbackJobId || job?.id || 0);
|
||||
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
|
||||
? `Incomplete_job-${numericJobId}`
|
||||
: 'Incomplete_job-unknown';
|
||||
const incompleteOutputPath = path.join(movieDir, incompleteFolder, path.basename(preferredFinalOutputPath));
|
||||
return {
|
||||
metadata,
|
||||
outputFormat,
|
||||
preferredFinalOutputPath,
|
||||
incompleteOutputPath
|
||||
};
|
||||
}
|
||||
|
||||
function truncateLine(value, max = 180) {
|
||||
const raw = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (raw.length <= max) {
|
||||
@@ -3177,7 +3257,9 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP
|
||||
if (sourceStat.isFile()) {
|
||||
const ext = path.extname(sourcePath).toLowerCase();
|
||||
if (
|
||||
ext === '.mkv'
|
||||
ext === '.aax'
|
||||
|| ext === '.m4b'
|
||||
|| ext === '.mkv'
|
||||
|| ext === '.mp4'
|
||||
|| isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size)
|
||||
) {
|
||||
@@ -3206,11 +3288,11 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP
|
||||
};
|
||||
}
|
||||
|
||||
const primary = findMediaFiles(sourcePath, ['.mkv', '.mp4']);
|
||||
const primary = findMediaFiles(sourcePath, ['.aax', '.m4b', '.mkv', '.mp4']);
|
||||
if (primary.length > 0) {
|
||||
return {
|
||||
mediaFiles: primary,
|
||||
source: 'mkv'
|
||||
source: path.extname(primary[0]?.path || '').toLowerCase() === '.aax' ? 'audiobook' : 'mkv'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3700,6 +3782,8 @@ class PipelineService extends EventEmitter {
|
||||
const effectiveSettings = settingsService.resolveEffectiveToolSettings(sourceMap, normalizedMediaProfile);
|
||||
const preferredDefaultRawDir = normalizedMediaProfile === 'cd'
|
||||
? settingsService.DEFAULT_CD_DIR
|
||||
: normalizedMediaProfile === 'audiobook'
|
||||
? settingsService.DEFAULT_AUDIOBOOK_RAW_DIR
|
||||
: settingsService.DEFAULT_RAW_DIR;
|
||||
const uniqueRawDirs = Array.from(
|
||||
new Set(
|
||||
@@ -3709,9 +3793,11 @@ class PipelineService extends EventEmitter {
|
||||
sourceMap?.raw_dir_bluray,
|
||||
sourceMap?.raw_dir_dvd,
|
||||
sourceMap?.raw_dir_cd,
|
||||
sourceMap?.raw_dir_audiobook,
|
||||
preferredDefaultRawDir,
|
||||
settingsService.DEFAULT_RAW_DIR,
|
||||
settingsService.DEFAULT_CD_DIR
|
||||
settingsService.DEFAULT_CD_DIR,
|
||||
settingsService.DEFAULT_AUDIOBOOK_RAW_DIR
|
||||
]
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
@@ -3741,7 +3827,9 @@ class PipelineService extends EventEmitter {
|
||||
settings?.raw_dir_bluray,
|
||||
settings?.raw_dir_dvd,
|
||||
settings?.raw_dir_cd,
|
||||
settingsService.DEFAULT_CD_DIR
|
||||
settings?.raw_dir_audiobook,
|
||||
settingsService.DEFAULT_CD_DIR,
|
||||
settingsService.DEFAULT_AUDIOBOOK_RAW_DIR
|
||||
].map((d) => String(d || '').trim()).filter(Boolean);
|
||||
const allRawDirs = [rawBaseDir, settingsService.DEFAULT_RAW_DIR, ...rawExtraDirs]
|
||||
.filter((d, i, arr) => arr.indexOf(d) === i && d && fs.existsSync(d));
|
||||
@@ -5331,6 +5419,29 @@ class PipelineService extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
async runCapturedCommand(cmd, args = []) {
|
||||
const command = String(cmd || '').trim();
|
||||
const argv = Array.isArray(args) ? args.map((item) => String(item)) : [];
|
||||
if (!command) {
|
||||
throw new Error('Kommando fehlt.');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, argv, { maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
stdout: String(stdout || ''),
|
||||
stderr: String(stderr || '')
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async ensureMakeMKVRegistration(jobId, stage) {
|
||||
const registrationConfig = await settingsService.buildMakeMKVRegisterConfig();
|
||||
if (!registrationConfig) {
|
||||
@@ -7021,6 +7132,16 @@ class PipelineService extends EventEmitter {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json);
|
||||
const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
|
||||
const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, {
|
||||
makemkvInfo: preloadedMakemkvInfo,
|
||||
encodePlan: preloadedEncodePlan
|
||||
});
|
||||
if (preloadedMediaProfile === 'audiobook') {
|
||||
return this.startAudiobookEncode(jobId, { ...options, preloadedJob });
|
||||
}
|
||||
|
||||
const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
|
||||
if (isReadyToEncode) {
|
||||
// Check whether this confirmed job will rip first (pre_rip mode) or encode directly.
|
||||
@@ -7061,10 +7182,6 @@ class PipelineService extends EventEmitter {
|
||||
return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob });
|
||||
}
|
||||
|
||||
this.ensureNotBusy('startPreparedJob', jobId);
|
||||
logger.info('startPreparedJob:requested', { jobId });
|
||||
this.cancelRequestedByJob.delete(Number(jobId));
|
||||
|
||||
const job = options?.preloadedJob || await historyService.getJobById(jobId);
|
||||
if (!job) {
|
||||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||||
@@ -7072,6 +7189,20 @@ class PipelineService extends EventEmitter {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json);
|
||||
const jobEncodePlan = this.safeParseJson(job.encode_plan_json);
|
||||
const jobMediaProfile = this.resolveMediaProfileForJob(job, {
|
||||
makemkvInfo: jobMakemkvInfo,
|
||||
encodePlan: jobEncodePlan
|
||||
});
|
||||
if (jobMediaProfile === 'audiobook') {
|
||||
return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job });
|
||||
}
|
||||
|
||||
this.ensureNotBusy('startPreparedJob', jobId);
|
||||
logger.info('startPreparedJob:requested', { jobId });
|
||||
this.cancelRequestedByJob.delete(Number(jobId));
|
||||
|
||||
if (!job.title && !job.detected_title) {
|
||||
const error = new Error('Start nicht möglich: keine Metadaten vorhanden.');
|
||||
error.statusCode = 400;
|
||||
@@ -7489,6 +7620,63 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
|
||||
const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
||||
const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
|
||||
const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, {
|
||||
makemkvInfo: mkInfo,
|
||||
encodePlan: sourceEncodePlan,
|
||||
rawPath: sourceJob.raw_path
|
||||
});
|
||||
if (reencodeMediaProfile === 'audiobook') {
|
||||
const reencodeSettings = await settingsService.getSettingsMap();
|
||||
const resolvedAudiobookRawPath = this.resolveCurrentRawPathForSettings(
|
||||
reencodeSettings,
|
||||
reencodeMediaProfile,
|
||||
sourceJob.raw_path
|
||||
);
|
||||
if (!resolvedAudiobookRawPath) {
|
||||
const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rawInput = findPreferredRawInput(resolvedAudiobookRawPath);
|
||||
if (!rawInput) {
|
||||
const error = new Error('Re-Encode nicht möglich: keine AAX-Datei im RAW-Pfad gefunden.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const refreshedPlan = {
|
||||
...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}),
|
||||
mediaProfile: 'audiobook',
|
||||
mode: 'audiobook',
|
||||
encodeInputPath: rawInput.path
|
||||
};
|
||||
|
||||
await historyService.resetProcessLog(sourceJobId);
|
||||
await historyService.updateJob(sourceJobId, {
|
||||
status: 'READY_TO_START',
|
||||
last_state: 'READY_TO_START',
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
error_message: null,
|
||||
output_path: null,
|
||||
handbrake_info_json: null,
|
||||
mediainfo_info_json: null,
|
||||
encode_plan_json: JSON.stringify(refreshedPlan),
|
||||
encode_input_path: rawInput.path,
|
||||
encode_review_confirmed: 1,
|
||||
raw_path: resolvedAudiobookRawPath
|
||||
});
|
||||
await historyService.appendLog(
|
||||
sourceJobId,
|
||||
'USER_ACTION',
|
||||
`Audiobook-Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input: ${rawInput.path}`
|
||||
);
|
||||
|
||||
return this.startPreparedJob(sourceJobId);
|
||||
}
|
||||
|
||||
const ripSuccessful = this.isRipSuccessful(sourceJob);
|
||||
if (!ripSuccessful) {
|
||||
const error = new Error(
|
||||
@@ -7497,11 +7685,6 @@ class PipelineService extends EventEmitter {
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, {
|
||||
makemkvInfo: mkInfo,
|
||||
rawPath: sourceJob.raw_path
|
||||
});
|
||||
const reencodeSettings = await settingsService.getSettingsMap();
|
||||
const resolvedReencodeRawPath = this.resolveCurrentRawPathForSettings(
|
||||
reencodeSettings,
|
||||
@@ -9226,6 +9409,7 @@ class PipelineService extends EventEmitter {
|
||||
encodePlan: sourceEncodePlan
|
||||
});
|
||||
const isCdRetry = mediaProfile === 'cd';
|
||||
const isAudiobookRetry = mediaProfile === 'audiobook';
|
||||
|
||||
let cdRetryConfig = null;
|
||||
if (isCdRetry) {
|
||||
@@ -9283,7 +9467,7 @@ class PipelineService extends EventEmitter {
|
||||
selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []),
|
||||
selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
|
||||
};
|
||||
} else {
|
||||
} else if (!isAudiobookRetry) {
|
||||
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
|
||||
retrySettings,
|
||||
@@ -9353,7 +9537,7 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
const retryJob = await historyService.createJob({
|
||||
discDevice: sourceJob.disc_device || null,
|
||||
status: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING',
|
||||
status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'),
|
||||
detectedTitle: sourceJob.detected_title || sourceJob.title || null
|
||||
});
|
||||
const retryJobId = Number(retryJob?.id || 0);
|
||||
@@ -9370,19 +9554,27 @@ class PipelineService extends EventEmitter {
|
||||
omdb_json: sourceJob.omdb_json || null,
|
||||
selected_from_omdb: Number(sourceJob.selected_from_omdb || 0),
|
||||
makemkv_info_json: sourceJob.makemkv_info_json || null,
|
||||
rip_successful: 0,
|
||||
rip_successful: isAudiobookRetry ? 1 : 0,
|
||||
error_message: null,
|
||||
end_time: null,
|
||||
handbrake_info_json: null,
|
||||
mediainfo_info_json: null,
|
||||
encode_plan_json: isCdRetry
|
||||
encode_plan_json: (isCdRetry || isAudiobookRetry)
|
||||
? (sourceJob.encode_plan_json || null)
|
||||
: null,
|
||||
encode_input_path: null,
|
||||
encode_review_confirmed: 0,
|
||||
encode_input_path: isAudiobookRetry
|
||||
? (
|
||||
sourceJob.encode_input_path
|
||||
|| sourceEncodePlan?.encodeInputPath
|
||||
|| sourceMakemkvInfo?.rawFilePath
|
||||
|| null
|
||||
)
|
||||
: null,
|
||||
encode_review_confirmed: isAudiobookRetry ? 1 : 0,
|
||||
output_path: null,
|
||||
status: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING',
|
||||
last_state: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING'
|
||||
raw_path: isAudiobookRetry ? (sourceJob.raw_path || null) : null,
|
||||
status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'),
|
||||
last_state: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING')
|
||||
};
|
||||
await historyService.updateJob(retryJobId, retryUpdatePayload);
|
||||
|
||||
@@ -9397,10 +9589,10 @@ class PipelineService extends EventEmitter {
|
||||
await historyService.appendLog(
|
||||
retryJobId,
|
||||
'USER_ACTION',
|
||||
`Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : 'Disc'}).`
|
||||
`Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : 'Disc')}).`
|
||||
);
|
||||
await historyService.retireJobInFavorOf(jobId, retryJobId, {
|
||||
reason: isCdRetry ? 'cd_retry' : 'retry'
|
||||
reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : 'retry')
|
||||
});
|
||||
this.cancelRequestedByJob.delete(retryJobId);
|
||||
|
||||
@@ -9412,6 +9604,14 @@ class PipelineService extends EventEmitter {
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
});
|
||||
} else if (isAudiobookRetry) {
|
||||
const startResult = await this.startPreparedJob(retryJobId);
|
||||
return {
|
||||
sourceJobId: Number(jobId),
|
||||
jobId: retryJobId,
|
||||
replacedSourceJob: true,
|
||||
...(startResult && typeof startResult === 'object' ? startResult : {})
|
||||
};
|
||||
} else {
|
||||
this.startRipEncode(retryJobId).catch((error) => {
|
||||
logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) });
|
||||
@@ -10545,7 +10745,7 @@ class PipelineService extends EventEmitter {
|
||||
cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null
|
||||
} : {}),
|
||||
canRestartEncodeFromLastSettings: hasConfirmedPlan,
|
||||
canRestartReviewFromRaw: hasRawPath
|
||||
canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath
|
||||
}
|
||||
});
|
||||
this.cancelRequestedByJob.delete(Number(jobId));
|
||||
@@ -10556,6 +10756,410 @@ class PipelineService extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
async uploadAudiobookFile(file, options = {}) {
|
||||
const tempFilePath = String(file?.path || '').trim();
|
||||
const originalName = String(file?.originalname || file?.originalName || '').trim()
|
||||
|| path.basename(tempFilePath || 'upload.aax');
|
||||
const detectedTitle = path.basename(originalName, path.extname(originalName)) || 'Audiobook';
|
||||
const requestedFormat = String(options?.format || '').trim().toLowerCase() || null;
|
||||
const startImmediately = options?.startImmediately === undefined
|
||||
? true
|
||||
: !['0', 'false', 'no', 'off'].includes(String(options.startImmediately).trim().toLowerCase());
|
||||
|
||||
if (!tempFilePath || !fs.existsSync(tempFilePath)) {
|
||||
const error = new Error('Upload-Datei fehlt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!audiobookService.isSupportedInputFile(originalName)) {
|
||||
const error = new Error('Nur AAX-Dateien werden für Audiobooks unterstützt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const settings = await settingsService.getEffectiveSettingsMap('audiobook');
|
||||
const rawBaseDir = String(
|
||||
settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR || settingsService.DEFAULT_RAW_DIR || ''
|
||||
).trim();
|
||||
const rawTemplate = String(
|
||||
settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE
|
||||
).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE;
|
||||
const outputTemplate = String(
|
||||
settings?.output_template || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE
|
||||
).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
|
||||
const outputFormat = audiobookService.normalizeOutputFormat(
|
||||
requestedFormat || settings?.output_extension || 'mp3'
|
||||
);
|
||||
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||
|
||||
const job = await historyService.createJob({
|
||||
discDevice: null,
|
||||
status: 'ANALYZING',
|
||||
detectedTitle
|
||||
});
|
||||
|
||||
let stagedRawDir = null;
|
||||
let stagedRawFilePath = null;
|
||||
|
||||
try {
|
||||
await historyService.resetProcessLog(job.id);
|
||||
await historyService.appendLog(job.id, 'SYSTEM', `AAX-Upload empfangen: ${originalName}`);
|
||||
|
||||
const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, tempFilePath);
|
||||
const captured = await this.runCapturedCommand(probeConfig.cmd, probeConfig.args);
|
||||
const probe = audiobookService.parseProbeOutput(captured.stdout);
|
||||
if (!probe) {
|
||||
const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden.');
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const metadata = audiobookService.buildMetadataFromProbe(probe, originalName);
|
||||
const storagePaths = audiobookService.buildRawStoragePaths(
|
||||
metadata,
|
||||
job.id,
|
||||
rawBaseDir,
|
||||
rawTemplate,
|
||||
originalName
|
||||
);
|
||||
|
||||
ensureDir(storagePaths.rawDir);
|
||||
fs.renameSync(tempFilePath, storagePaths.rawFilePath);
|
||||
stagedRawDir = storagePaths.rawDir;
|
||||
stagedRawFilePath = storagePaths.rawFilePath;
|
||||
chownRecursive(storagePaths.rawDir, settings?.raw_dir_owner);
|
||||
|
||||
const makemkvInfo = this.withAnalyzeContextMediaProfile({
|
||||
status: 'SUCCESS',
|
||||
source: 'aax_upload',
|
||||
importedAt: nowIso(),
|
||||
mediaProfile: 'audiobook',
|
||||
rawFileName: storagePaths.rawFileName,
|
||||
rawFilePath: storagePaths.rawFilePath,
|
||||
chapters: metadata.chapters,
|
||||
detectedMetadata: metadata,
|
||||
selectedMetadata: metadata,
|
||||
probeSummary: {
|
||||
durationMs: metadata.durationMs,
|
||||
tagKeys: Object.keys(metadata.tags || {})
|
||||
}
|
||||
}, 'audiobook');
|
||||
|
||||
const encodePlan = {
|
||||
mediaProfile: 'audiobook',
|
||||
mode: 'audiobook',
|
||||
sourceType: 'upload',
|
||||
uploadedAt: nowIso(),
|
||||
format: outputFormat,
|
||||
rawTemplate,
|
||||
outputTemplate,
|
||||
encodeInputPath: storagePaths.rawFilePath,
|
||||
metadata,
|
||||
reviewConfirmed: true
|
||||
};
|
||||
|
||||
await historyService.updateJob(job.id, {
|
||||
status: 'READY_TO_START',
|
||||
last_state: 'READY_TO_START',
|
||||
title: metadata.title || detectedTitle,
|
||||
detected_title: metadata.title || detectedTitle,
|
||||
year: metadata.year ?? null,
|
||||
raw_path: storagePaths.rawDir,
|
||||
rip_successful: 1,
|
||||
makemkv_info_json: JSON.stringify(makemkvInfo),
|
||||
handbrake_info_json: null,
|
||||
mediainfo_info_json: null,
|
||||
encode_plan_json: JSON.stringify(encodePlan),
|
||||
encode_input_path: storagePaths.rawFilePath,
|
||||
encode_review_confirmed: 1,
|
||||
output_path: null,
|
||||
error_message: null,
|
||||
start_time: null,
|
||||
end_time: null
|
||||
});
|
||||
|
||||
await historyService.appendLog(
|
||||
job.id,
|
||||
'SYSTEM',
|
||||
`Audiobook analysiert: ${metadata.title || detectedTitle} | Autor: ${metadata.author || '-'} | Format: ${outputFormat.toUpperCase()}`
|
||||
);
|
||||
|
||||
if (!startImmediately) {
|
||||
return {
|
||||
jobId: job.id,
|
||||
started: false,
|
||||
queued: false,
|
||||
stage: 'READY_TO_START'
|
||||
};
|
||||
}
|
||||
|
||||
const startResult = await this.startPreparedJob(job.id);
|
||||
return {
|
||||
jobId: job.id,
|
||||
...(startResult && typeof startResult === 'object' ? startResult : {})
|
||||
};
|
||||
} catch (error) {
|
||||
const updatePayload = {
|
||||
status: 'ERROR',
|
||||
last_state: 'ERROR',
|
||||
end_time: nowIso(),
|
||||
error_message: error?.message || 'Audiobook-Upload fehlgeschlagen.'
|
||||
};
|
||||
if (stagedRawDir) {
|
||||
updatePayload.raw_path = stagedRawDir;
|
||||
}
|
||||
if (stagedRawFilePath) {
|
||||
updatePayload.encode_input_path = stagedRawFilePath;
|
||||
}
|
||||
await historyService.updateJob(job.id, updatePayload).catch(() => {});
|
||||
await historyService.appendLog(
|
||||
job.id,
|
||||
'SYSTEM',
|
||||
`Audiobook-Upload fehlgeschlagen: ${error?.message || 'unknown'}`
|
||||
).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
if (tempFilePath && fs.existsSync(tempFilePath)) {
|
||||
try {
|
||||
fs.rmSync(tempFilePath, { force: true });
|
||||
} catch (_error) {
|
||||
// best effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startAudiobookEncode(jobId, options = {}) {
|
||||
const immediate = Boolean(options?.immediate);
|
||||
if (!immediate) {
|
||||
return this.enqueueOrStartAction(
|
||||
QUEUE_ACTIONS.START_PREPARED,
|
||||
jobId,
|
||||
() => this.startAudiobookEncode(jobId, { ...options, immediate: true })
|
||||
);
|
||||
}
|
||||
|
||||
this.ensureNotBusy('startAudiobookEncode', jobId);
|
||||
logger.info('audiobook:encode:start', { jobId });
|
||||
this.cancelRequestedByJob.delete(Number(jobId));
|
||||
|
||||
const job = options?.preloadedJob || await historyService.getJobById(jobId);
|
||||
if (!job) {
|
||||
const error = new Error(`Job ${jobId} 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 ${jobId} ist kein Audiobook-Job.`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const settings = await settingsService.getEffectiveSettingsMap('audiobook');
|
||||
const resolvedRawPath = this.resolveCurrentRawPathForSettings(
|
||||
settings,
|
||||
'audiobook',
|
||||
job.raw_path
|
||||
) || String(job.raw_path || '').trim() || null;
|
||||
|
||||
let inputPath = String(
|
||||
job.encode_input_path
|
||||
|| encodePlan?.encodeInputPath
|
||||
|| makemkvInfo?.rawFilePath
|
||||
|| ''
|
||||
).trim();
|
||||
|
||||
if ((!inputPath || !fs.existsSync(inputPath)) && resolvedRawPath) {
|
||||
inputPath = findPreferredRawInput(resolvedRawPath)?.path || '';
|
||||
}
|
||||
|
||||
if (!inputPath) {
|
||||
const error = new Error('Audiobook-Encode nicht möglich: keine Input-Datei gefunden.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
const error = new Error(`Audiobook-Encode nicht möglich: Input-Datei fehlt (${inputPath}).`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
metadata,
|
||||
outputFormat,
|
||||
preferredFinalOutputPath,
|
||||
incompleteOutputPath
|
||||
} = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId);
|
||||
ensureDir(path.dirname(incompleteOutputPath));
|
||||
|
||||
await historyService.resetProcessLog(jobId);
|
||||
await this.setState('ENCODING', {
|
||||
activeJobId: jobId,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: `Audiobook-Encoding (${outputFormat.toUpperCase()})`,
|
||||
context: {
|
||||
jobId,
|
||||
mode: 'audiobook',
|
||||
mediaProfile: 'audiobook',
|
||||
inputPath,
|
||||
outputPath: incompleteOutputPath,
|
||||
format: outputFormat,
|
||||
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,
|
||||
poster: job.poster_url || null
|
||||
},
|
||||
canRestartEncodeFromLastSettings: false,
|
||||
canRestartReviewFromRaw: false
|
||||
}
|
||||
});
|
||||
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'ENCODING',
|
||||
last_state: 'ENCODING',
|
||||
start_time: nowIso(),
|
||||
end_time: null,
|
||||
error_message: null,
|
||||
raw_path: resolvedRawPath || job.raw_path || null,
|
||||
output_path: incompleteOutputPath,
|
||||
encode_input_path: inputPath
|
||||
});
|
||||
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}`
|
||||
);
|
||||
|
||||
void this.notifyPushover('encoding_started', {
|
||||
title: 'Ripster - Audiobook-Encoding gestartet',
|
||||
message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
||||
});
|
||||
|
||||
try {
|
||||
const ffmpegConfig = audiobookService.buildEncodeCommand(
|
||||
settings?.ffmpeg_command || 'ffmpeg',
|
||||
inputPath,
|
||||
incompleteOutputPath,
|
||||
outputFormat
|
||||
);
|
||||
logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
|
||||
const ffmpegRunInfo = await this.runCommand({
|
||||
jobId,
|
||||
stage: 'ENCODING',
|
||||
source: 'FFMPEG',
|
||||
cmd: ffmpegConfig.cmd,
|
||||
args: ffmpegConfig.args,
|
||||
parser: audiobookService.buildProgressParser(metadata.durationMs)
|
||||
});
|
||||
|
||||
const outputFinalization = finalizeOutputPathForCompletedEncode(
|
||||
incompleteOutputPath,
|
||||
preferredFinalOutputPath
|
||||
);
|
||||
const finalizedOutputPath = outputFinalization.outputPath;
|
||||
chownRecursive(path.dirname(finalizedOutputPath), settings?.movie_dir_owner);
|
||||
|
||||
if (outputFinalization.outputPathWithTimestamp) {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Finaler Audiobook-Output existierte bereits. Zielpfad mit Timestamp verwendet: ${finalizedOutputPath}`
|
||||
);
|
||||
}
|
||||
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Audiobook-Output finalisiert: ${finalizedOutputPath}`
|
||||
);
|
||||
|
||||
const ffmpegInfo = {
|
||||
...ffmpegRunInfo,
|
||||
mode: 'audiobook_encode',
|
||||
format: outputFormat,
|
||||
metadata,
|
||||
inputPath,
|
||||
outputPath: finalizedOutputPath
|
||||
};
|
||||
|
||||
await historyService.updateJob(jobId, {
|
||||
handbrake_info_json: JSON.stringify(ffmpegInfo),
|
||||
status: 'FINISHED',
|
||||
last_state: 'FINISHED',
|
||||
end_time: nowIso(),
|
||||
rip_successful: 1,
|
||||
raw_path: resolvedRawPath || job.raw_path || null,
|
||||
output_path: finalizedOutputPath,
|
||||
error_message: null
|
||||
});
|
||||
|
||||
await this.setState('FINISHED', {
|
||||
activeJobId: jobId,
|
||||
progress: 100,
|
||||
eta: null,
|
||||
statusText: 'Audiobook abgeschlossen',
|
||||
context: {
|
||||
jobId,
|
||||
mode: 'audiobook',
|
||||
mediaProfile: 'audiobook',
|
||||
outputPath: finalizedOutputPath
|
||||
}
|
||||
});
|
||||
|
||||
void this.notifyPushover('job_finished', {
|
||||
title: 'Ripster - Audiobook abgeschlossen',
|
||||
message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}`
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) {
|
||||
await this.setState('IDLE', {
|
||||
finishingJobId: jobId,
|
||||
activeJobId: null,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: 'Bereit',
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return {
|
||||
started: true,
|
||||
stage: 'ENCODING',
|
||||
outputPath: finalizedOutputPath
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.runInfo && error.runInfo.source === 'FFMPEG') {
|
||||
await historyService.updateJob(jobId, {
|
||||
handbrake_info_json: JSON.stringify({
|
||||
...error.runInfo,
|
||||
mode: 'audiobook_encode',
|
||||
format: outputFormat,
|
||||
inputPath
|
||||
})
|
||||
}).catch(() => {});
|
||||
}
|
||||
logger.error('audiobook:encode:failed', { jobId, error: errorToMeta(error) });
|
||||
await this.failJob(jobId, 'ENCODING', error);
|
||||
error.jobAlreadyFailed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CD Pipeline ─────────────────────────────────────────────────────────────
|
||||
|
||||
async analyzeCd(device) {
|
||||
@@ -10775,24 +11379,54 @@ class PipelineService extends EventEmitter {
|
||||
const renamed = [];
|
||||
const mediaProfile = this.resolveMediaProfileForJob(job);
|
||||
const isCd = mediaProfile === 'cd';
|
||||
const isAudiobook = mediaProfile === 'audiobook';
|
||||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||
const mkInfo = this.safeParseJson(job.makemkv_info_json) || {};
|
||||
const encodePlan = this.safeParseJson(job.encode_plan_json) || {};
|
||||
|
||||
// Rename raw folder
|
||||
const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null;
|
||||
if (currentRawPath && fs.existsSync(currentRawPath)) {
|
||||
const rawBaseDir = path.dirname(currentRawPath);
|
||||
const newMetadataBase = buildRawMetadataBase({
|
||||
title: job.title || job.detected_title || null,
|
||||
year: job.year || null
|
||||
}, jobId);
|
||||
const currentState = resolveRawFolderStateFromPath(currentRawPath);
|
||||
const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState });
|
||||
const newRawPath = path.join(rawBaseDir, newRawDirName);
|
||||
let newRawPath;
|
||||
if (isAudiobook) {
|
||||
const audiobookMeta = buildAudiobookMetadataForJob(job, mkInfo, encodePlan);
|
||||
const rawTemplate = String(
|
||||
settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE
|
||||
).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE;
|
||||
const currentInputPath = findPreferredRawInput(currentRawPath)?.path
|
||||
|| String(job.encode_input_path || mkInfo?.rawFilePath || '').trim()
|
||||
|| 'input.aax';
|
||||
const nextRaw = audiobookService.buildRawStoragePaths(
|
||||
audiobookMeta,
|
||||
jobId,
|
||||
settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
rawTemplate,
|
||||
path.basename(currentInputPath)
|
||||
);
|
||||
newRawPath = nextRaw.rawDir;
|
||||
} else {
|
||||
const rawBaseDir = path.dirname(currentRawPath);
|
||||
const newMetadataBase = buildRawMetadataBase({
|
||||
title: job.title || job.detected_title || null,
|
||||
year: job.year || null
|
||||
}, jobId);
|
||||
const currentState = resolveRawFolderStateFromPath(currentRawPath);
|
||||
const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState });
|
||||
newRawPath = path.join(rawBaseDir, newRawDirName);
|
||||
}
|
||||
|
||||
if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(newRawPath) && !fs.existsSync(newRawPath)) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(newRawPath), { recursive: true });
|
||||
fs.renameSync(currentRawPath, newRawPath);
|
||||
await historyService.updateJob(jobId, { raw_path: newRawPath });
|
||||
const updatePayload = { raw_path: newRawPath };
|
||||
if (isAudiobook) {
|
||||
const previousInputPath = String(job.encode_input_path || mkInfo?.rawFilePath || '').trim();
|
||||
if (previousInputPath && previousInputPath.startsWith(`${currentRawPath}${path.sep}`)) {
|
||||
updatePayload.encode_input_path = path.join(newRawPath, path.basename(previousInputPath));
|
||||
}
|
||||
}
|
||||
await historyService.updateJob(jobId, updatePayload);
|
||||
renamed.push({ type: 'raw', from: currentRawPath, to: newRawPath });
|
||||
logger.info('rename-job-folders:raw', { jobId, from: currentRawPath, to: newRawPath });
|
||||
} catch (err) {
|
||||
@@ -10828,7 +11462,9 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newOutputPath = buildFinalOutputPathFromJob(settings, job, jobId);
|
||||
const newOutputPath = isAudiobook
|
||||
? buildAudiobookOutputConfig(settings, job, mkInfo, encodePlan, jobId).preferredFinalOutputPath
|
||||
: buildFinalOutputPathFromJob(settings, job, jobId);
|
||||
if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newOutputPath) && !fs.existsSync(newOutputPath)) {
|
||||
fs.mkdirSync(path.dirname(newOutputPath), { recursive: true });
|
||||
moveFileWithFallback(currentOutputPath, newOutputPath);
|
||||
@@ -10840,7 +11476,11 @@ class PipelineService extends EventEmitter {
|
||||
} catch (_ignoreErr) {}
|
||||
await historyService.updateJob(jobId, { output_path: newOutputPath });
|
||||
renamed.push({ type: 'output', from: currentOutputPath, to: newOutputPath });
|
||||
logger.info('rename-job-folders:film-output', { jobId, from: currentOutputPath, to: newOutputPath });
|
||||
logger.info(isAudiobook ? 'rename-job-folders:audiobook-output' : 'rename-job-folders:film-output', {
|
||||
jobId,
|
||||
from: currentOutputPath,
|
||||
to: newOutputPath
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -13,7 +13,13 @@ const {
|
||||
const { splitArgs } = require('../utils/commandLine');
|
||||
const { setLogRootDir } = require('./logPathService');
|
||||
|
||||
const { defaultRawDir: DEFAULT_RAW_DIR, defaultMovieDir: DEFAULT_MOVIE_DIR, defaultCdDir: DEFAULT_CD_DIR } = require('../config');
|
||||
const {
|
||||
defaultRawDir: DEFAULT_RAW_DIR,
|
||||
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
||||
defaultCdDir: DEFAULT_CD_DIR,
|
||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR
|
||||
} = require('../config');
|
||||
|
||||
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||
const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000;
|
||||
@@ -38,27 +44,31 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
|
||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd'];
|
||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook'];
|
||||
const PROFILED_SETTINGS = {
|
||||
raw_dir: {
|
||||
bluray: 'raw_dir_bluray',
|
||||
dvd: 'raw_dir_dvd',
|
||||
cd: 'raw_dir_cd'
|
||||
cd: 'raw_dir_cd',
|
||||
audiobook: 'raw_dir_audiobook'
|
||||
},
|
||||
raw_dir_owner: {
|
||||
bluray: 'raw_dir_bluray_owner',
|
||||
dvd: 'raw_dir_dvd_owner',
|
||||
cd: 'raw_dir_cd_owner'
|
||||
cd: 'raw_dir_cd_owner',
|
||||
audiobook: 'raw_dir_audiobook_owner'
|
||||
},
|
||||
movie_dir: {
|
||||
bluray: 'movie_dir_bluray',
|
||||
dvd: 'movie_dir_dvd',
|
||||
cd: 'movie_dir_cd'
|
||||
cd: 'movie_dir_cd',
|
||||
audiobook: 'movie_dir_audiobook'
|
||||
},
|
||||
movie_dir_owner: {
|
||||
bluray: 'movie_dir_bluray_owner',
|
||||
dvd: 'movie_dir_dvd_owner',
|
||||
cd: 'movie_dir_cd_owner'
|
||||
cd: 'movie_dir_cd_owner',
|
||||
audiobook: 'movie_dir_audiobook_owner'
|
||||
},
|
||||
mediainfo_extra_args: {
|
||||
bluray: 'mediainfo_extra_args_bluray',
|
||||
@@ -86,11 +96,13 @@ const PROFILED_SETTINGS = {
|
||||
},
|
||||
output_extension: {
|
||||
bluray: 'output_extension_bluray',
|
||||
dvd: 'output_extension_dvd'
|
||||
dvd: 'output_extension_dvd',
|
||||
audiobook: 'output_extension_audiobook'
|
||||
},
|
||||
output_template: {
|
||||
bluray: 'output_template_bluray',
|
||||
dvd: 'output_template_dvd'
|
||||
dvd: 'output_template_dvd',
|
||||
audiobook: 'output_template_audiobook'
|
||||
}
|
||||
};
|
||||
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||
@@ -372,11 +384,17 @@ function normalizeMediaProfileValue(value) {
|
||||
if (raw === 'cd' || raw === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
||||
return 'audiobook';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProfileFallbackOrder(profile) {
|
||||
const normalized = normalizeMediaProfileValue(profile);
|
||||
if (normalized === 'audiobook') {
|
||||
return ['audiobook'];
|
||||
}
|
||||
if (normalized === 'bluray') {
|
||||
return ['bluray', 'dvd'];
|
||||
}
|
||||
@@ -690,9 +708,21 @@ class SettingsService {
|
||||
// Fallback to hardcoded install defaults when no setting value is configured
|
||||
if (!hasUsableProfileSpecificValue(resolvedValue)) {
|
||||
if (legacyKey === 'raw_dir') {
|
||||
resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR;
|
||||
if (normalizedRequestedProfile === 'cd') {
|
||||
resolvedValue = DEFAULT_CD_DIR;
|
||||
} else if (normalizedRequestedProfile === 'audiobook') {
|
||||
resolvedValue = DEFAULT_AUDIOBOOK_RAW_DIR;
|
||||
} else {
|
||||
resolvedValue = DEFAULT_RAW_DIR;
|
||||
}
|
||||
} else if (legacyKey === 'movie_dir') {
|
||||
resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_MOVIE_DIR;
|
||||
if (normalizedRequestedProfile === 'cd') {
|
||||
resolvedValue = DEFAULT_CD_DIR;
|
||||
} else if (normalizedRequestedProfile === 'audiobook') {
|
||||
resolvedValue = DEFAULT_AUDIOBOOK_DIR;
|
||||
} else {
|
||||
resolvedValue = DEFAULT_MOVIE_DIR;
|
||||
}
|
||||
}
|
||||
}
|
||||
effective[legacyKey] = resolvedValue;
|
||||
@@ -724,14 +754,18 @@ class SettingsService {
|
||||
const bluray = this.resolveEffectiveToolSettings(map, 'bluray');
|
||||
const dvd = this.resolveEffectiveToolSettings(map, 'dvd');
|
||||
const cd = this.resolveEffectiveToolSettings(map, 'cd');
|
||||
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
|
||||
return {
|
||||
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||
defaults: {
|
||||
raw: DEFAULT_RAW_DIR,
|
||||
movies: DEFAULT_MOVIE_DIR,
|
||||
cd: DEFAULT_CD_DIR
|
||||
cd: DEFAULT_CD_DIR,
|
||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1480,4 +1514,6 @@ const settingsServiceInstance = new SettingsService();
|
||||
settingsServiceInstance.DEFAULT_RAW_DIR = DEFAULT_RAW_DIR;
|
||||
settingsServiceInstance.DEFAULT_MOVIE_DIR = DEFAULT_MOVIE_DIR;
|
||||
settingsServiceInstance.DEFAULT_CD_DIR = DEFAULT_CD_DIR;
|
||||
settingsServiceInstance.DEFAULT_AUDIOBOOK_RAW_DIR = DEFAULT_AUDIOBOOK_RAW_DIR;
|
||||
settingsServiceInstance.DEFAULT_AUDIOBOOK_DIR = DEFAULT_AUDIOBOOK_DIR;
|
||||
module.exports = settingsServiceInstance;
|
||||
|
||||
Reference in New Issue
Block a user