Compare commits
2 Commits
5d83d02d9f
...
e56cff43a9
| Author | SHA1 | Date | |
|---|---|---|---|
| e56cff43a9 | |||
| 5d79a34905 |
@@ -80,12 +80,5 @@ Thumbs.db
|
|||||||
# Scripts
|
# Scripts
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
/scripts/
|
/scripts/
|
||||||
/deploy-ripster.sh
|
|
||||||
/setup.sh
|
|
||||||
/install.sh
|
|
||||||
/install-dev.sh
|
|
||||||
/build-handbrake-nvdec.sh
|
|
||||||
/gitea_setup.sh
|
|
||||||
/gitea_install.sh
|
|
||||||
/release.sh
|
/release.sh
|
||||||
/Audible_Tool
|
/Audible_Tool
|
||||||
Generated
+69
-2
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.9.1-6",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.9.1-6",
|
"version": "0.10.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
@@ -158,6 +159,11 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/aproba": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||||
@@ -311,6 +317,22 @@
|
|||||||
"ieee754": "^1.1.13"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -431,6 +453,20 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"optional": true
|
"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": {
|
"node_modules/console-control-strings": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
"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": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
@@ -2236,6 +2290,14 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -2398,6 +2460,11 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.9.1-6",
|
"version": "0.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
|
|||||||
@@ -23,5 +23,7 @@ module.exports = {
|
|||||||
logLevel: process.env.LOG_LEVEL || 'info',
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
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 = [
|
const SETTINGS_CATEGORY_MOVES = [
|
||||||
{ key: 'cd_output_template', category: 'Pfade' },
|
{ key: 'cd_output_template', category: 'Pfade' },
|
||||||
{ key: 'output_template_bluray', 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) {
|
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)`
|
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_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() {
|
async function getDb() {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const multer = require('multer');
|
||||||
const asyncHandler = require('../middleware/asyncHandler');
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
const pipelineService = require('../services/pipelineService');
|
const pipelineService = require('../services/pipelineService');
|
||||||
const diskDetectionService = require('../services/diskDetectionService');
|
const diskDetectionService = require('../services/diskDetectionService');
|
||||||
@@ -6,6 +9,9 @@ const hardwareMonitorService = require('../services/hardwareMonitorService');
|
|||||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const audiobookUpload = multer({
|
||||||
|
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads')
|
||||||
|
});
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/state',
|
'/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(
|
router.post(
|
||||||
'/select-metadata',
|
'/select-metadata',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -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 PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
||||||
const processLogStreams = new Map();
|
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_INCOMPLETE_PREFIX = 'Incomplete_';
|
||||||
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
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) {
|
function detectOrphanMediaType(rawPath) {
|
||||||
if (hasBlurayStructure(rawPath)) {
|
if (hasBlurayStructure(rawPath)) {
|
||||||
return 'bluray';
|
return 'bluray';
|
||||||
@@ -193,6 +217,9 @@ function detectOrphanMediaType(rawPath) {
|
|||||||
if (hasCdStructure(rawPath)) {
|
if (hasCdStructure(rawPath)) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (hasAudiobookStructure(rawPath)) {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +296,9 @@ function normalizeMediaTypeValue(value) {
|
|||||||
if (raw === 'cd' || raw === 'audio_cd') {
|
if (raw === 'cd' || raw === 'audio_cd') {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +318,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
|
|||||||
|| job?.mediaType
|
|| job?.mediaType
|
||||||
);
|
);
|
||||||
|
|
||||||
if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd') {
|
if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd' || profileHint === 'audiobook') {
|
||||||
return profileHint;
|
return profileHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +342,15 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
|
|||||||
if (Array.isArray(mkInfo?.tracks) && mkInfo.tracks.length > 0) {
|
if (Array.isArray(mkInfo?.tracks) && mkInfo.tracks.length > 0) {
|
||||||
return 'cd';
|
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)) {
|
if (hasBlurayStructure(rawPath)) {
|
||||||
return 'bluray';
|
return 'bluray';
|
||||||
@@ -1383,7 +1422,7 @@ class HistoryService {
|
|||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir');
|
const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir');
|
||||||
if (rawDirs.length === 0) {
|
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;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -1457,6 +1496,7 @@ class HistoryService {
|
|||||||
hasBlurayStructure: detectedMediaType === 'bluray',
|
hasBlurayStructure: detectedMediaType === 'bluray',
|
||||||
hasDvdStructure: detectedMediaType === 'dvd',
|
hasDvdStructure: detectedMediaType === 'dvd',
|
||||||
hasCdStructure: detectedMediaType === 'cd',
|
hasCdStructure: detectedMediaType === 'cd',
|
||||||
|
hasAudiobookStructure: detectedMediaType === 'audiobook',
|
||||||
lastModifiedAt: stat.mtime.toISOString()
|
lastModifiedAt: stat.mtime.toISOString()
|
||||||
});
|
});
|
||||||
seenOrphanPaths.add(normalizedPath);
|
seenOrphanPaths.add(normalizedPath);
|
||||||
@@ -1483,7 +1523,7 @@ class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rawDirs.length === 0) {
|
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;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { EventEmitter } = require('events');
|
const { EventEmitter } = require('events');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const settingsService = require('./settingsService');
|
const settingsService = require('./settingsService');
|
||||||
const historyService = require('./historyService');
|
const historyService = require('./historyService');
|
||||||
const omdbService = require('./omdbService');
|
const omdbService = require('./omdbService');
|
||||||
const musicBrainzService = require('./musicBrainzService');
|
const musicBrainzService = require('./musicBrainzService');
|
||||||
const cdRipService = require('./cdRipService');
|
const cdRipService = require('./cdRipService');
|
||||||
|
const audiobookService = require('./audiobookService');
|
||||||
const scriptService = require('./scriptService');
|
const scriptService = require('./scriptService');
|
||||||
const scriptChainService = require('./scriptChainService');
|
const scriptChainService = require('./scriptChainService');
|
||||||
const runtimeActivityService = require('./runtimeActivityService');
|
const runtimeActivityService = require('./runtimeActivityService');
|
||||||
@@ -249,11 +251,14 @@ function normalizeMediaProfile(value) {
|
|||||||
if (raw === 'cd' || raw === 'audio_cd') {
|
if (raw === 'cd' || raw === 'audio_cd') {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSpecificMediaProfile(value) {
|
function isSpecificMediaProfile(value) {
|
||||||
return value === 'bluray' || value === 'dvd' || value === 'cd';
|
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook';
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||||
@@ -365,6 +370,9 @@ function inferMediaProfileFromRawPath(rawPath) {
|
|||||||
try {
|
try {
|
||||||
const sourceStat = fs.statSync(source);
|
const sourceStat = fs.statSync(source);
|
||||||
if (sourceStat.isFile()) {
|
if (sourceStat.isFile()) {
|
||||||
|
if (path.extname(source).toLowerCase() === '.aax') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
if (isLikelyExtensionlessDvdImageFile(source, sourceStat.size)) {
|
if (isLikelyExtensionlessDvdImageFile(source, sourceStat.size)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
@@ -393,6 +401,15 @@ function inferMediaProfileFromRawPath(rawPath) {
|
|||||||
// ignore fs errors
|
// 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) {
|
if (listTopLevelExtensionlessDvdImages(source).length > 0) {
|
||||||
return 'dvd';
|
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) {
|
function truncateLine(value, max = 180) {
|
||||||
const raw = String(value || '').replace(/\s+/g, ' ').trim();
|
const raw = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
if (raw.length <= max) {
|
if (raw.length <= max) {
|
||||||
@@ -3177,7 +3257,9 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP
|
|||||||
if (sourceStat.isFile()) {
|
if (sourceStat.isFile()) {
|
||||||
const ext = path.extname(sourcePath).toLowerCase();
|
const ext = path.extname(sourcePath).toLowerCase();
|
||||||
if (
|
if (
|
||||||
ext === '.mkv'
|
ext === '.aax'
|
||||||
|
|| ext === '.m4b'
|
||||||
|
|| ext === '.mkv'
|
||||||
|| ext === '.mp4'
|
|| ext === '.mp4'
|
||||||
|| isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size)
|
|| 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) {
|
if (primary.length > 0) {
|
||||||
return {
|
return {
|
||||||
mediaFiles: primary,
|
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 effectiveSettings = settingsService.resolveEffectiveToolSettings(sourceMap, normalizedMediaProfile);
|
||||||
const preferredDefaultRawDir = normalizedMediaProfile === 'cd'
|
const preferredDefaultRawDir = normalizedMediaProfile === 'cd'
|
||||||
? settingsService.DEFAULT_CD_DIR
|
? settingsService.DEFAULT_CD_DIR
|
||||||
|
: normalizedMediaProfile === 'audiobook'
|
||||||
|
? settingsService.DEFAULT_AUDIOBOOK_RAW_DIR
|
||||||
: settingsService.DEFAULT_RAW_DIR;
|
: settingsService.DEFAULT_RAW_DIR;
|
||||||
const uniqueRawDirs = Array.from(
|
const uniqueRawDirs = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -3709,9 +3793,11 @@ class PipelineService extends EventEmitter {
|
|||||||
sourceMap?.raw_dir_bluray,
|
sourceMap?.raw_dir_bluray,
|
||||||
sourceMap?.raw_dir_dvd,
|
sourceMap?.raw_dir_dvd,
|
||||||
sourceMap?.raw_dir_cd,
|
sourceMap?.raw_dir_cd,
|
||||||
|
sourceMap?.raw_dir_audiobook,
|
||||||
preferredDefaultRawDir,
|
preferredDefaultRawDir,
|
||||||
settingsService.DEFAULT_RAW_DIR,
|
settingsService.DEFAULT_RAW_DIR,
|
||||||
settingsService.DEFAULT_CD_DIR
|
settingsService.DEFAULT_CD_DIR,
|
||||||
|
settingsService.DEFAULT_AUDIOBOOK_RAW_DIR
|
||||||
]
|
]
|
||||||
.map((item) => String(item || '').trim())
|
.map((item) => String(item || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -3741,7 +3827,9 @@ class PipelineService extends EventEmitter {
|
|||||||
settings?.raw_dir_bluray,
|
settings?.raw_dir_bluray,
|
||||||
settings?.raw_dir_dvd,
|
settings?.raw_dir_dvd,
|
||||||
settings?.raw_dir_cd,
|
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);
|
].map((d) => String(d || '').trim()).filter(Boolean);
|
||||||
const allRawDirs = [rawBaseDir, settingsService.DEFAULT_RAW_DIR, ...rawExtraDirs]
|
const allRawDirs = [rawBaseDir, settingsService.DEFAULT_RAW_DIR, ...rawExtraDirs]
|
||||||
.filter((d, i, arr) => arr.indexOf(d) === i && d && fs.existsSync(d));
|
.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) {
|
async ensureMakeMKVRegistration(jobId, stage) {
|
||||||
const registrationConfig = await settingsService.buildMakeMKVRegisterConfig();
|
const registrationConfig = await settingsService.buildMakeMKVRegisterConfig();
|
||||||
if (!registrationConfig) {
|
if (!registrationConfig) {
|
||||||
@@ -7021,6 +7132,16 @@ class PipelineService extends EventEmitter {
|
|||||||
throw error;
|
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';
|
const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
|
||||||
if (isReadyToEncode) {
|
if (isReadyToEncode) {
|
||||||
// Check whether this confirmed job will rip first (pre_rip mode) or encode directly.
|
// 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 });
|
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);
|
const job = options?.preloadedJob || await historyService.getJobById(jobId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||||||
@@ -7072,6 +7189,20 @@ class PipelineService extends EventEmitter {
|
|||||||
throw error;
|
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) {
|
if (!job.title && !job.detected_title) {
|
||||||
const error = new Error('Start nicht möglich: keine Metadaten vorhanden.');
|
const error = new Error('Start nicht möglich: keine Metadaten vorhanden.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -7489,6 +7620,63 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
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);
|
const ripSuccessful = this.isRipSuccessful(sourceJob);
|
||||||
if (!ripSuccessful) {
|
if (!ripSuccessful) {
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
@@ -7497,11 +7685,6 @@ class PipelineService extends EventEmitter {
|
|||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, {
|
|
||||||
makemkvInfo: mkInfo,
|
|
||||||
rawPath: sourceJob.raw_path
|
|
||||||
});
|
|
||||||
const reencodeSettings = await settingsService.getSettingsMap();
|
const reencodeSettings = await settingsService.getSettingsMap();
|
||||||
const resolvedReencodeRawPath = this.resolveCurrentRawPathForSettings(
|
const resolvedReencodeRawPath = this.resolveCurrentRawPathForSettings(
|
||||||
reencodeSettings,
|
reencodeSettings,
|
||||||
@@ -9226,6 +9409,7 @@ class PipelineService extends EventEmitter {
|
|||||||
encodePlan: sourceEncodePlan
|
encodePlan: sourceEncodePlan
|
||||||
});
|
});
|
||||||
const isCdRetry = mediaProfile === 'cd';
|
const isCdRetry = mediaProfile === 'cd';
|
||||||
|
const isAudiobookRetry = mediaProfile === 'audiobook';
|
||||||
|
|
||||||
let cdRetryConfig = null;
|
let cdRetryConfig = null;
|
||||||
if (isCdRetry) {
|
if (isCdRetry) {
|
||||||
@@ -9283,7 +9467,7 @@ class PipelineService extends EventEmitter {
|
|||||||
selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []),
|
selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []),
|
||||||
selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
|
selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
|
||||||
};
|
};
|
||||||
} else {
|
} else if (!isAudiobookRetry) {
|
||||||
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||||
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
|
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
|
||||||
retrySettings,
|
retrySettings,
|
||||||
@@ -9353,7 +9537,7 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const retryJob = await historyService.createJob({
|
const retryJob = await historyService.createJob({
|
||||||
discDevice: sourceJob.disc_device || null,
|
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
|
detectedTitle: sourceJob.detected_title || sourceJob.title || null
|
||||||
});
|
});
|
||||||
const retryJobId = Number(retryJob?.id || 0);
|
const retryJobId = Number(retryJob?.id || 0);
|
||||||
@@ -9370,19 +9554,27 @@ class PipelineService extends EventEmitter {
|
|||||||
omdb_json: sourceJob.omdb_json || null,
|
omdb_json: sourceJob.omdb_json || null,
|
||||||
selected_from_omdb: Number(sourceJob.selected_from_omdb || 0),
|
selected_from_omdb: Number(sourceJob.selected_from_omdb || 0),
|
||||||
makemkv_info_json: sourceJob.makemkv_info_json || null,
|
makemkv_info_json: sourceJob.makemkv_info_json || null,
|
||||||
rip_successful: 0,
|
rip_successful: isAudiobookRetry ? 1 : 0,
|
||||||
error_message: null,
|
error_message: null,
|
||||||
end_time: null,
|
end_time: null,
|
||||||
handbrake_info_json: null,
|
handbrake_info_json: null,
|
||||||
mediainfo_info_json: null,
|
mediainfo_info_json: null,
|
||||||
encode_plan_json: isCdRetry
|
encode_plan_json: (isCdRetry || isAudiobookRetry)
|
||||||
? (sourceJob.encode_plan_json || null)
|
? (sourceJob.encode_plan_json || null)
|
||||||
: null,
|
: null,
|
||||||
encode_input_path: null,
|
encode_input_path: isAudiobookRetry
|
||||||
encode_review_confirmed: 0,
|
? (
|
||||||
|
sourceJob.encode_input_path
|
||||||
|
|| sourceEncodePlan?.encodeInputPath
|
||||||
|
|| sourceMakemkvInfo?.rawFilePath
|
||||||
|
|| null
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
encode_review_confirmed: isAudiobookRetry ? 1 : 0,
|
||||||
output_path: null,
|
output_path: null,
|
||||||
status: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING',
|
raw_path: isAudiobookRetry ? (sourceJob.raw_path || null) : null,
|
||||||
last_state: isCdRetry ? 'CD_READY_TO_RIP' : 'RIPPING'
|
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);
|
await historyService.updateJob(retryJobId, retryUpdatePayload);
|
||||||
|
|
||||||
@@ -9397,10 +9589,10 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
retryJobId,
|
retryJobId,
|
||||||
'USER_ACTION',
|
'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, {
|
await historyService.retireJobInFavorOf(jobId, retryJobId, {
|
||||||
reason: isCdRetry ? 'cd_retry' : 'retry'
|
reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : 'retry')
|
||||||
});
|
});
|
||||||
this.cancelRequestedByJob.delete(retryJobId);
|
this.cancelRequestedByJob.delete(retryJobId);
|
||||||
|
|
||||||
@@ -9412,6 +9604,14 @@ class PipelineService extends EventEmitter {
|
|||||||
error: errorToMeta(error)
|
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 {
|
} else {
|
||||||
this.startRipEncode(retryJobId).catch((error) => {
|
this.startRipEncode(retryJobId).catch((error) => {
|
||||||
logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(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
|
cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null
|
||||||
} : {}),
|
} : {}),
|
||||||
canRestartEncodeFromLastSettings: hasConfirmedPlan,
|
canRestartEncodeFromLastSettings: hasConfirmedPlan,
|
||||||
canRestartReviewFromRaw: hasRawPath
|
canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.cancelRequestedByJob.delete(Number(jobId));
|
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 ─────────────────────────────────────────────────────────────
|
// ── CD Pipeline ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async analyzeCd(device) {
|
async analyzeCd(device) {
|
||||||
@@ -10775,24 +11379,54 @@ class PipelineService extends EventEmitter {
|
|||||||
const renamed = [];
|
const renamed = [];
|
||||||
const mediaProfile = this.resolveMediaProfileForJob(job);
|
const mediaProfile = this.resolveMediaProfileForJob(job);
|
||||||
const isCd = mediaProfile === 'cd';
|
const isCd = mediaProfile === 'cd';
|
||||||
|
const isAudiobook = mediaProfile === 'audiobook';
|
||||||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
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
|
// Rename raw folder
|
||||||
const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null;
|
const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null;
|
||||||
if (currentRawPath && fs.existsSync(currentRawPath)) {
|
if (currentRawPath && fs.existsSync(currentRawPath)) {
|
||||||
const rawBaseDir = path.dirname(currentRawPath);
|
let newRawPath;
|
||||||
const newMetadataBase = buildRawMetadataBase({
|
if (isAudiobook) {
|
||||||
title: job.title || job.detected_title || null,
|
const audiobookMeta = buildAudiobookMetadataForJob(job, mkInfo, encodePlan);
|
||||||
year: job.year || null
|
const rawTemplate = String(
|
||||||
}, jobId);
|
settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE
|
||||||
const currentState = resolveRawFolderStateFromPath(currentRawPath);
|
).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE;
|
||||||
const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState });
|
const currentInputPath = findPreferredRawInput(currentRawPath)?.path
|
||||||
const newRawPath = path.join(rawBaseDir, newRawDirName);
|
|| 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)) {
|
if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(newRawPath) && !fs.existsSync(newRawPath)) {
|
||||||
try {
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(newRawPath), { recursive: true });
|
||||||
fs.renameSync(currentRawPath, newRawPath);
|
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 });
|
renamed.push({ type: 'raw', from: currentRawPath, to: newRawPath });
|
||||||
logger.info('rename-job-folders:raw', { jobId, from: currentRawPath, to: newRawPath });
|
logger.info('rename-job-folders:raw', { jobId, from: currentRawPath, to: newRawPath });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -10828,7 +11462,9 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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)) {
|
if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newOutputPath) && !fs.existsSync(newOutputPath)) {
|
||||||
fs.mkdirSync(path.dirname(newOutputPath), { recursive: true });
|
fs.mkdirSync(path.dirname(newOutputPath), { recursive: true });
|
||||||
moveFileWithFallback(currentOutputPath, newOutputPath);
|
moveFileWithFallback(currentOutputPath, newOutputPath);
|
||||||
@@ -10840,7 +11476,11 @@ class PipelineService extends EventEmitter {
|
|||||||
} catch (_ignoreErr) {}
|
} catch (_ignoreErr) {}
|
||||||
await historyService.updateJob(jobId, { output_path: newOutputPath });
|
await historyService.updateJob(jobId, { output_path: newOutputPath });
|
||||||
renamed.push({ type: 'output', from: currentOutputPath, to: 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) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ const {
|
|||||||
const { splitArgs } = require('../utils/commandLine');
|
const { splitArgs } = require('../utils/commandLine');
|
||||||
const { setLogRootDir } = require('./logPathService');
|
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 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;
|
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 SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd'];
|
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook'];
|
||||||
const PROFILED_SETTINGS = {
|
const PROFILED_SETTINGS = {
|
||||||
raw_dir: {
|
raw_dir: {
|
||||||
bluray: 'raw_dir_bluray',
|
bluray: 'raw_dir_bluray',
|
||||||
dvd: 'raw_dir_dvd',
|
dvd: 'raw_dir_dvd',
|
||||||
cd: 'raw_dir_cd'
|
cd: 'raw_dir_cd',
|
||||||
|
audiobook: 'raw_dir_audiobook'
|
||||||
},
|
},
|
||||||
raw_dir_owner: {
|
raw_dir_owner: {
|
||||||
bluray: 'raw_dir_bluray_owner',
|
bluray: 'raw_dir_bluray_owner',
|
||||||
dvd: 'raw_dir_dvd_owner',
|
dvd: 'raw_dir_dvd_owner',
|
||||||
cd: 'raw_dir_cd_owner'
|
cd: 'raw_dir_cd_owner',
|
||||||
|
audiobook: 'raw_dir_audiobook_owner'
|
||||||
},
|
},
|
||||||
movie_dir: {
|
movie_dir: {
|
||||||
bluray: 'movie_dir_bluray',
|
bluray: 'movie_dir_bluray',
|
||||||
dvd: 'movie_dir_dvd',
|
dvd: 'movie_dir_dvd',
|
||||||
cd: 'movie_dir_cd'
|
cd: 'movie_dir_cd',
|
||||||
|
audiobook: 'movie_dir_audiobook'
|
||||||
},
|
},
|
||||||
movie_dir_owner: {
|
movie_dir_owner: {
|
||||||
bluray: 'movie_dir_bluray_owner',
|
bluray: 'movie_dir_bluray_owner',
|
||||||
dvd: 'movie_dir_dvd_owner',
|
dvd: 'movie_dir_dvd_owner',
|
||||||
cd: 'movie_dir_cd_owner'
|
cd: 'movie_dir_cd_owner',
|
||||||
|
audiobook: 'movie_dir_audiobook_owner'
|
||||||
},
|
},
|
||||||
mediainfo_extra_args: {
|
mediainfo_extra_args: {
|
||||||
bluray: 'mediainfo_extra_args_bluray',
|
bluray: 'mediainfo_extra_args_bluray',
|
||||||
@@ -86,11 +96,13 @@ const PROFILED_SETTINGS = {
|
|||||||
},
|
},
|
||||||
output_extension: {
|
output_extension: {
|
||||||
bluray: 'output_extension_bluray',
|
bluray: 'output_extension_bluray',
|
||||||
dvd: 'output_extension_dvd'
|
dvd: 'output_extension_dvd',
|
||||||
|
audiobook: 'output_extension_audiobook'
|
||||||
},
|
},
|
||||||
output_template: {
|
output_template: {
|
||||||
bluray: 'output_template_bluray',
|
bluray: 'output_template_bluray',
|
||||||
dvd: 'output_template_dvd'
|
dvd: 'output_template_dvd',
|
||||||
|
audiobook: 'output_template_audiobook'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||||
@@ -372,11 +384,17 @@ function normalizeMediaProfileValue(value) {
|
|||||||
if (raw === 'cd' || raw === 'audio_cd') {
|
if (raw === 'cd' || raw === 'audio_cd') {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProfileFallbackOrder(profile) {
|
function resolveProfileFallbackOrder(profile) {
|
||||||
const normalized = normalizeMediaProfileValue(profile);
|
const normalized = normalizeMediaProfileValue(profile);
|
||||||
|
if (normalized === 'audiobook') {
|
||||||
|
return ['audiobook'];
|
||||||
|
}
|
||||||
if (normalized === 'bluray') {
|
if (normalized === 'bluray') {
|
||||||
return ['bluray', 'dvd'];
|
return ['bluray', 'dvd'];
|
||||||
}
|
}
|
||||||
@@ -690,9 +708,21 @@ class SettingsService {
|
|||||||
// Fallback to hardcoded install defaults when no setting value is configured
|
// Fallback to hardcoded install defaults when no setting value is configured
|
||||||
if (!hasUsableProfileSpecificValue(resolvedValue)) {
|
if (!hasUsableProfileSpecificValue(resolvedValue)) {
|
||||||
if (legacyKey === 'raw_dir') {
|
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') {
|
} 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;
|
effective[legacyKey] = resolvedValue;
|
||||||
@@ -724,14 +754,18 @@ class SettingsService {
|
|||||||
const bluray = this.resolveEffectiveToolSettings(map, 'bluray');
|
const bluray = this.resolveEffectiveToolSettings(map, 'bluray');
|
||||||
const dvd = this.resolveEffectiveToolSettings(map, 'dvd');
|
const dvd = this.resolveEffectiveToolSettings(map, 'dvd');
|
||||||
const cd = this.resolveEffectiveToolSettings(map, 'cd');
|
const cd = this.resolveEffectiveToolSettings(map, 'cd');
|
||||||
|
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
|
||||||
return {
|
return {
|
||||||
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
||||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||||
|
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||||
defaults: {
|
defaults: {
|
||||||
raw: DEFAULT_RAW_DIR,
|
raw: DEFAULT_RAW_DIR,
|
||||||
movies: DEFAULT_MOVIE_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_RAW_DIR = DEFAULT_RAW_DIR;
|
||||||
settingsServiceInstance.DEFAULT_MOVIE_DIR = DEFAULT_MOVIE_DIR;
|
settingsServiceInstance.DEFAULT_MOVIE_DIR = DEFAULT_MOVIE_DIR;
|
||||||
settingsServiceInstance.DEFAULT_CD_DIR = DEFAULT_CD_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;
|
module.exports = settingsServiceInstance;
|
||||||
|
|||||||
@@ -355,6 +355,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230);
|
VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', 'cdparanoia');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', 'cdparanoia');
|
||||||
|
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg');
|
||||||
|
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES (
|
VALUES (
|
||||||
'cd_output_template',
|
'cd_output_template',
|
||||||
@@ -371,6 +379,19 @@ VALUES (
|
|||||||
INSERT OR IGNORE INTO settings_values (key, value)
|
INSERT OR IGNORE INTO settings_values (key, value)
|
||||||
VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}');
|
VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}');
|
||||||
|
|
||||||
|
-- Tools – Audiobook
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_audiobook', 'mp3');
|
||||||
|
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})');
|
||||||
|
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})');
|
||||||
|
|
||||||
-- Pfade – CD
|
-- Pfade – CD
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104);
|
VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104);
|
||||||
@@ -388,6 +409,23 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
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);
|
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);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL);
|
||||||
|
|
||||||
|
-- Pfade – Audiobook
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL);
|
||||||
|
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL);
|
||||||
|
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL);
|
||||||
|
|
||||||
|
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);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL);
|
||||||
|
|
||||||
-- Metadaten
|
-- Metadaten
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.9.1-6",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.9.1-6",
|
"version": "0.10.0",
|
||||||
"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.9.1-6",
|
"version": "0.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -78,11 +78,13 @@ function afterMutationInvalidate(prefixes = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
|
const isFormDataBody = typeof FormData !== 'undefined' && options?.body instanceof FormData;
|
||||||
|
const mergedHeaders = {
|
||||||
|
...(isFormDataBody ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(options.headers || {})
|
||||||
|
};
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: {
|
headers: mergedHeaders,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options.headers || {})
|
|
||||||
},
|
|
||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,6 +303,24 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
async uploadAudiobook(file, payload = {}) {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (file) {
|
||||||
|
formData.append('file', file);
|
||||||
|
}
|
||||||
|
if (payload?.format) {
|
||||||
|
formData.append('format', String(payload.format));
|
||||||
|
}
|
||||||
|
if (payload?.startImmediately !== undefined) {
|
||||||
|
formData.append('startImmediately', String(payload.startImmediately));
|
||||||
|
}
|
||||||
|
const result = await request('/pipeline/audiobook/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
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',
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const GENERAL_TOOL_KEYS = new Set([
|
|||||||
'makemkv_min_length_minutes',
|
'makemkv_min_length_minutes',
|
||||||
'mediainfo_command',
|
'mediainfo_command',
|
||||||
'handbrake_command',
|
'handbrake_command',
|
||||||
|
'ffmpeg_command',
|
||||||
|
'ffprobe_command',
|
||||||
'handbrake_restart_delete_incomplete_output',
|
'handbrake_restart_delete_incomplete_output',
|
||||||
'script_test_timeout_ms'
|
'script_test_timeout_ms'
|
||||||
]);
|
]);
|
||||||
@@ -122,6 +124,12 @@ function buildToolSections(settings) {
|
|||||||
description: 'Profil-spezifische Settings für DVD.',
|
description: 'Profil-spezifische Settings für DVD.',
|
||||||
settings: []
|
settings: []
|
||||||
};
|
};
|
||||||
|
const audiobookBucket = {
|
||||||
|
id: 'audiobook',
|
||||||
|
title: 'Audiobook',
|
||||||
|
description: 'Profil-spezifische Settings für Audiobooks.',
|
||||||
|
settings: []
|
||||||
|
};
|
||||||
const fallbackBucket = {
|
const fallbackBucket = {
|
||||||
id: 'other',
|
id: 'other',
|
||||||
title: 'Weitere Tool-Settings',
|
title: 'Weitere Tool-Settings',
|
||||||
@@ -143,13 +151,18 @@ function buildToolSections(settings) {
|
|||||||
dvdBucket.settings.push(setting);
|
dvdBucket.settings.push(setting);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (key.endsWith('_audiobook')) {
|
||||||
|
audiobookBucket.settings.push(setting);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
fallbackBucket.settings.push(setting);
|
fallbackBucket.settings.push(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
generalBucket,
|
generalBucket,
|
||||||
blurayBucket,
|
blurayBucket,
|
||||||
dvdBucket
|
dvdBucket,
|
||||||
|
audiobookBucket
|
||||||
].filter((item) => item.settings.length > 0);
|
].filter((item) => item.settings.length > 0);
|
||||||
if (fallbackBucket.settings.length > 0) {
|
if (fallbackBucket.settings.length > 0) {
|
||||||
sections.push(fallbackBucket);
|
sections.push(fallbackBucket);
|
||||||
@@ -161,6 +174,7 @@ function buildToolSections(settings) {
|
|||||||
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
||||||
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
||||||
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
|
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
|
||||||
|
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'audiobook_raw_template'];
|
||||||
const LOG_PATH_KEYS = ['log_dir'];
|
const LOG_PATH_KEYS = ['log_dir'];
|
||||||
|
|
||||||
function buildSectionsForCategory(categoryName, settings) {
|
function buildSectionsForCategory(categoryName, settings) {
|
||||||
@@ -369,11 +383,14 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
const bluraySettings = list.filter((s) => BLURAY_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && BLURAY_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
const bluraySettings = list.filter((s) => BLURAY_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && BLURAY_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||||
const dvdSettings = list.filter((s) => DVD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DVD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
const dvdSettings = list.filter((s) => DVD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DVD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||||
const cdSettings = list.filter((s) => CD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && CD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
const cdSettings = list.filter((s) => CD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && CD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||||
|
const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||||
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
|
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
|
||||||
|
|
||||||
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||||
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
|
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
|
||||||
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
||||||
|
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
|
||||||
|
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
|
||||||
|
|
||||||
const ep = effectivePaths || {};
|
const ep = effectivePaths || {};
|
||||||
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||||
@@ -382,6 +399,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
||||||
const cdRaw = ep.cd?.raw || defaultCd;
|
const cdRaw = ep.cd?.raw || defaultCd;
|
||||||
const cdMovies = ep.cd?.movies || cdRaw;
|
const cdMovies = ep.cd?.movies || cdRaw;
|
||||||
|
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
||||||
|
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
|
||||||
|
|
||||||
const isDefault = (path, def) => path === def;
|
const isDefault = (path, def) => path === def;
|
||||||
|
|
||||||
@@ -435,6 +454,17 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
{isDefault(cdMovies, cdRaw) && <span className="path-default-badge">Standard</span>}
|
{isDefault(cdMovies, cdRaw) && <span className="path-default-badge">Standard</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Audiobook</strong></td>
|
||||||
|
<td>
|
||||||
|
<code>{audiobookRaw}</code>
|
||||||
|
{isDefault(audiobookRaw, defaultAudiobookRaw) && <span className="path-default-badge">Standard</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{audiobookMovies}</code>
|
||||||
|
{isDefault(audiobookMovies, defaultAudiobookMovies) && <span className="path-default-badge">Standard</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -468,6 +498,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
dirtyKeys={dirtyKeys}
|
dirtyKeys={dirtyKeys}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
|
<PathMediumCard
|
||||||
|
title="Audiobook"
|
||||||
|
pathSettings={audiobookSettings}
|
||||||
|
settingsByKey={settingsByKey}
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
dirtyKeys={dirtyKeys}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log-Ordner */}
|
{/* Log-Ordner */}
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ function resolveMediaType(job) {
|
|||||||
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
|
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
|
||||||
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
|
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||||
@@ -247,6 +250,12 @@ function resolveMediaType(job) {
|
|||||||
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
|
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +317,26 @@ function resolveCdDetails(job) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAudiobookDetails(job) {
|
||||||
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
||||||
|
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||||
|
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
|
? makemkvInfo.selectedMetadata
|
||||||
|
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
|
||||||
|
const chapters = Array.isArray(selectedMetadata?.chapters)
|
||||||
|
? selectedMetadata.chapters
|
||||||
|
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
|
||||||
|
const format = String(job?.handbrakeInfo?.format || encodePlan?.format || '').trim().toLowerCase() || null;
|
||||||
|
return {
|
||||||
|
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
|
||||||
|
narrator: String(selectedMetadata?.narrator || '').trim() || null,
|
||||||
|
series: String(selectedMetadata?.series || '').trim() || null,
|
||||||
|
part: String(selectedMetadata?.part || '').trim() || null,
|
||||||
|
chapterCount: chapters.length,
|
||||||
|
formatLabel: format ? format.toUpperCase() : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function statusBadgeMeta(status, queued = false) {
|
function statusBadgeMeta(status, queued = false) {
|
||||||
const normalized = String(status || '').trim().toUpperCase();
|
const normalized = String(status || '').trim().toUpperCase();
|
||||||
const label = getStatusLabel(normalized, { queued });
|
const label = getStatusLabel(normalized, { queued });
|
||||||
@@ -404,6 +433,9 @@ export default function JobDetailDialog({
|
|||||||
&& !running
|
&& !running
|
||||||
&& typeof onResumeReady === 'function'
|
&& typeof onResumeReady === 'function'
|
||||||
);
|
);
|
||||||
|
const mediaType = resolveMediaType(job);
|
||||||
|
const isCd = mediaType === 'cd';
|
||||||
|
const isAudiobook = mediaType === 'audiobook';
|
||||||
const hasConfirmedPlan = Boolean(
|
const hasConfirmedPlan = Boolean(
|
||||||
job?.encodePlan
|
job?.encodePlan
|
||||||
&& Array.isArray(job?.encodePlan?.titles)
|
&& Array.isArray(job?.encodePlan?.titles)
|
||||||
@@ -416,6 +448,7 @@ export default function JobDetailDialog({
|
|||||||
job?.rawStatus?.exists
|
job?.rawStatus?.exists
|
||||||
&& job?.rawStatus?.isEmpty !== true
|
&& job?.rawStatus?.isEmpty !== true
|
||||||
&& !running
|
&& !running
|
||||||
|
&& mediaType !== 'audiobook'
|
||||||
&& typeof onRestartReview === 'function'
|
&& typeof onRestartReview === 'function'
|
||||||
);
|
);
|
||||||
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
|
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
|
||||||
@@ -424,9 +457,8 @@ export default function JobDetailDialog({
|
|||||||
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||||
const logTruncated = Boolean(logMeta?.truncated);
|
const logTruncated = Boolean(logMeta?.truncated);
|
||||||
const mediaType = resolveMediaType(job);
|
|
||||||
const isCd = mediaType === 'cd';
|
|
||||||
const cdDetails = isCd ? resolveCdDetails(job) : null;
|
const cdDetails = isCd ? resolveCdDetails(job) : null;
|
||||||
|
const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null;
|
||||||
const canRetry = isCd && !running && typeof onRetry === 'function';
|
const canRetry = isCd && !running && typeof onRetry === 'function';
|
||||||
const mediaTypeLabel = mediaType === 'bluray'
|
const mediaTypeLabel = mediaType === 'bluray'
|
||||||
? 'Blu-ray'
|
? 'Blu-ray'
|
||||||
@@ -434,7 +466,7 @@ export default function JobDetailDialog({
|
|||||||
? 'DVD'
|
? 'DVD'
|
||||||
: isCd
|
: isCd
|
||||||
? 'Audio CD'
|
? 'Audio CD'
|
||||||
: 'Sonstiges Medium';
|
: (isAudiobook ? 'Audiobook' : 'Sonstiges Medium');
|
||||||
const mediaTypeIcon = mediaType === 'bluray'
|
const mediaTypeIcon = mediaType === 'bluray'
|
||||||
? blurayIndicatorIcon
|
? blurayIndicatorIcon
|
||||||
: mediaType === 'dvd'
|
: mediaType === 'dvd'
|
||||||
@@ -535,7 +567,7 @@ export default function JobDetailDialog({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<section className="job-meta-block job-meta-block-film">
|
<section className="job-meta-block job-meta-block-film">
|
||||||
<h4>Film-Infos</h4>
|
<h4>{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}</h4>
|
||||||
<div className="job-meta-list">
|
<div className="job-meta-list">
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>Titel:</strong>
|
<strong>Titel:</strong>
|
||||||
@@ -545,14 +577,45 @@ export default function JobDetailDialog({
|
|||||||
<strong>Jahr:</strong>
|
<strong>Jahr:</strong>
|
||||||
<span>{job.year || '-'}</span>
|
<span>{job.year || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="job-meta-item">
|
{isAudiobook ? (
|
||||||
<strong>IMDb:</strong>
|
<>
|
||||||
<span>{job.imdb_id || '-'}</span>
|
<div className="job-meta-item">
|
||||||
</div>
|
<strong>Autor:</strong>
|
||||||
<div className="job-meta-item">
|
<span>{audiobookDetails?.author || '-'}</span>
|
||||||
<strong>OMDb Match:</strong>
|
</div>
|
||||||
<BoolState value={job.selected_from_omdb} />
|
<div className="job-meta-item">
|
||||||
</div>
|
<strong>Sprecher:</strong>
|
||||||
|
<span>{audiobookDetails?.narrator || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Serie:</strong>
|
||||||
|
<span>{audiobookDetails?.series || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Teil:</strong>
|
||||||
|
<span>{audiobookDetails?.part || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Kapitel:</strong>
|
||||||
|
<span>{audiobookDetails?.chapterCount || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Format:</strong>
|
||||||
|
<span>{audiobookDetails?.formatLabel || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>IMDb:</strong>
|
||||||
|
<span>{job.imdb_id || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>OMDb Match:</strong>
|
||||||
|
<BoolState value={job.selected_from_omdb} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>Medium:</strong>
|
<strong>Medium:</strong>
|
||||||
<span className="job-step-cell">
|
<span className="job-step-cell">
|
||||||
@@ -563,35 +626,37 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="job-meta-block job-meta-block-film">
|
{!isAudiobook ? (
|
||||||
<h4>OMDb Details</h4>
|
<section className="job-meta-block job-meta-block-film">
|
||||||
<div className="job-meta-list">
|
<h4>OMDb Details</h4>
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-list">
|
||||||
<strong>Regisseur:</strong>
|
<div className="job-meta-item">
|
||||||
<span>{omdbField(omdbInfo?.Director)}</span>
|
<strong>Regisseur:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.Director)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Schauspieler:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.Actors)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Laufzeit:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.Runtime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Genre:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.Genre)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Rotten Tomatoes:</strong>
|
||||||
|
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>imdbRating:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.imdbRating)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="job-meta-item">
|
</section>
|
||||||
<strong>Schauspieler:</strong>
|
) : null}
|
||||||
<span>{omdbField(omdbInfo?.Actors)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="job-meta-item">
|
|
||||||
<strong>Laufzeit:</strong>
|
|
||||||
<span>{omdbField(omdbInfo?.Runtime)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="job-meta-item">
|
|
||||||
<strong>Genre:</strong>
|
|
||||||
<span>{omdbField(omdbInfo?.Genre)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="job-meta-item">
|
|
||||||
<strong>Rotten Tomatoes:</strong>
|
|
||||||
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="job-meta-item">
|
|
||||||
<strong>imdbRating:</strong>
|
|
||||||
<span>{omdbField(omdbInfo?.imdbRating)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -631,7 +696,7 @@ export default function JobDetailDialog({
|
|||||||
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>{isCd ? 'Audio-Dateien vorhanden:' : 'Movie Datei vorhanden:'}</strong> <BoolState value={job.outputStatus?.exists} />
|
<strong>{isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? 'Audiobook-Datei vorhanden:' : 'Movie Datei vorhanden:')}</strong> <BoolState value={job.outputStatus?.exists} />
|
||||||
</div>
|
</div>
|
||||||
{isCd ? (
|
{isCd ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -640,7 +705,7 @@ export default function JobDetailDialog({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
|
<strong>{isAudiobook ? 'Import erfolgreich:' : 'Backup erfolgreich:'}</strong> <BoolState value={job?.backupSuccess} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
||||||
@@ -653,7 +718,7 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!isCd && (hasConfiguredSelection || encodePlanUserPreset) ? (
|
{!isCd && !isAudiobook && (hasConfiguredSelection || encodePlanUserPreset) ? (
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Hinterlegte Encode-Auswahl</h4>
|
<h4>Hinterlegte Encode-Auswahl</h4>
|
||||||
<div className="job-configured-selection-grid">
|
<div className="job-configured-selection-grid">
|
||||||
@@ -683,13 +748,13 @@ export default function JobDetailDialog({
|
|||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Ausgeführter Encode-Befehl</h4>
|
<h4>Ausgeführter Encode-Befehl</h4>
|
||||||
<div className="handbrake-command-preview">
|
<div className="handbrake-command-preview">
|
||||||
<small><strong>HandBrakeCLI (tatsächlich gestartet):</strong></small>
|
<small><strong>{isAudiobook ? 'FFmpeg' : 'HandBrakeCLI'} (tatsächlich gestartet):</strong></small>
|
||||||
<pre>{executedHandBrakeCommand}</pre>
|
<pre>{executedHandBrakeCommand}</pre>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isCd && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
{!isCd && !isAudiobook && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Skripte</h4>
|
<h4>Skripte</h4>
|
||||||
<div className="script-results-grid">
|
<div className="script-results-grid">
|
||||||
@@ -700,14 +765,14 @@ export default function JobDetailDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="job-json-grid">
|
<div className="job-json-grid">
|
||||||
{!isCd ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
|
{!isCd && !isAudiobook ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
|
||||||
<JsonView title={isCd ? 'cdparanoia Info' : 'MakeMKV Info'} value={job.makemkvInfo} />
|
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : 'MakeMKV Info')} value={job.makemkvInfo} />
|
||||||
{!isCd ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
||||||
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
|
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
|
||||||
{!isCd ? <JsonView title="HandBrake Info" value={job.handbrakeInfo} /> : null}
|
{!isCd ? <JsonView title={isAudiobook ? 'FFmpeg Info' : 'HandBrake Info'} value={job.handbrakeInfo} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isCd && job.encodePlan ? (
|
{!isCd && !isAudiobook && job.encodePlan ? (
|
||||||
<>
|
<>
|
||||||
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
||||||
<MediaInfoReviewPanel
|
<MediaInfoReviewPanel
|
||||||
@@ -740,7 +805,7 @@ export default function JobDetailDialog({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!isCd ? (
|
{!isCd && !isAudiobook ? (
|
||||||
<Button
|
<Button
|
||||||
label="OMDb neu zuordnen"
|
label="OMDb neu zuordnen"
|
||||||
icon="pi pi-search"
|
icon="pi pi-search"
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ function normalizeMediaProfile(value) {
|
|||||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
|
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
@@ -234,8 +237,8 @@ function sanitizeFileName(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTemplate(template, values) {
|
function renderTemplate(template, values) {
|
||||||
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
|
||||||
const value = values[key.trim()];
|
const value = values[(keyA || keyB || '').trim()];
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
@@ -248,7 +251,11 @@ function resolveProfiledSetting(settings, key, mediaProfile) {
|
|||||||
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
|
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
|
||||||
return settings[profileKey];
|
return settings[profileKey];
|
||||||
}
|
}
|
||||||
const fallbackProfiles = mediaProfile === 'bluray' ? ['dvd'] : ['bluray'];
|
const fallbackProfiles = mediaProfile === 'bluray'
|
||||||
|
? ['dvd']
|
||||||
|
: mediaProfile === 'dvd'
|
||||||
|
? ['bluray']
|
||||||
|
: [];
|
||||||
for (const fb of fallbackProfiles) {
|
for (const fb of fallbackProfiles) {
|
||||||
const fbKey = `${key}_${fb}`;
|
const fbKey = `${key}_${fb}`;
|
||||||
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
|
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
|
||||||
@@ -265,12 +272,14 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
|
|||||||
}
|
}
|
||||||
|
|
||||||
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||||
|
const author = metadata?.author || metadata?.artist || 'unknown';
|
||||||
|
const narrator = metadata?.narrator || 'unknown';
|
||||||
const year = metadata?.year || new Date().getFullYear();
|
const year = metadata?.year || new Date().getFullYear();
|
||||||
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||||
const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})';
|
const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})';
|
||||||
const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile);
|
const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile);
|
||||||
const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE;
|
const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE;
|
||||||
const rendered = renderTemplate(template, { title, year, imdbId });
|
const rendered = renderTemplate(template, { title, year, imdbId, author, narrator });
|
||||||
const segments = rendered
|
const segments = rendered
|
||||||
.replace(/\\/g, '/')
|
.replace(/\\/g, '/')
|
||||||
.replace(/\/+/g, '/')
|
.replace(/\/+/g, '/')
|
||||||
|
|||||||
@@ -377,6 +377,9 @@ function resolveMediaType(job) {
|
|||||||
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const statusCandidates = [
|
const statusCandidates = [
|
||||||
job?.status,
|
job?.status,
|
||||||
@@ -397,6 +400,12 @@ function resolveMediaType(job) {
|
|||||||
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
|
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +420,9 @@ function mediaIndicatorMeta(job) {
|
|||||||
if (mediaType === 'cd') {
|
if (mediaType === 'cd') {
|
||||||
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
|
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
|
||||||
}
|
}
|
||||||
|
if (mediaType === 'audiobook') {
|
||||||
|
return { mediaType, src: otherIndicatorIcon, alt: 'Audiobook', title: 'Audiobook' };
|
||||||
|
}
|
||||||
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
|
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +460,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
|
|
||||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||||
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||||
|
const resolvedMediaType = resolveMediaType(job);
|
||||||
const analyzeContext = getAnalyzeContext(job);
|
const analyzeContext = getAnalyzeContext(job);
|
||||||
const normalizePlanIdList = (values) => {
|
const normalizePlanIdList = (values) => {
|
||||||
const list = Array.isArray(values) ? values : [];
|
const list = Array.isArray(values) ? values : [];
|
||||||
@@ -575,15 +588,26 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
|
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
|
||||||
: '<temp>/trackNN.cdda.wav';
|
: '<temp>/trackNN.cdda.wav';
|
||||||
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
||||||
const selectedMetadata = {
|
const audiobookSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
? makemkvInfo.selectedMetadata
|
||||||
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
|
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
|
||||||
year: cdSelectedMeta?.year ?? job?.year ?? null,
|
const selectedMetadata = resolvedMediaType === 'audiobook'
|
||||||
mbId: resolvedCdMbId,
|
? {
|
||||||
coverUrl: resolvedCdCoverUrl,
|
title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null,
|
||||||
imdbId: job?.imdb_id || null,
|
author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
|
||||||
poster: job?.poster_url || resolvedCdCoverUrl || null
|
narrator: audiobookSelectedMeta?.narrator || null,
|
||||||
};
|
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
|
||||||
|
poster: job?.poster_url || null
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
||||||
|
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
|
||||||
|
year: cdSelectedMeta?.year ?? job?.year ?? null,
|
||||||
|
mbId: resolvedCdMbId,
|
||||||
|
coverUrl: resolvedCdCoverUrl,
|
||||||
|
imdbId: job?.imdb_id || null,
|
||||||
|
poster: job?.poster_url || resolvedCdCoverUrl || null
|
||||||
|
};
|
||||||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||||
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||||
const inputPath = isPreRip
|
const inputPath = isPreRip
|
||||||
@@ -623,13 +647,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
const canRestartReviewFromRaw = Boolean(
|
const canRestartReviewFromRaw = Boolean(
|
||||||
job?.raw_path
|
job?.raw_path
|
||||||
&& !processingStates.includes(jobStatus)
|
&& !processingStates.includes(jobStatus)
|
||||||
|
&& resolvedMediaType !== 'audiobook'
|
||||||
);
|
);
|
||||||
const computedContext = {
|
const computedContext = {
|
||||||
jobId,
|
jobId,
|
||||||
rawPath: job?.raw_path || null,
|
rawPath: job?.raw_path || null,
|
||||||
outputPath: job?.output_path || null,
|
outputPath: job?.output_path || null,
|
||||||
detectedTitle: job?.detected_title || null,
|
detectedTitle: job?.detected_title || null,
|
||||||
mediaProfile: resolveMediaType(job),
|
mediaProfile: resolvedMediaType,
|
||||||
lastState,
|
lastState,
|
||||||
devicePath,
|
devicePath,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
@@ -751,6 +776,8 @@ export default function DashboardPage({
|
|||||||
const [jobsLoading, setJobsLoading] = useState(false);
|
const [jobsLoading, setJobsLoading] = useState(false);
|
||||||
const [dashboardJobs, setDashboardJobs] = useState([]);
|
const [dashboardJobs, setDashboardJobs] = useState([]);
|
||||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||||
|
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
|
||||||
|
const [audiobookUploadBusy, setAudiobookUploadBusy] = useState(false);
|
||||||
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
||||||
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
||||||
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||||
@@ -1245,6 +1272,8 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startOptions = options && typeof options === 'object' ? options : {};
|
const startOptions = options && typeof options === 'object' ? options : {};
|
||||||
|
const startJobRow = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||||
|
const mediaType = resolveMediaType(startJobRow);
|
||||||
setJobBusy(normalizedJobId, true);
|
setJobBusy(normalizedJobId, true);
|
||||||
try {
|
try {
|
||||||
if (startOptions.ensureConfirmed) {
|
if (startOptions.ensureConfirmed) {
|
||||||
@@ -1270,7 +1299,9 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
|
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
|
||||||
}
|
}
|
||||||
const response = await api.startJob(normalizedJobId);
|
const response = mediaType === 'audiobook'
|
||||||
|
? await api.startJob(normalizedJobId)
|
||||||
|
: await api.startJob(normalizedJobId);
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
@@ -1286,6 +1317,39 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAudiobookUpload = async () => {
|
||||||
|
if (!audiobookUploadFile) {
|
||||||
|
showError(new Error('Bitte zuerst eine AAX-Datei auswählen.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAudiobookUploadBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: true });
|
||||||
|
const result = getQueueActionResult(response);
|
||||||
|
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
if (result.queued) {
|
||||||
|
showQueuedToast(toastRef, 'Audiobook', result);
|
||||||
|
} else {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Audiobook importiert',
|
||||||
|
detail: uploadedJobId ? `Job #${uploadedJobId} wurde angelegt.` : 'Audiobook wurde importiert.',
|
||||||
|
life: 3200
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (uploadedJobId) {
|
||||||
|
setExpandedJobId(uploadedJobId);
|
||||||
|
}
|
||||||
|
setAudiobookUploadFile(null);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setAudiobookUploadBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirmReview = async (
|
const handleConfirmReview = async (
|
||||||
jobId,
|
jobId,
|
||||||
selectedEncodeTitleId = null,
|
selectedEncodeTitleId = null,
|
||||||
@@ -1944,6 +2008,35 @@ 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.">
|
||||||
|
<div className="actions-row">
|
||||||
|
<input
|
||||||
|
key={audiobookUploadFile ? `${audiobookUploadFile.name}-${audiobookUploadFile.size}` : 'audiobook-upload-input'}
|
||||||
|
type="file"
|
||||||
|
accept=".aax"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextFile = event.target?.files?.[0] || null;
|
||||||
|
setAudiobookUploadFile(nextFile);
|
||||||
|
}}
|
||||||
|
disabled={audiobookUploadBusy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Audiobook hochladen"
|
||||||
|
icon="pi pi-upload"
|
||||||
|
onClick={() => {
|
||||||
|
void handleAudiobookUpload();
|
||||||
|
}}
|
||||||
|
loading={audiobookUploadBusy}
|
||||||
|
disabled={!audiobookUploadFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
{audiobookUploadFile
|
||||||
|
? `Ausgewählt: ${audiobookUploadFile.name}`
|
||||||
|
: 'Unterstützt im MVP: AAX-Upload. Das Ausgabeformat wird aus den Audiobook-Settings gelesen.'}
|
||||||
|
</small>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
|
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
|
||||||
<div className="pipeline-queue-meta">
|
<div className="pipeline-queue-meta">
|
||||||
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ function resolveMediaType(row) {
|
|||||||
if (['cd', 'audio_cd'].includes(raw)) {
|
if (['cd', 'audio_cd'].includes(raw)) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
@@ -698,13 +701,13 @@ export default function DatabasePage() {
|
|||||||
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
||||||
const alt = mediaType === 'bluray'
|
const alt = mediaType === 'bluray'
|
||||||
? 'Blu-ray'
|
? 'Blu-ray'
|
||||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
|
||||||
const title = mediaType === 'bluray'
|
const title = mediaType === 'bluray'
|
||||||
? 'Blu-ray'
|
? 'Blu-ray'
|
||||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
|
||||||
const label = mediaType === 'bluray'
|
const label = mediaType === 'bluray'
|
||||||
? 'Blu-ray'
|
? 'Blu-ray'
|
||||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges');
|
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges'));
|
||||||
return (
|
return (
|
||||||
<span className="job-step-cell">
|
<span className="job-step-cell">
|
||||||
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
||||||
@@ -781,7 +784,7 @@ export default function DatabasePage() {
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="RAW ohne Historie"
|
title="RAW ohne Historie"
|
||||||
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,other}) ohne zugehörigen Job können hier importiert werden"
|
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,cd,audiobook,other}) ohne zugehörigen Job können hier importiert werden"
|
||||||
>
|
>
|
||||||
<div className="table-filters">
|
<div className="table-filters">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const MEDIA_FILTER_OPTIONS = [
|
|||||||
{ label: 'Blu-ray', value: 'bluray' },
|
{ label: 'Blu-ray', value: 'bluray' },
|
||||||
{ label: 'DVD', value: 'dvd' },
|
{ label: 'DVD', value: 'dvd' },
|
||||||
{ label: 'Audio CD', value: 'cd' },
|
{ label: 'Audio CD', value: 'cd' },
|
||||||
|
{ label: 'Audiobook', value: 'audiobook' },
|
||||||
{ label: 'Sonstiges', value: 'other' }
|
{ label: 'Sonstiges', value: 'other' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -80,6 +81,9 @@ function resolveMediaType(row) {
|
|||||||
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const statusCandidates = [
|
const statusCandidates = [
|
||||||
row?.status,
|
row?.status,
|
||||||
@@ -100,6 +104,12 @@ function resolveMediaType(row) {
|
|||||||
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
|
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
|
if (String(row?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'audiobook_encode') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
|
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +139,14 @@ function resolveMediaTypeMeta(row) {
|
|||||||
alt: 'Audio CD'
|
alt: 'Audio CD'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (mediaType === 'audiobook') {
|
||||||
|
return {
|
||||||
|
mediaType,
|
||||||
|
icon: otherIndicatorIcon,
|
||||||
|
label: 'Audiobook',
|
||||||
|
alt: 'Audiobook'
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
mediaType,
|
mediaType,
|
||||||
icon: otherIndicatorIcon,
|
icon: otherIndicatorIcon,
|
||||||
@@ -216,12 +234,47 @@ function resolveCdDetails(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAudiobookDetails(row) {
|
||||||
|
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
|
||||||
|
const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object'
|
||||||
|
? row.makemkvInfo.selectedMetadata
|
||||||
|
: (encodePlan?.metadata && typeof encodePlan.metadata === 'object' ? encodePlan.metadata : {});
|
||||||
|
const chapters = Array.isArray(selectedMetadata?.chapters)
|
||||||
|
? selectedMetadata.chapters
|
||||||
|
: (Array.isArray(row?.makemkvInfo?.chapters) ? row.makemkvInfo.chapters : []);
|
||||||
|
const format = String(
|
||||||
|
row?.handbrakeInfo?.format
|
||||||
|
|| encodePlan?.format
|
||||||
|
|| ''
|
||||||
|
).trim().toLowerCase() || null;
|
||||||
|
return {
|
||||||
|
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
|
||||||
|
narrator: String(selectedMetadata?.narrator || '').trim() || null,
|
||||||
|
chapterCount: chapters.length,
|
||||||
|
formatLabel: format ? format.toUpperCase() : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getOutputLabelForRow(row) {
|
function getOutputLabelForRow(row) {
|
||||||
return resolveMediaType(row) === 'cd' ? 'Audio-Dateien' : 'Movie-Datei(en)';
|
const mediaType = resolveMediaType(row);
|
||||||
|
if (mediaType === 'cd') {
|
||||||
|
return 'Audio-Dateien';
|
||||||
|
}
|
||||||
|
if (mediaType === 'audiobook') {
|
||||||
|
return 'Audiobook-Datei(en)';
|
||||||
|
}
|
||||||
|
return 'Movie-Datei(en)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOutputShortLabelForRow(row) {
|
function getOutputShortLabelForRow(row) {
|
||||||
return resolveMediaType(row) === 'cd' ? 'Audio' : 'Movie';
|
const mediaType = resolveMediaType(row);
|
||||||
|
if (mediaType === 'cd') {
|
||||||
|
return 'Audio';
|
||||||
|
}
|
||||||
|
if (mediaType === 'audiobook') {
|
||||||
|
return 'Audiobook';
|
||||||
|
}
|
||||||
|
return 'Movie';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
function normalizeJobId(value) {
|
||||||
@@ -760,7 +813,7 @@ export default function HistoryPage() {
|
|||||||
if (row?.poster_url && row.poster_url !== 'N/A') {
|
if (row?.poster_url && row.poster_url !== 'N/A') {
|
||||||
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
|
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
|
||||||
}
|
}
|
||||||
return <div className="history-dv-poster-fallback">{mediaMeta.mediaType === 'cd' ? 'Kein Cover' : 'Kein Poster'}</div>;
|
return <div className="history-dv-poster-fallback">{['cd', 'audiobook'].includes(mediaMeta.mediaType) ? 'Kein Cover' : 'Kein Poster'}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPresenceChip = (label, available) => (
|
const renderPresenceChip = (label, available) => (
|
||||||
@@ -803,6 +856,32 @@ export default function HistoryPage() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolveMediaType(row) === 'audiobook') {
|
||||||
|
const audiobookDetails = resolveAudiobookDetails(row);
|
||||||
|
const infoItems = [];
|
||||||
|
if (audiobookDetails.author) {
|
||||||
|
infoItems.push({ key: 'author', label: 'Autor', value: audiobookDetails.author });
|
||||||
|
}
|
||||||
|
if (audiobookDetails.narrator) {
|
||||||
|
infoItems.push({ key: 'narrator', label: 'Sprecher', value: audiobookDetails.narrator });
|
||||||
|
}
|
||||||
|
if (audiobookDetails.chapterCount > 0) {
|
||||||
|
infoItems.push({ key: 'chapters', label: 'Kapitel', value: String(audiobookDetails.chapterCount) });
|
||||||
|
}
|
||||||
|
if (audiobookDetails.formatLabel) {
|
||||||
|
infoItems.push({ key: 'format', label: 'Format', value: audiobookDetails.formatLabel });
|
||||||
|
}
|
||||||
|
if (infoItems.length === 0) {
|
||||||
|
return <span className="history-dv-subtle">Keine Audiobook-Details</span>;
|
||||||
|
}
|
||||||
|
return infoItems.map((item) => (
|
||||||
|
<span key={`${row?.id}-${item.key}`} className="history-dv-rating-chip">
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<span>{item.value}</span>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
const ratings = resolveRatings(row);
|
const ratings = resolveRatings(row);
|
||||||
if (ratings.length === 0) {
|
if (ratings.length === 0) {
|
||||||
return <span className="history-dv-subtle">Keine Ratings</span>;
|
return <span className="history-dv-subtle">Keine Ratings</span>;
|
||||||
|
|||||||
+960
@@ -0,0 +1,960 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Ripster – Installationsskript
|
||||||
|
# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
|
||||||
|
# Benötigt: sudo / root
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# chmod +x install.sh
|
||||||
|
# sudo ./install.sh [Optionen]
|
||||||
|
#
|
||||||
|
# Optionen:
|
||||||
|
# --dir <pfad> Installationsverzeichnis (Standard: /opt/ripster)
|
||||||
|
# --user <benutzer> Systembenutzer für den Dienst (Standard: ripster)
|
||||||
|
# --port <port> Backend-Port (Standard: 3001)
|
||||||
|
# --host <hostname> Hostname/IP für die Weboberfläche (Standard: Maschinen-IP)
|
||||||
|
# --no-makemkv MakeMKV-Installation überspringen
|
||||||
|
# --no-handbrake HandBrake-Installation überspringen
|
||||||
|
# --build-handbrake HandBrake aus Quellcode mit NVDEC-Unterstützung bauen
|
||||||
|
# --handbrake-version HandBrake-Version für Source-Build (Standard: 1.9.0)
|
||||||
|
# --handbrake-update-policy <keep|prompt|build>
|
||||||
|
# Bei NVDEC-Self-Build: bei neuer Git-Release behalten,
|
||||||
|
# nachfragen oder direkt neu bauen (Standard: keep)
|
||||||
|
# --no-nginx Nginx-Einrichtung überspringen (Frontend läuft dann auf Port 5173)
|
||||||
|
# --reinstall Vorhandene Installation ersetzen (Daten bleiben erhalten)
|
||||||
|
# -h, --help Diese Hilfe anzeigen
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Farben -------------------------------------------------------------------
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
||||||
|
error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; }
|
||||||
|
header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \
|
||||||
|
echo -e "${BOLD} $*${RESET}"; \
|
||||||
|
echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; }
|
||||||
|
fatal() { error "$*"; exit 1; }
|
||||||
|
|
||||||
|
# --- Standard-Optionen --------------------------------------------------------
|
||||||
|
INSTALL_DIR="/opt/ripster"
|
||||||
|
SERVICE_USER="ripster"
|
||||||
|
BACKEND_PORT="3001"
|
||||||
|
FRONTEND_HOST="" # wird automatisch ermittelt, wenn leer
|
||||||
|
SKIP_MAKEMKV=false
|
||||||
|
SKIP_HANDBRAKE=false
|
||||||
|
BUILD_HANDBRAKE_NVDEC=false
|
||||||
|
HANDBRAKE_MODE_SELECTED=false
|
||||||
|
HANDBRAKE_VERSION="1.9.0"
|
||||||
|
HANDBRAKE_UPDATE_POLICY="keep"
|
||||||
|
HANDBRAKE_UPDATE_POLICY_SELECTED=false
|
||||||
|
SKIP_NGINX=false
|
||||||
|
REINSTALL=false
|
||||||
|
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
HANDBRAKE_SELFBUILD_MARKER="/usr/local/share/ripster/handbrake-selfbuild.env"
|
||||||
|
|
||||||
|
# --- Argumente parsen ---------------------------------------------------------
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||||
|
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||||
|
--port) BACKEND_PORT="$2"; shift 2 ;;
|
||||||
|
--host) FRONTEND_HOST="$2"; shift 2 ;;
|
||||||
|
--no-makemkv) SKIP_MAKEMKV=true; shift ;;
|
||||||
|
--no-handbrake) SKIP_HANDBRAKE=true; shift ;;
|
||||||
|
--build-handbrake) BUILD_HANDBRAKE_NVDEC=true; HANDBRAKE_MODE_SELECTED=true; shift ;;
|
||||||
|
--handbrake-version) HANDBRAKE_VERSION="$2"; shift 2 ;;
|
||||||
|
--handbrake-update-policy)
|
||||||
|
case "$2" in
|
||||||
|
keep|prompt|build) HANDBRAKE_UPDATE_POLICY="$2"; HANDBRAKE_UPDATE_POLICY_SELECTED=true ;;
|
||||||
|
*) fatal "Ungültige --handbrake-update-policy: $2 (erlaubt: keep|prompt|build)" ;;
|
||||||
|
esac
|
||||||
|
shift 2 ;;
|
||||||
|
--no-nginx) SKIP_NGINX=true; shift ;;
|
||||||
|
--reinstall) REINSTALL=true; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//'
|
||||||
|
exit 0 ;;
|
||||||
|
*) fatal "Unbekannte Option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Voraussetzungen prüfen ---------------------------------------------------
|
||||||
|
header "Ripster Installationsskript"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
fatal "Dieses Skript muss als root ausgeführt werden (sudo ./install.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# OS-Erkennung
|
||||||
|
if [[ ! -f /etc/os-release ]]; then
|
||||||
|
fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt."
|
||||||
|
fi
|
||||||
|
. /etc/os-release
|
||||||
|
case "$ID" in
|
||||||
|
debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;;
|
||||||
|
*) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Host-IP ermitteln
|
||||||
|
if [[ -z "$FRONTEND_HOST" ]]; then
|
||||||
|
FRONTEND_HOST=$(hostname -I | awk '{print $1}')
|
||||||
|
info "Erkannte IP: $FRONTEND_HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Quelldirectory prüfen
|
||||||
|
[[ -f "$SOURCE_DIR/backend/package.json" ]] || \
|
||||||
|
fatal "Ripster-Quellen nicht gefunden in: $SOURCE_DIR"
|
||||||
|
|
||||||
|
info "Quellverzeichnis: $SOURCE_DIR"
|
||||||
|
info "Installationsverzeichnis: $INSTALL_DIR"
|
||||||
|
info "Systembenutzer: $SERVICE_USER"
|
||||||
|
info "Backend-Port: $BACKEND_PORT"
|
||||||
|
info "Frontend-Host: $FRONTEND_HOST"
|
||||||
|
|
||||||
|
# --- Hilfsfunktionen ----------------------------------------------------------
|
||||||
|
|
||||||
|
command_exists() { command -v "$1" &>/dev/null; }
|
||||||
|
|
||||||
|
install_node() {
|
||||||
|
header "Node.js installieren"
|
||||||
|
local required_major=20
|
||||||
|
|
||||||
|
if command_exists node; then
|
||||||
|
local current_major
|
||||||
|
current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))")
|
||||||
|
if [[ "$current_major" -ge "$required_major" ]]; then
|
||||||
|
ok "Node.js $(node --version) bereits installiert"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
warn "Node.js $(node --version) zu alt – Node.js 20 wird installiert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Installiere Node.js 20.x über NodeSource..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
ok "Node.js $(node --version) installiert"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_makemkv() {
|
||||||
|
header "MakeMKV installieren"
|
||||||
|
|
||||||
|
if command_exists makemkvcon; then
|
||||||
|
local mk_version_line
|
||||||
|
mk_version_line="$(makemkvcon --version 2>&1 | head -1 || true)"
|
||||||
|
if [[ -z "$mk_version_line" || "$mk_version_line" == *"unrecognized option"* ]]; then
|
||||||
|
mk_version_line="$(makemkvcon 2>&1 | head -1 || true)"
|
||||||
|
fi
|
||||||
|
ok "makemkvcon bereits installiert (${mk_version_line:-Version unbekannt})"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Installiere Build-Abhängigkeiten für MakeMKV..."
|
||||||
|
apt-get install -y \
|
||||||
|
build-essential pkg-config libc6-dev libssl-dev \
|
||||||
|
libexpat1-dev libavcodec-dev libgl1-mesa-dev \
|
||||||
|
qtbase5-dev zlib1g-dev wget
|
||||||
|
|
||||||
|
local makemkv_fallback="1.18.3"
|
||||||
|
info "Ermittle aktuelle MakeMKV-Version (forum.makemkv.com)..."
|
||||||
|
local makemkv_version
|
||||||
|
makemkv_version=$(curl -s --max-time 15 \
|
||||||
|
"https://forum.makemkv.com/forum/viewtopic.php?f=3&t=224" \
|
||||||
|
| grep -oP 'MakeMKV \K[0-9]+\.[0-9]+\.[0-9]+(?= for Linux)' | head -1 || true)
|
||||||
|
|
||||||
|
if [[ -z "$makemkv_version" ]]; then
|
||||||
|
warn "MakeMKV-Version konnte nicht ermittelt werden – verwende Fallback $makemkv_fallback"
|
||||||
|
makemkv_version="$makemkv_fallback"
|
||||||
|
else
|
||||||
|
info "Gefundene Version: $makemkv_version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Baue MakeMKV $makemkv_version..."
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir=$(mktemp -d)
|
||||||
|
cd "$tmp_dir"
|
||||||
|
|
||||||
|
local base_url="https://www.makemkv.com/download"
|
||||||
|
wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz"
|
||||||
|
wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz"
|
||||||
|
|
||||||
|
tar xf "makemkv-oss-${makemkv_version}.tar.gz"
|
||||||
|
cd "makemkv-oss-${makemkv_version}"
|
||||||
|
./configure
|
||||||
|
make -j"$(nproc)"
|
||||||
|
make install
|
||||||
|
|
||||||
|
cd "$tmp_dir"
|
||||||
|
tar xf "makemkv-bin-${makemkv_version}.tar.gz"
|
||||||
|
cd "makemkv-bin-${makemkv_version}"
|
||||||
|
mkdir -p tmp && echo "accepted" > tmp/eula_accepted
|
||||||
|
make -j"$(nproc)"
|
||||||
|
make install
|
||||||
|
|
||||||
|
cd /
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
ok "MakeMKV $makemkv_version installiert"
|
||||||
|
warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key."
|
||||||
|
warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053"
|
||||||
|
}
|
||||||
|
|
||||||
|
handbrake_has_nvdec() {
|
||||||
|
command_exists HandBrakeCLI || return 1
|
||||||
|
HandBrakeCLI --help 2>&1 | grep -qi "nvdec"
|
||||||
|
}
|
||||||
|
|
||||||
|
handbrake_installed_version() {
|
||||||
|
command_exists HandBrakeCLI || return 1
|
||||||
|
HandBrakeCLI --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+){1,3}' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
handbrake_latest_git_version() {
|
||||||
|
local latest=""
|
||||||
|
latest=$(curl -fsSL --max-time 10 "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" 2>/dev/null \
|
||||||
|
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \
|
||||||
|
| head -1 \
|
||||||
|
| sed -E 's/.*"([^"]+)".*/\1/' \
|
||||||
|
| sed 's/^v//')
|
||||||
|
[[ -n "$latest" ]] || return 1
|
||||||
|
[[ "$latest" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]] || return 1
|
||||||
|
printf '%s\n' "$latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
handbrake_is_self_built() {
|
||||||
|
local hb_path="${1:-$(command -v HandBrakeCLI 2>/dev/null || true)}"
|
||||||
|
local resolved_path=""
|
||||||
|
[[ -n "$hb_path" ]] || return 1
|
||||||
|
[[ "$hb_path" == "/usr/local/bin/HandBrakeCLI" ]] || return 1
|
||||||
|
[[ -f "$HANDBRAKE_SELFBUILD_MARKER" ]] && return 0
|
||||||
|
resolved_path=$(readlink -f "$hb_path" 2>/dev/null || true)
|
||||||
|
dpkg -S "$hb_path" >/dev/null 2>&1 && return 1
|
||||||
|
[[ -n "$resolved_path" ]] && dpkg -S "$resolved_path" >/dev/null 2>&1 && return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_non_selfbuilt_handbrake() {
|
||||||
|
info "Entferne nicht-selbst-gebaute HandBrake-Installationen..."
|
||||||
|
apt-get remove -y handbrake-cli handbrake 2>/dev/null || true
|
||||||
|
snap remove handbrake-cli 2>/dev/null || true
|
||||||
|
|
||||||
|
rm -f /usr/bin/HandBrakeCLI \
|
||||||
|
/snap/bin/handbrake-cli \
|
||||||
|
/snap/bin/HandBrakeCLI
|
||||||
|
|
||||||
|
if [[ -e /usr/local/bin/HandBrakeCLI ]] && ! handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then
|
||||||
|
warn "Entferne fremdes /usr/local/bin/HandBrakeCLI"
|
||||||
|
rm -f /usr/local/bin/HandBrakeCLI
|
||||||
|
fi
|
||||||
|
|
||||||
|
hash -r 2>/dev/null || true
|
||||||
|
ok "Bereinigung abgeschlossen"
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_selfbuilt_handbrake() {
|
||||||
|
if [[ -e /usr/local/bin/HandBrakeCLI ]] && handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then
|
||||||
|
warn "Entferne selbst gebautes /usr/local/bin/HandBrakeCLI"
|
||||||
|
rm -f /usr/local/bin/HandBrakeCLI
|
||||||
|
fi
|
||||||
|
rm -f "$HANDBRAKE_SELFBUILD_MARKER"
|
||||||
|
hash -r 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
build_handbrake_nvdec() {
|
||||||
|
header "HandBrake ${HANDBRAKE_VERSION} mit NVDEC aus Quellcode bauen"
|
||||||
|
|
||||||
|
local cache_dir="/var/cache/ripster/handbrake"
|
||||||
|
local src_url="https://github.com/HandBrake/HandBrake/releases/download/${HANDBRAKE_VERSION}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2"
|
||||||
|
local tarball="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2"
|
||||||
|
local src_dir="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}"
|
||||||
|
|
||||||
|
if [[ -t 0 && -d "$cache_dir" ]]; then
|
||||||
|
local clear_cache_answer=""
|
||||||
|
warn "Build-Cache gefunden: $cache_dir"
|
||||||
|
warn "Wenn du ihn löschst, startet der Source-Build wieder komplett von vorne."
|
||||||
|
read -r -p "Build-Cache jetzt löschen (sudo rm -rf $cache_dir)? [y/N] " clear_cache_answer
|
||||||
|
case "${clear_cache_answer,,}" in
|
||||||
|
y|yes|j|ja)
|
||||||
|
rm -rf "$cache_dir"
|
||||||
|
info "Build-Cache gelöscht."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
mkdir -p "$cache_dir"
|
||||||
|
|
||||||
|
# Build-Abhängigkeiten
|
||||||
|
info "Installiere Build-Abhängigkeiten..."
|
||||||
|
apt-get install -y \
|
||||||
|
autoconf automake build-essential clang cmake git \
|
||||||
|
libass-dev libbz2-dev libdvdnav-dev libdvdread-dev \
|
||||||
|
libfontconfig-dev libfreetype-dev libfribidi-dev libharfbuzz-dev \
|
||||||
|
libjansson-dev liblzma-dev libmp3lame-dev libnuma-dev libogg-dev \
|
||||||
|
libopus-dev libsamplerate0-dev libspeex-dev libtheora-dev libtool libtool-bin libva-dev \
|
||||||
|
libturbojpeg0-dev libvorbis-dev libvpx-dev libx264-dev libxml2-dev \
|
||||||
|
m4 meson nasm ninja-build patch pkg-config python3 tar yasm zlib1g-dev \
|
||||||
|
>/dev/null
|
||||||
|
|
||||||
|
# CUDA Toolkit für NVDEC-Header
|
||||||
|
info "Installiere CUDA Toolkit (für NVDEC-Header)..."
|
||||||
|
if ! dpkg -l 2>/dev/null | grep -q '^ii.*nvidia-cuda-toolkit'; then
|
||||||
|
apt-get install -y nvidia-cuda-toolkit >/dev/null 2>&1 || {
|
||||||
|
warn "nvidia-cuda-toolkit nicht verfügbar – versuche Fallback-Header..."
|
||||||
|
local cuda_keyring="/tmp/cuda-keyring.deb"
|
||||||
|
local ubuntu_ver="${VERSION_ID//./}"
|
||||||
|
curl -fsSL "https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${ubuntu_ver}/x86_64/cuda-keyring_1.1-1_all.deb" \
|
||||||
|
-o "$cuda_keyring" 2>/dev/null && \
|
||||||
|
dpkg -i "$cuda_keyring" 2>/dev/null && \
|
||||||
|
apt-get update -qq && \
|
||||||
|
apt-get install -y cuda-cudart-dev-12-8 >/dev/null 2>&1 || \
|
||||||
|
warn "CUDA-Header konnten nicht installiert werden – NVDEC wird möglicherweise nicht verfügbar sein."
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
ok "Build-Abhängigkeiten installiert"
|
||||||
|
|
||||||
|
if [[ ! -d "$src_dir" ]]; then
|
||||||
|
if [[ ! -f "$tarball" ]]; then
|
||||||
|
info "Lade HandBrake ${HANDBRAKE_VERSION} herunter..."
|
||||||
|
curl -fsSL "$src_url" -o "$tarball" 2>/dev/null || \
|
||||||
|
wget -q "$src_url" -O "$tarball" || \
|
||||||
|
fatal "HandBrake-Quellcode konnte nicht heruntergeladen werden (${src_url})"
|
||||||
|
else
|
||||||
|
info "Nutze vorhandenes HandBrake-Source-Archiv: $tarball"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Entpacke Quellcode..."
|
||||||
|
tar xjf "$tarball" -C "$cache_dir"
|
||||||
|
[[ -d "$src_dir" ]] || src_dir=$(find "$cache_dir" -maxdepth 1 -type d -name "HandBrake*" | head -1)
|
||||||
|
[[ -d "$src_dir" ]] || fatal "HandBrake-Quellverzeichnis nicht gefunden in $cache_dir"
|
||||||
|
else
|
||||||
|
info "Nutze vorhandenen HandBrake-Source-Baum: $src_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$src_dir"
|
||||||
|
|
||||||
|
local configure_log="${src_dir}/build/configure-ripster.log"
|
||||||
|
local configure_stamp="${src_dir}/build/.ripster-config"
|
||||||
|
local configure_args="--enable-nvdec --disable-gtk --prefix=/usr/local"
|
||||||
|
local need_configure="false"
|
||||||
|
local configure_cmd=(
|
||||||
|
./configure
|
||||||
|
--launch-jobs="$(nproc)"
|
||||||
|
--enable-nvdec
|
||||||
|
--disable-gtk
|
||||||
|
--prefix=/usr/local
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ ! -f "$src_dir/build/GNUmakefile" ]]; then
|
||||||
|
need_configure="true"
|
||||||
|
elif [[ ! -f "$configure_stamp" ]]; then
|
||||||
|
need_configure="true"
|
||||||
|
elif ! grep -qx "args=${configure_args}" "$configure_stamp"; then
|
||||||
|
need_configure="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$need_configure" == "true" ]]; then
|
||||||
|
if [[ -d "$src_dir/build" ]]; then
|
||||||
|
configure_cmd=(./configure --force "${configure_cmd[@]:1}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$src_dir/build/GNUmakefile" ]]; then
|
||||||
|
info "Vorhandener Build gefunden – aktualisiere Konfiguration (CLI-only)."
|
||||||
|
else
|
||||||
|
info "Konfiguriere HandBrake mit NVDEC (CLI-only)..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! "${configure_cmd[@]}" >"$configure_log" 2>&1; then
|
||||||
|
tail -n 50 "$configure_log" >&2 || true
|
||||||
|
fatal "HandBrake-Konfiguration fehlgeschlagen. Vollständiges Log: $configure_log"
|
||||||
|
fi
|
||||||
|
tail -n 10 "$configure_log"
|
||||||
|
|
||||||
|
mkdir -p "${src_dir}/build"
|
||||||
|
cat > "$configure_stamp" <<EOF
|
||||||
|
args=${configure_args}
|
||||||
|
version=${HANDBRAKE_VERSION}
|
||||||
|
configured_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
info "Vorhandene CLI-only-Konfiguration erkannt – überspringe configure."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -x "$src_dir/build/HandBrakeCLI" ]]; then
|
||||||
|
info "Vorhandenes HandBrakeCLI-Build-Artefakt gefunden – versuche direkte Installation."
|
||||||
|
if ! make --directory=build install; then
|
||||||
|
warn "Direkte Installation fehlgeschlagen – setze Build fort."
|
||||||
|
make --directory=build -j"$(nproc)"
|
||||||
|
make --directory=build install
|
||||||
|
fi
|
||||||
|
hash -r 2>/dev/null || true
|
||||||
|
if ! command_exists HandBrakeCLI; then
|
||||||
|
warn "HandBrakeCLI nach direkter Installation nicht gefunden – setze Build fort."
|
||||||
|
make --directory=build -j"$(nproc)"
|
||||||
|
make --directory=build install
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Baue HandBrake ($(nproc) Threads – bitte warten)..."
|
||||||
|
make --directory=build -j"$(nproc)"
|
||||||
|
info "Installiere HandBrake nach /usr/local/bin..."
|
||||||
|
make --directory=build install
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /
|
||||||
|
hash -r 2>/dev/null || true
|
||||||
|
|
||||||
|
if command_exists HandBrakeCLI; then
|
||||||
|
local ver
|
||||||
|
ver=$(HandBrakeCLI --version 2>&1 | head -1)
|
||||||
|
handbrake_has_nvdec || fatal "HandBrakeCLI ist installiert, aber ohne NVDEC-Unterstützung."
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$HANDBRAKE_SELFBUILD_MARKER")"
|
||||||
|
cat > "$HANDBRAKE_SELFBUILD_MARKER" <<EOF
|
||||||
|
version=${HANDBRAKE_VERSION}
|
||||||
|
source_dir=${src_dir}
|
||||||
|
binary_path=$(command -v HandBrakeCLI)
|
||||||
|
installed_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ok "HandBrakeCLI mit NVDEC installiert: ${ver}"
|
||||||
|
if ldconfig -p 2>/dev/null | grep -q libnvcuvid; then
|
||||||
|
ok "libnvcuvid gefunden – NVDEC ist zur Laufzeit verfügbar."
|
||||||
|
else
|
||||||
|
warn "libnvcuvid NICHT gefunden. NVDEC benötigt den installierten NVIDIA-Treiber."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fatal "HandBrakeCLI nach dem Build nicht gefunden – Build fehlgeschlagen."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
has_nvidia_gpu() {
|
||||||
|
[[ -e /dev/nvidia0 ]] && return 0
|
||||||
|
command_exists nvidia-smi && nvidia-smi &>/dev/null && return 0
|
||||||
|
command_exists lspci && lspci 2>/dev/null | grep -qi "nvidia" && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_handbrake() {
|
||||||
|
header "HandBrake CLI installieren"
|
||||||
|
|
||||||
|
local hb_path
|
||||||
|
local current_mode="none"
|
||||||
|
hb_path=$(command -v HandBrakeCLI 2>/dev/null || true)
|
||||||
|
if [[ -n "$hb_path" ]]; then
|
||||||
|
if handbrake_has_nvdec; then
|
||||||
|
current_mode="nvdec"
|
||||||
|
else
|
||||||
|
current_mode="standard"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$BUILD_HANDBRAKE_NVDEC" == true ]]; then
|
||||||
|
info "Installmodus: Source-Build mit NVDEC-Support"
|
||||||
|
if has_nvidia_gpu; then
|
||||||
|
info "NVIDIA-GPU erkannt – NVDEC-Build wird verwendet."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if handbrake_has_nvdec; then
|
||||||
|
if handbrake_is_self_built "$hb_path"; then
|
||||||
|
local installed_ver latest_ver answer
|
||||||
|
installed_ver=$(handbrake_installed_version || true)
|
||||||
|
latest_ver=$(handbrake_latest_git_version || true)
|
||||||
|
|
||||||
|
if [[ -n "$installed_ver" && -n "$latest_ver" ]] && dpkg --compare-versions "$latest_ver" gt "$installed_ver"; then
|
||||||
|
case "$HANDBRAKE_UPDATE_POLICY" in
|
||||||
|
keep)
|
||||||
|
warn "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – behalte aktuelle Installation."
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
prompt)
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
read -r -p "Neue HandBrake-Version ${latest_ver} verfügbar (installiert ${installed_ver}). Neu bauen? [y/N] " answer
|
||||||
|
case "${answer,,}" in
|
||||||
|
y|yes|j|ja)
|
||||||
|
info "Aktualisiere auf HandBrake ${latest_ver}."
|
||||||
|
HANDBRAKE_VERSION="$latest_ver"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
info "Behalte bestehende Installation (${installed_ver})."
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
warn "Neuere HandBrake-Version verfügbar (${latest_ver}), aber kein TTY für Rückfrage – behalte aktuelle Installation."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
info "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – aktualisiere."
|
||||||
|
HANDBRAKE_VERSION="$latest_ver"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
ok "Selbst gebautes HandBrakeCLI mit NVDEC bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
warn "HandBrakeCLI mit NVDEC gefunden (${hb_path}), aber nicht als Selbst-Build erkannt."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$hb_path" ]] && ! handbrake_has_nvdec; then
|
||||||
|
warn "HandBrakeCLI ohne NVDEC gefunden (${hb_path}) – wird ersetzt durch Selbst-Build."
|
||||||
|
elif [[ -z "$hb_path" ]]; then
|
||||||
|
info "Kein HandBrakeCLI gefunden – baue aus Quellcode."
|
||||||
|
fi
|
||||||
|
|
||||||
|
remove_non_selfbuilt_handbrake
|
||||||
|
build_handbrake_nvdec
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Installmodus: Standard (APT, ohne NVDEC-Zwang)"
|
||||||
|
if [[ "$current_mode" == "standard" ]] && ! handbrake_is_self_built "$hb_path"; then
|
||||||
|
ok "HandBrakeCLI bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$current_mode" == "nvdec" ]]; then
|
||||||
|
warn "Umschalten von NVDEC-Build auf Standard-Installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
remove_selfbuilt_handbrake
|
||||||
|
remove_non_selfbuilt_handbrake
|
||||||
|
|
||||||
|
info "Installiere HandBrakeCLI aus den Standard-Repositories..."
|
||||||
|
apt_update
|
||||||
|
apt-get install -y handbrake-cli >/dev/null
|
||||||
|
hash -r 2>/dev/null || true
|
||||||
|
|
||||||
|
if command_exists HandBrakeCLI; then
|
||||||
|
ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command_exists handbrake-cli; then
|
||||||
|
ln -sf "$(command -v handbrake-cli)" /usr/local/bin/HandBrakeCLI
|
||||||
|
hash -r 2>/dev/null || true
|
||||||
|
ok "HandBrakeCLI Alias angelegt auf: $(command -v handbrake-cli)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- apt-Hilfsfunktionen ------------------------------------------------------
|
||||||
|
|
||||||
|
apt_update() {
|
||||||
|
local output
|
||||||
|
if output=$(apt-get update 2>&1); then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then
|
||||||
|
warn "apt-Sources fehlerhaft. Versuche Reparatur..."
|
||||||
|
|
||||||
|
if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then
|
||||||
|
ok "apt-Update mit --allow-releaseinfo-change erfolgreich"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${VERSION_CODENAME:-}" ]]; then
|
||||||
|
warn "Schreibe minimale sources.list für $VERSION_CODENAME..."
|
||||||
|
local main_list=/etc/apt/sources.list
|
||||||
|
cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
|
||||||
|
|
||||||
|
case "$ID" in
|
||||||
|
ubuntu)
|
||||||
|
cat > "$main_list" <<EOF
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME} main restricted universe multiverse
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-updates main restricted universe multiverse
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-security main restricted universe multiverse
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
debian)
|
||||||
|
cat > "$main_list" <<EOF
|
||||||
|
deb http://deb.debian.org/debian ${VERSION_CODENAME} main contrib non-free
|
||||||
|
deb http://deb.debian.org/debian ${VERSION_CODENAME}-updates main contrib non-free
|
||||||
|
deb http://security.debian.org/debian-security ${VERSION_CODENAME}-security main contrib non-free
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if apt-get update -qq 2>/dev/null; then
|
||||||
|
ok "apt-Update nach Sources-Reparatur erfolgreich"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..."
|
||||||
|
local broken_files
|
||||||
|
broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \
|
||||||
|
xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true)
|
||||||
|
if [[ -n "$broken_files" ]]; then
|
||||||
|
echo "$broken_files" | while read -r f; do
|
||||||
|
warn "Deaktiviere: $f"
|
||||||
|
mv "$f" "${f}.disabled" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if apt-get update -qq 2>/dev/null; then
|
||||||
|
ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:"
|
||||||
|
echo "$output"
|
||||||
|
fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut."
|
||||||
|
else
|
||||||
|
error "apt-Update fehlgeschlagen:"
|
||||||
|
echo "$output"
|
||||||
|
fatal "Installation abgebrochen."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Systemabhängigkeiten -----------------------------------------------------
|
||||||
|
header "Systemabhängigkeiten installieren"
|
||||||
|
|
||||||
|
info "Paketlisten aktualisieren..."
|
||||||
|
apt_update
|
||||||
|
|
||||||
|
info "Installiere Basispakete..."
|
||||||
|
apt-get install -y \
|
||||||
|
curl wget git \
|
||||||
|
mediainfo \
|
||||||
|
util-linux udev \
|
||||||
|
ca-certificates gnupg \
|
||||||
|
lsb-release
|
||||||
|
|
||||||
|
ok "Basispakete installiert"
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
install_node
|
||||||
|
|
||||||
|
# MakeMKV
|
||||||
|
if [[ "$SKIP_MAKEMKV" == false ]]; then
|
||||||
|
install_makemkv
|
||||||
|
else
|
||||||
|
warn "MakeMKV-Installation übersprungen (--no-makemkv)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HandBrake
|
||||||
|
if [[ "$SKIP_HANDBRAKE" == false ]]; then
|
||||||
|
install_handbrake
|
||||||
|
else
|
||||||
|
warn "HandBrake-Installation übersprungen (--no-handbrake)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nginx
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
if ! command_exists nginx; then
|
||||||
|
info "Installiere nginx..."
|
||||||
|
apt-get install -y nginx
|
||||||
|
fi
|
||||||
|
ok "nginx installiert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Systembenutzer anlegen ---------------------------------------------------
|
||||||
|
header "Systembenutzer anlegen"
|
||||||
|
|
||||||
|
if id "$SERVICE_USER" &>/dev/null; then
|
||||||
|
ok "Benutzer '$SERVICE_USER' existiert bereits"
|
||||||
|
else
|
||||||
|
info "Lege Systembenutzer '$SERVICE_USER' an..."
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||||
|
ok "Benutzer '$SERVICE_USER' angelegt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
|
||||||
|
if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
|
||||||
|
SERVICE_HOME="/home/$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
mkdir -p "$SERVICE_HOME"
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
|
||||||
|
chmod 755 "$SERVICE_HOME" 2>/dev/null || true
|
||||||
|
info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
|
||||||
|
|
||||||
|
# Optisches Laufwerk: Benutzer zur cdrom/optical-Gruppe hinzufügen
|
||||||
|
for grp in cdrom optical disk; do
|
||||||
|
if getent group "$grp" &>/dev/null; then
|
||||||
|
usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
|
||||||
|
info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Dateien kopieren ---------------------------------------------------------
|
||||||
|
header "Ripster-Dateien installieren"
|
||||||
|
|
||||||
|
if [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then
|
||||||
|
fatal "Verzeichnis $INSTALL_DIR existiert bereits.\nVerwende --reinstall um zu überschreiben (Daten bleiben erhalten)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bei Reinstall: Daten sichern
|
||||||
|
if [[ -d "$INSTALL_DIR/backend/data" ]]; then
|
||||||
|
info "Sichere vorhandene Datenbank..."
|
||||||
|
cp -a "$INSTALL_DIR/backend/data" "/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
|
||||||
|
ok "Datenbank gesichert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Kopiere Quellen nach $INSTALL_DIR..."
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='backend/node_modules' \
|
||||||
|
--exclude='frontend/node_modules' \
|
||||||
|
--exclude='backend/data' \
|
||||||
|
--exclude='backend/logs' \
|
||||||
|
--exclude='frontend/dist' \
|
||||||
|
--exclude='*.sh' \
|
||||||
|
--exclude='deploy-ripster.sh' \
|
||||||
|
--exclude='debug/' \
|
||||||
|
--exclude='site/' \
|
||||||
|
--exclude='docs/' \
|
||||||
|
"$SOURCE_DIR/" "$INSTALL_DIR/"
|
||||||
|
|
||||||
|
# Datenbank-/Log-Verzeichnisse anlegen
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/logs"
|
||||||
|
|
||||||
|
# Bei Reinstall: Daten wiederherstellen
|
||||||
|
if [[ -d "$INSTALL_DIR/../ripster-data-backup" ]]; then
|
||||||
|
cp -a /tmp/ripster-data-backup-*/ "$INSTALL_DIR/backend/data/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Dateien kopiert"
|
||||||
|
|
||||||
|
# --- npm-Abhängigkeiten installieren -----------------------------------------
|
||||||
|
header "npm-Abhängigkeiten installieren"
|
||||||
|
|
||||||
|
info "Installiere Root-Abhängigkeiten..."
|
||||||
|
npm install --prefix "$INSTALL_DIR" --omit=dev --silent
|
||||||
|
|
||||||
|
info "Installiere Backend-Abhängigkeiten..."
|
||||||
|
npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent
|
||||||
|
|
||||||
|
info "Installiere Frontend-Abhängigkeiten..."
|
||||||
|
npm install --prefix "$INSTALL_DIR/frontend" --silent
|
||||||
|
|
||||||
|
ok "npm-Abhängigkeiten installiert"
|
||||||
|
|
||||||
|
# --- Frontend bauen -----------------------------------------------------------
|
||||||
|
header "Frontend bauen"
|
||||||
|
|
||||||
|
info "Baue Frontend für $FRONTEND_HOST..."
|
||||||
|
|
||||||
|
# Relative URLs verwenden – funktioniert mit jedem Hostnamen/Domain, da nginx
|
||||||
|
# /api/ und /ws auf dem selben Host proxied. Absolute IP-URLs würden Chromes
|
||||||
|
# Private Network Access (PNA) Policy verletzen, wenn das Frontend über einen
|
||||||
|
# Domainnamen aufgerufen wird.
|
||||||
|
rm -f "$INSTALL_DIR/frontend/.env.production.local"
|
||||||
|
|
||||||
|
npm run build --prefix "$INSTALL_DIR/frontend" --silent
|
||||||
|
ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
|
||||||
|
|
||||||
|
# --- Backend-Konfiguration ---------------------------------------------------
|
||||||
|
header "Backend konfigurieren"
|
||||||
|
|
||||||
|
ENV_FILE="$INSTALL_DIR/backend/.env"
|
||||||
|
|
||||||
|
if [[ -f "$ENV_FILE" && "$REINSTALL" == false ]]; then
|
||||||
|
warn "Backend .env existiert bereits – wird nicht überschrieben"
|
||||||
|
else
|
||||||
|
info "Erstelle Backend .env..."
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
# Ripster Backend – Konfiguration
|
||||||
|
# Generiert von install.sh am $(date)
|
||||||
|
|
||||||
|
PORT=${BACKEND_PORT}
|
||||||
|
DB_PATH=./data/ripster.db
|
||||||
|
LOG_DIR=./logs
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
||||||
|
CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||||
|
EOF
|
||||||
|
ok "Backend .env erstellt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Berechtigungen setzen ---------------------------------------------------
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
|
||||||
|
chmod -R 755 "$INSTALL_DIR"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
||||||
|
MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
|
||||||
|
if [[ ! -d "$MAKEMKV_SERVICE_DIR" ]]; then
|
||||||
|
mkdir -p "$MAKEMKV_SERVICE_DIR"
|
||||||
|
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_SERVICE_DIR"
|
||||||
|
else
|
||||||
|
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_SERVICE_DIR"
|
||||||
|
fi
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
||||||
|
chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- Systemd-Dienst: Backend -------------------------------------------------
|
||||||
|
header "Systemd-Dienst (Backend) erstellen"
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/ripster-backend.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Ripster Backend API
|
||||||
|
Documentation=https://github.com/your-repo/ripster
|
||||||
|
After=network.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${SERVICE_USER}
|
||||||
|
Group=${SERVICE_USER}
|
||||||
|
WorkingDirectory=${INSTALL_DIR}/backend
|
||||||
|
ExecStart=$(command -v node) src/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=3
|
||||||
|
|
||||||
|
# Umgebung
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=HOME=${SERVICE_HOME}
|
||||||
|
Environment=LANG=C.UTF-8
|
||||||
|
Environment=LC_ALL=C.UTF-8
|
||||||
|
Environment=LANGUAGE=C.UTF-8
|
||||||
|
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=ripster-backend
|
||||||
|
|
||||||
|
# Sicherheit
|
||||||
|
# Für Skriptausführung via GUI (inkl. optionalem sudo in User-Skripten)
|
||||||
|
# darf no_new_privileges nicht aktiv sein.
|
||||||
|
NoNewPrivileges=false
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ok "ripster-backend.service erstellt"
|
||||||
|
|
||||||
|
# --- nginx konfigurieren -----------------------------------------------------
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
header "nginx konfigurieren"
|
||||||
|
|
||||||
|
cat > /etc/nginx/sites-available/ripster <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ${FRONTEND_HOST} _;
|
||||||
|
|
||||||
|
# Frontend (statische Dateien)
|
||||||
|
root ${INSTALL_DIR}/frontend/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA: alle unbekannten Pfade → index.html
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API → Backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket → Backend
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:${BACKEND_PORT}/ws;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Alte Default-Seite deaktivieren, Ripster aktivieren
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
ln -sf /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ripster
|
||||||
|
|
||||||
|
nginx -t && ok "nginx-Konfiguration gültig" || fatal "nginx-Konfiguration fehlerhaft!"
|
||||||
|
|
||||||
|
ok "nginx konfiguriert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Dienste aktivieren und starten ------------------------------------------
|
||||||
|
header "Dienste starten"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
systemctl enable ripster-backend
|
||||||
|
systemctl restart ripster-backend
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if systemctl is-active --quiet ripster-backend; then
|
||||||
|
ok "ripster-backend läuft"
|
||||||
|
else
|
||||||
|
error "ripster-backend konnte nicht gestartet werden!"
|
||||||
|
journalctl -u ripster-backend -n 30 --no-pager
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
systemctl enable nginx
|
||||||
|
systemctl restart nginx
|
||||||
|
if systemctl is-active --quiet nginx; then
|
||||||
|
ok "nginx läuft"
|
||||||
|
else
|
||||||
|
error "nginx konnte nicht gestartet werden!"
|
||||||
|
journalctl -u nginx -n 20 --no-pager
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Zusammenfassung ----------------------------------------------------------
|
||||||
|
header "Installation abgeschlossen!"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}${BOLD}Ripster ist installiert und läuft.${RESET}"
|
||||||
|
echo ""
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
echo -e " ${BOLD}Weboberfläche:${RESET} http://${FRONTEND_HOST}"
|
||||||
|
else
|
||||||
|
echo -e " ${BOLD}Backend API:${RESET} http://${FRONTEND_HOST}:${BACKEND_PORT}/api"
|
||||||
|
warn "nginx deaktiviert – Frontend nicht automatisch erreichbar."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Dienste verwalten:${RESET}"
|
||||||
|
echo -e " sudo systemctl status ripster-backend"
|
||||||
|
echo -e " sudo systemctl restart ripster-backend"
|
||||||
|
echo -e " sudo systemctl stop ripster-backend"
|
||||||
|
echo -e " sudo journalctl -u ripster-backend -f"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Konfiguration:${RESET} $INSTALL_DIR/backend/.env"
|
||||||
|
echo -e " ${BOLD}Datenbank:${RESET} $INSTALL_DIR/backend/data/ripster.db"
|
||||||
|
echo -e " ${BOLD}Logs:${RESET} $INSTALL_DIR/backend/logs/"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Warnungen zu fehlenden Tools
|
||||||
|
missing_tools=()
|
||||||
|
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
||||||
|
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
||||||
|
command_exists mediainfo || missing_tools+=("mediainfo")
|
||||||
|
|
||||||
|
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
||||||
|
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
|
||||||
|
for t in "${missing_tools[@]}"; do
|
||||||
|
echo -e " ${YELLOW}✗${RESET} $t"
|
||||||
|
done
|
||||||
|
echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
+761
@@ -0,0 +1,761 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Ripster – Installationsskript (Git)
|
||||||
|
# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
|
||||||
|
# Benötigt: sudo / root, Internetzugang
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||||
|
# oder:
|
||||||
|
# wget -qO- https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||||
|
#
|
||||||
|
# Mit Optionen (nur via Datei möglich):
|
||||||
|
# sudo bash install.sh [Optionen]
|
||||||
|
#
|
||||||
|
# Optionen:
|
||||||
|
# --branch <branch> Git-Branch (Standard: main)
|
||||||
|
# --dir <pfad> Installationsverzeichnis (Standard: /opt/ripster)
|
||||||
|
# --user <benutzer> Systembenutzer für den Dienst (Standard: ripster)
|
||||||
|
# --port <port> Backend-Port (Standard: 3001)
|
||||||
|
# --host <hostname> Hostname/IP für die Weboberfläche (Standard: Maschinen-IP)
|
||||||
|
# --no-makemkv MakeMKV-Installation überspringen
|
||||||
|
# --no-handbrake HandBrake-Installation überspringen
|
||||||
|
# --no-nginx Nginx-Einrichtung überspringen
|
||||||
|
# --reinstall Vorhandene Installation aktualisieren (Daten bleiben erhalten)
|
||||||
|
# -h, --help Diese Hilfe anzeigen
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_URL="https://github.com/Mboehmlaender/ripster.git"
|
||||||
|
REPO_RAW_BASE="https://raw.githubusercontent.com/Mboehmlaender/ripster"
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
|
||||||
|
BUNDLED_HANDBRAKE_CLI="${SCRIPT_DIR}/bin/HandBrakeCLI"
|
||||||
|
|
||||||
|
# --- Farben -------------------------------------------------------------------
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
||||||
|
error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; }
|
||||||
|
header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \
|
||||||
|
echo -e "${BOLD} $*${RESET}"; \
|
||||||
|
echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; }
|
||||||
|
fatal() { error "$*"; exit 1; }
|
||||||
|
|
||||||
|
# --- Standard-Optionen --------------------------------------------------------
|
||||||
|
GIT_BRANCH="dev"
|
||||||
|
INSTALL_DIR="/opt/ripster"
|
||||||
|
SERVICE_USER="ripster"
|
||||||
|
BACKEND_PORT="3001"
|
||||||
|
FRONTEND_HOST=""
|
||||||
|
SKIP_MAKEMKV=false
|
||||||
|
SKIP_HANDBRAKE=false
|
||||||
|
HANDBRAKE_INSTALL_MODE=""
|
||||||
|
SKIP_NGINX=false
|
||||||
|
REINSTALL=false
|
||||||
|
|
||||||
|
# --- Argumente parsen ---------------------------------------------------------
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--branch) GIT_BRANCH="$2"; shift 2 ;;
|
||||||
|
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||||
|
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||||
|
--port) BACKEND_PORT="$2"; shift 2 ;;
|
||||||
|
--host) FRONTEND_HOST="$2"; shift 2 ;;
|
||||||
|
--no-makemkv) SKIP_MAKEMKV=true; shift ;;
|
||||||
|
--no-handbrake) SKIP_HANDBRAKE=true; shift ;;
|
||||||
|
--no-nginx) SKIP_NGINX=true; shift ;;
|
||||||
|
--reinstall) REINSTALL=true; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//'
|
||||||
|
exit 0 ;;
|
||||||
|
*) fatal "Unbekannte Option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Voraussetzungen prüfen ---------------------------------------------------
|
||||||
|
header "Ripster Installationsskript (Git)"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
fatal "Dieses Skript muss als root ausgeführt werden (sudo bash install.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f /etc/os-release ]]; then
|
||||||
|
fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt."
|
||||||
|
fi
|
||||||
|
. /etc/os-release
|
||||||
|
case "$ID" in
|
||||||
|
debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;;
|
||||||
|
*) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -z "$FRONTEND_HOST" ]]; then
|
||||||
|
FRONTEND_HOST=$(hostname -I | awk '{print $1}')
|
||||||
|
info "Erkannte IP: $FRONTEND_HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Repository: $REPO_URL"
|
||||||
|
info "Branch: $GIT_BRANCH"
|
||||||
|
info "Installationsverzeichnis: $INSTALL_DIR"
|
||||||
|
info "Systembenutzer: $SERVICE_USER"
|
||||||
|
info "Backend-Port: $BACKEND_PORT"
|
||||||
|
info "Frontend-Host: $FRONTEND_HOST"
|
||||||
|
|
||||||
|
# --- Hilfsfunktionen ----------------------------------------------------------
|
||||||
|
|
||||||
|
command_exists() { command -v "$1" &>/dev/null; }
|
||||||
|
|
||||||
|
download_file() {
|
||||||
|
local url="$1"
|
||||||
|
local target="$2"
|
||||||
|
|
||||||
|
if command_exists curl; then
|
||||||
|
curl -fsSL "$url" -o "$target"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command_exists wget; then
|
||||||
|
wget -q "$url" -O "$target"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_node() {
|
||||||
|
header "Node.js installieren"
|
||||||
|
local required_major=20
|
||||||
|
|
||||||
|
if command_exists node; then
|
||||||
|
local current_major
|
||||||
|
current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))")
|
||||||
|
if [[ "$current_major" -ge "$required_major" ]]; then
|
||||||
|
ok "Node.js $(node --version) bereits installiert"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
warn "Node.js $(node --version) zu alt – Node.js 20 wird installiert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Installiere Node.js 20.x über NodeSource..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
ok "Node.js $(node --version) installiert"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_makemkv() {
|
||||||
|
header "MakeMKV installieren"
|
||||||
|
|
||||||
|
if command_exists makemkvcon; then
|
||||||
|
ok "makemkvcon bereits installiert ($(makemkvcon --version 2>&1 | head -1))"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Installiere Build-Abhängigkeiten für MakeMKV..."
|
||||||
|
apt-get install -y \
|
||||||
|
build-essential pkg-config libc6-dev libssl-dev \
|
||||||
|
libexpat1-dev libavcodec-dev libgl1-mesa-dev \
|
||||||
|
qtbase5-dev zlib1g-dev wget
|
||||||
|
|
||||||
|
# Aktuelle Version aus dem offiziellen Linux-Forum-Thread ermitteln.
|
||||||
|
# Der Titel lautet immer: "MakeMKV X.Y.Z for Linux is available"
|
||||||
|
local makemkv_fallback="1.18.3"
|
||||||
|
info "Ermittle aktuelle MakeMKV-Version (forum.makemkv.com)..."
|
||||||
|
local makemkv_version
|
||||||
|
makemkv_version=$(curl -s --max-time 15 \
|
||||||
|
"https://forum.makemkv.com/forum/viewtopic.php?f=3&t=224" \
|
||||||
|
| grep -oP 'MakeMKV \K[0-9]+\.[0-9]+\.[0-9]+(?= for Linux)' | head -1 || true)
|
||||||
|
|
||||||
|
if [[ -z "$makemkv_version" ]]; then
|
||||||
|
warn "MakeMKV-Version konnte nicht ermittelt werden – verwende Fallback $makemkv_fallback"
|
||||||
|
makemkv_version="$makemkv_fallback"
|
||||||
|
else
|
||||||
|
info "Aktuelle Version: $makemkv_version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Baue MakeMKV $makemkv_version..."
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir=$(mktemp -d)
|
||||||
|
cd "$tmp_dir"
|
||||||
|
|
||||||
|
local base_url="https://www.makemkv.com/download"
|
||||||
|
wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz"
|
||||||
|
wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz"
|
||||||
|
|
||||||
|
tar xf "makemkv-oss-${makemkv_version}.tar.gz"
|
||||||
|
cd "makemkv-oss-${makemkv_version}"
|
||||||
|
./configure
|
||||||
|
make -j"$(nproc)"
|
||||||
|
make install
|
||||||
|
|
||||||
|
cd "$tmp_dir"
|
||||||
|
tar xf "makemkv-bin-${makemkv_version}.tar.gz"
|
||||||
|
cd "makemkv-bin-${makemkv_version}"
|
||||||
|
mkdir -p tmp && echo "accepted" > tmp/eula_accepted
|
||||||
|
make -j"$(nproc)"
|
||||||
|
make install
|
||||||
|
|
||||||
|
cd /
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
ok "MakeMKV $makemkv_version installiert"
|
||||||
|
warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key."
|
||||||
|
warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053"
|
||||||
|
}
|
||||||
|
|
||||||
|
select_handbrake_mode() {
|
||||||
|
[[ "$SKIP_HANDBRAKE" == true ]] && return
|
||||||
|
|
||||||
|
local mode_answer=""
|
||||||
|
echo ""
|
||||||
|
echo "Install HandBrake:"
|
||||||
|
echo ""
|
||||||
|
echo "1. Standard version (apt install handbrake-cli)"
|
||||||
|
echo "2. GPU version with NVDEC (use bundled binary)"
|
||||||
|
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
read -r -p "Select option [1/2]: " mode_answer
|
||||||
|
elif [[ -r /dev/tty ]]; then
|
||||||
|
read -r -p "Select option [1/2]: " mode_answer </dev/tty
|
||||||
|
else
|
||||||
|
HANDBRAKE_INSTALL_MODE="standard"
|
||||||
|
warn "Kein interaktives Terminal erkannt – verwende Standardversion (apt)."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$mode_answer" in
|
||||||
|
2) HANDBRAKE_INSTALL_MODE="gpu" ;;
|
||||||
|
1|"") HANDBRAKE_INSTALL_MODE="standard" ;;
|
||||||
|
*) warn "Ungültige Auswahl '$mode_answer' – verwende Standardversion."; HANDBRAKE_INSTALL_MODE="standard" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
install_handbrake_standard() {
|
||||||
|
info "Installiere HandBrakeCLI aus den Standard-Repositories..."
|
||||||
|
info "Aktualisiere Paketlisten..."
|
||||||
|
apt_update
|
||||||
|
apt-get install -y handbrake-cli
|
||||||
|
hash -r 2>/dev/null || true
|
||||||
|
|
||||||
|
if command_exists HandBrakeCLI; then
|
||||||
|
ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command_exists handbrake-cli; then
|
||||||
|
ok "handbrake-cli installiert: $(handbrake-cli --version 2>&1 | head -1)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
install_handbrake_gpu_bundled() {
|
||||||
|
info "Installiere gebündeltes HandBrakeCLI mit NVDEC..."
|
||||||
|
local bundled_source="$BUNDLED_HANDBRAKE_CLI"
|
||||||
|
local downloaded_tmp=""
|
||||||
|
|
||||||
|
if [[ ! -f "$bundled_source" ]]; then
|
||||||
|
local remote_url="${REPO_RAW_BASE}/${GIT_BRANCH}/bin/HandBrakeCLI"
|
||||||
|
downloaded_tmp=$(mktemp)
|
||||||
|
info "Lokale Binary fehlt – lade aus Branch '$GIT_BRANCH' nach..."
|
||||||
|
if download_file "$remote_url" "$downloaded_tmp"; then
|
||||||
|
chmod 0755 "$downloaded_tmp"
|
||||||
|
bundled_source="$downloaded_tmp"
|
||||||
|
ok "Bundled HandBrakeCLI temporär heruntergeladen"
|
||||||
|
else
|
||||||
|
rm -f "$downloaded_tmp" 2>/dev/null || true
|
||||||
|
fatal "Bundled Binary fehlt lokal ($BUNDLED_HANDBRAKE_CLI) und Download schlug fehl: $remote_url"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0755 "$bundled_source" /usr/local/bin/HandBrakeCLI
|
||||||
|
hash -r 2>/dev/null || true
|
||||||
|
if [[ -n "$downloaded_tmp" ]]; then
|
||||||
|
rm -f "$downloaded_tmp" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Bundled HandBrakeCLI installiert nach /usr/local/bin/HandBrakeCLI"
|
||||||
|
if command_exists HandBrakeCLI; then
|
||||||
|
ok "HandBrakeCLI Version: $(HandBrakeCLI --version 2>&1 | head -1)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_handbrake() {
|
||||||
|
header "HandBrake CLI installieren"
|
||||||
|
|
||||||
|
if [[ -z "$HANDBRAKE_INSTALL_MODE" ]]; then
|
||||||
|
HANDBRAKE_INSTALL_MODE="standard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$HANDBRAKE_INSTALL_MODE" in
|
||||||
|
standard) install_handbrake_standard ;;
|
||||||
|
gpu) install_handbrake_gpu_bundled ;;
|
||||||
|
*) fatal "Unbekannter HandBrake-Modus: $HANDBRAKE_INSTALL_MODE" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- apt-Hilfsfunktionen ------------------------------------------------------
|
||||||
|
|
||||||
|
# Führt apt-get update aus. Bei Release-Fehlern wird versucht, die Sources zu
|
||||||
|
# reparieren (Proxmox-Container, veraltete Spiegelserver, etc.).
|
||||||
|
apt_update() {
|
||||||
|
local output
|
||||||
|
if output=$(apt-get update 2>&1); then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Release-Datei fehlt → versuche Repair
|
||||||
|
if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then
|
||||||
|
warn "apt-Sources fehlerhaft. Versuche Reparatur..."
|
||||||
|
|
||||||
|
# Strategie 1: --allow-releaseinfo-change
|
||||||
|
if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then
|
||||||
|
ok "apt-Update mit --allow-releaseinfo-change erfolgreich"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strategie 2: Kaputte Einträge aus sources.list.d entfernen und Fallback
|
||||||
|
# auf offizielle Spiegel schreiben
|
||||||
|
if [[ -n "${VERSION_CODENAME:-}" ]]; then
|
||||||
|
warn "Schreibe minimale sources.list für $VERSION_CODENAME..."
|
||||||
|
local main_list=/etc/apt/sources.list
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
|
||||||
|
|
||||||
|
case "$ID" in
|
||||||
|
ubuntu)
|
||||||
|
cat > "$main_list" <<EOF
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME} main restricted universe multiverse
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-updates main restricted universe multiverse
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-security main restricted universe multiverse
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
debian)
|
||||||
|
cat > "$main_list" <<EOF
|
||||||
|
deb http://deb.debian.org/debian ${VERSION_CODENAME} main contrib non-free
|
||||||
|
deb http://deb.debian.org/debian ${VERSION_CODENAME}-updates main contrib non-free
|
||||||
|
deb http://security.debian.org/debian-security ${VERSION_CODENAME}-security main contrib non-free
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if apt-get update -qq 2>/dev/null; then
|
||||||
|
ok "apt-Update nach Sources-Reparatur erfolgreich"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strategie 3: Kaputte .list-Dateien in sources.list.d deaktivieren
|
||||||
|
warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..."
|
||||||
|
local broken_files
|
||||||
|
broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \
|
||||||
|
xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true)
|
||||||
|
if [[ -n "$broken_files" ]]; then
|
||||||
|
echo "$broken_files" | while read -r f; do
|
||||||
|
warn "Deaktiviere: $f"
|
||||||
|
mv "$f" "${f}.disabled" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
if apt-get update -qq 2>/dev/null; then
|
||||||
|
ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:"
|
||||||
|
echo "$output"
|
||||||
|
fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut."
|
||||||
|
else
|
||||||
|
error "apt-Update fehlgeschlagen:"
|
||||||
|
echo "$output"
|
||||||
|
fatal "Installation abgebrochen."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- HandBrake-Installmodus auswählen ----------------------------------------
|
||||||
|
select_handbrake_mode
|
||||||
|
|
||||||
|
# --- Systemabhängigkeiten -----------------------------------------------------
|
||||||
|
header "Systemabhängigkeiten installieren"
|
||||||
|
|
||||||
|
info "Paketlisten aktualisieren..."
|
||||||
|
apt_update
|
||||||
|
|
||||||
|
info "Installiere Basispakete..."
|
||||||
|
apt-get install -y \
|
||||||
|
curl wget git jq \
|
||||||
|
mediainfo \
|
||||||
|
util-linux udev \
|
||||||
|
ca-certificates gnupg \
|
||||||
|
lsb-release
|
||||||
|
|
||||||
|
ok "Basispakete installiert"
|
||||||
|
|
||||||
|
info "Installiere CD-Ripping-Tools..."
|
||||||
|
apt-get install -y \
|
||||||
|
cdparanoia \
|
||||||
|
flac \
|
||||||
|
lame \
|
||||||
|
opus-tools \
|
||||||
|
vorbis-tools
|
||||||
|
|
||||||
|
ok "CD-Ripping-Tools installiert (cdparanoia, flac, lame, opus-tools, vorbis-tools)"
|
||||||
|
|
||||||
|
install_node
|
||||||
|
|
||||||
|
if [[ "$SKIP_MAKEMKV" == false ]]; then
|
||||||
|
install_makemkv
|
||||||
|
else
|
||||||
|
warn "MakeMKV-Installation übersprungen (--no-makemkv)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_HANDBRAKE" == false ]]; then
|
||||||
|
install_handbrake
|
||||||
|
else
|
||||||
|
warn "HandBrake-Installation übersprungen (--no-handbrake)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
if ! command_exists nginx; then
|
||||||
|
info "Installiere nginx..."
|
||||||
|
apt-get install -y nginx
|
||||||
|
fi
|
||||||
|
ok "nginx installiert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Systembenutzer anlegen ---------------------------------------------------
|
||||||
|
header "Systembenutzer anlegen"
|
||||||
|
|
||||||
|
if id "$SERVICE_USER" &>/dev/null; then
|
||||||
|
ok "Benutzer '$SERVICE_USER' existiert bereits"
|
||||||
|
else
|
||||||
|
info "Lege Systembenutzer '$SERVICE_USER' an..."
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||||
|
ok "Benutzer '$SERVICE_USER' angelegt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
|
||||||
|
if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
|
||||||
|
SERVICE_HOME="/home/$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
mkdir -p "$SERVICE_HOME"
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
|
||||||
|
chmod 755 "$SERVICE_HOME" 2>/dev/null || true
|
||||||
|
info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
|
||||||
|
|
||||||
|
for grp in cdrom optical disk video render; do
|
||||||
|
if getent group "$grp" &>/dev/null; then
|
||||||
|
usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
|
||||||
|
info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Repository klonen / aktualisieren ----------------------------------------
|
||||||
|
header "Repository holen (Git)"
|
||||||
|
|
||||||
|
# Prüfen ob der gewünschte Branch auf dem Remote existiert
|
||||||
|
info "Prüfe Branch '$GIT_BRANCH' auf Remote..."
|
||||||
|
if ! git ls-remote --exit-code --heads "$REPO_URL" "$GIT_BRANCH" &>/dev/null; then
|
||||||
|
fatal "Branch '$GIT_BRANCH' existiert nicht im Repository $REPO_URL.\nVerfügbare Branches: $(git ls-remote --heads "$REPO_URL" | awk '{print $2}' | sed 's|refs/heads/||' | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
ok "Branch '$GIT_BRANCH' gefunden"
|
||||||
|
|
||||||
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
if [[ "$REINSTALL" == true ]]; then
|
||||||
|
info "Aktualisiere bestehendes Repository..."
|
||||||
|
# Daten sichern
|
||||||
|
if [[ -d "$INSTALL_DIR/backend/data" ]]; then
|
||||||
|
DATA_BACKUP="/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
|
||||||
|
cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP"
|
||||||
|
info "Datenbank gesichert nach: $DATA_BACKUP"
|
||||||
|
fi
|
||||||
|
# safe.directory nötig wenn das Verzeichnis einem anderen User gehört
|
||||||
|
# (z.B. ripster-Serviceuser nach erstem Install)
|
||||||
|
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
|
||||||
|
git -C "$INSTALL_DIR" remote set-branches origin '*'
|
||||||
|
git -C "$INSTALL_DIR" fetch --quiet origin
|
||||||
|
git -C "$INSTALL_DIR" reset --hard HEAD
|
||||||
|
git -C "$INSTALL_DIR" checkout --quiet -B "$GIT_BRANCH" "origin/$GIT_BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
|
||||||
|
ok "Repository aktualisiert auf Branch '$GIT_BRANCH'"
|
||||||
|
else
|
||||||
|
fatal "$INSTALL_DIR enthält bereits ein Git-Repository.\nVerwende --reinstall um zu aktualisieren."
|
||||||
|
fi
|
||||||
|
elif [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then
|
||||||
|
fatal "Verzeichnis $INSTALL_DIR existiert bereits (kein Git-Repo).\nBitte manuell entfernen oder --reinstall verwenden."
|
||||||
|
else
|
||||||
|
info "Klone $REPO_URL (Branch: $GIT_BRANCH)..."
|
||||||
|
git clone --quiet --branch "$GIT_BRANCH" --depth 1 "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
ok "Repository geklont nach $INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Daten- und Log-Verzeichnisse sicherstellen
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/logs"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||||
|
|
||||||
|
# Gesicherte Daten zurückspielen
|
||||||
|
if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
|
||||||
|
cp -a "$DATA_BACKUP/." "$INSTALL_DIR/backend/data/"
|
||||||
|
ok "Datenbank wiederhergestellt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- npm-Abhängigkeiten installieren -----------------------------------------
|
||||||
|
header "npm-Abhängigkeiten installieren"
|
||||||
|
|
||||||
|
info "Root-Abhängigkeiten..."
|
||||||
|
npm install --prefix "$INSTALL_DIR" --omit=dev --silent
|
||||||
|
|
||||||
|
info "Backend-Abhängigkeiten..."
|
||||||
|
npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent
|
||||||
|
|
||||||
|
info "Frontend-Abhängigkeiten..."
|
||||||
|
npm install --prefix "$INSTALL_DIR/frontend" --silent
|
||||||
|
|
||||||
|
ok "npm-Abhängigkeiten installiert"
|
||||||
|
|
||||||
|
# --- Frontend bauen -----------------------------------------------------------
|
||||||
|
header "Frontend bauen"
|
||||||
|
|
||||||
|
info "Baue Frontend für $FRONTEND_HOST..."
|
||||||
|
|
||||||
|
# Relative URLs verwenden – funktioniert mit jedem Hostnamen/Domain, da nginx
|
||||||
|
# /api/ und /ws auf dem selben Host proxied. Absolute IP-URLs würden Chromes
|
||||||
|
# Private Network Access (PNA) Policy verletzen, wenn das Frontend über einen
|
||||||
|
# Domainnamen aufgerufen wird.
|
||||||
|
rm -f "$INSTALL_DIR/frontend/.env.production.local"
|
||||||
|
|
||||||
|
npm run build --prefix "$INSTALL_DIR/frontend" --silent
|
||||||
|
ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
|
||||||
|
|
||||||
|
# --- Backend-Konfiguration ---------------------------------------------------
|
||||||
|
header "Backend konfigurieren"
|
||||||
|
|
||||||
|
ENV_FILE="$INSTALL_DIR/backend/.env"
|
||||||
|
|
||||||
|
if [[ -f "$ENV_FILE" && "$REINSTALL" == true ]]; then
|
||||||
|
warn "Bestehende .env bleibt erhalten (--reinstall)"
|
||||||
|
else
|
||||||
|
info "Erstelle Backend .env..."
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
# Ripster Backend – Konfiguration
|
||||||
|
# Generiert von install.sh am $(date)
|
||||||
|
|
||||||
|
PORT=${BACKEND_PORT}
|
||||||
|
DB_PATH=./data/ripster.db
|
||||||
|
LOG_DIR=./logs
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
||||||
|
CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||||
|
|
||||||
|
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
|
||||||
|
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
||||||
|
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
||||||
|
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
||||||
|
EOF
|
||||||
|
ok "Backend .env erstellt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Berechtigungen setzen ---------------------------------------------------
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
|
||||||
|
chmod -R 755 "$INSTALL_DIR"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# Ausgabe- und Log-Verzeichnisse dem installierenden User zuweisen
|
||||||
|
# (SUDO_USER = der echte User hinter sudo; leer wenn direkt als root ausgeführt)
|
||||||
|
ACTUAL_USER="${SUDO_USER:-}"
|
||||||
|
if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
|
||||||
|
chown -R "$ACTUAL_USER:$SERVICE_USER" \
|
||||||
|
"$INSTALL_DIR/backend/data/output" \
|
||||||
|
"$INSTALL_DIR/backend/data/logs"
|
||||||
|
chmod -R 775 \
|
||||||
|
"$INSTALL_DIR/backend/data/output" \
|
||||||
|
"$INSTALL_DIR/backend/data/logs"
|
||||||
|
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
|
||||||
|
else
|
||||||
|
ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
||||||
|
# Laufzeit-relevant ist das Verzeichnis des Service-Users.
|
||||||
|
MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
|
||||||
|
if [[ ! -d "$MAKEMKV_SERVICE_DIR" ]]; then
|
||||||
|
mkdir -p "$MAKEMKV_SERVICE_DIR"
|
||||||
|
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_SERVICE_DIR"
|
||||||
|
else
|
||||||
|
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_SERVICE_DIR"
|
||||||
|
fi
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
||||||
|
chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- Systemd-Dienst: Backend -------------------------------------------------
|
||||||
|
header "Systemd-Dienst (Backend) erstellen"
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/ripster-backend.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Ripster Backend API
|
||||||
|
After=network.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${SERVICE_USER}
|
||||||
|
Group=${SERVICE_USER}
|
||||||
|
WorkingDirectory=${INSTALL_DIR}/backend
|
||||||
|
ExecStart=$(command -v node) src/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=3
|
||||||
|
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=HOME=${SERVICE_HOME}
|
||||||
|
Environment=LANG=C.UTF-8
|
||||||
|
Environment=LC_ALL=C.UTF-8
|
||||||
|
Environment=LANGUAGE=C.UTF-8
|
||||||
|
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=ripster-backend
|
||||||
|
|
||||||
|
# Kein statisches DeviceAllow: Device-Pfade unterscheiden sich je nach Host/Container.
|
||||||
|
# Damit Ripster auf unterschiedlichen Systemen funktioniert, kein Device-Cgroup-Filter.
|
||||||
|
DevicePolicy=auto
|
||||||
|
SupplementaryGroups=video render cdrom disk
|
||||||
|
|
||||||
|
# Für Skriptausführung via GUI (inkl. optionalem sudo in User-Skripten)
|
||||||
|
# darf no_new_privileges nicht aktiv sein.
|
||||||
|
NoNewPrivileges=false
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ok "ripster-backend.service erstellt"
|
||||||
|
|
||||||
|
# --- nginx konfigurieren -----------------------------------------------------
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
header "nginx konfigurieren"
|
||||||
|
|
||||||
|
cat > /etc/nginx/sites-available/ripster <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ${FRONTEND_HOST} _;
|
||||||
|
|
||||||
|
root ${INSTALL_DIR}/frontend/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:${BACKEND_PORT}/ws;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
ln -sf /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ripster
|
||||||
|
|
||||||
|
nginx -t && ok "nginx-Konfiguration gültig" || fatal "nginx-Konfiguration fehlerhaft!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Dienste starten ----------------------------------------------------------
|
||||||
|
header "Dienste starten"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
systemctl enable ripster-backend
|
||||||
|
systemctl restart ripster-backend
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if systemctl is-active --quiet ripster-backend; then
|
||||||
|
ok "ripster-backend läuft"
|
||||||
|
else
|
||||||
|
error "ripster-backend konnte nicht gestartet werden!"
|
||||||
|
journalctl -u ripster-backend -n 30 --no-pager
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
systemctl enable nginx
|
||||||
|
systemctl restart nginx
|
||||||
|
if systemctl is-active --quiet nginx; then
|
||||||
|
ok "nginx läuft"
|
||||||
|
else
|
||||||
|
error "nginx konnte nicht gestartet werden!"
|
||||||
|
journalctl -u nginx -n 20 --no-pager
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Zusammenfassung ----------------------------------------------------------
|
||||||
|
header "Installation abgeschlossen!"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}${BOLD}Ripster ist installiert und läuft.${RESET}"
|
||||||
|
echo ""
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
echo -e " ${BOLD}Weboberfläche:${RESET} http://${FRONTEND_HOST}"
|
||||||
|
else
|
||||||
|
echo -e " ${BOLD}Backend API:${RESET} http://${FRONTEND_HOST}:${BACKEND_PORT}/api"
|
||||||
|
warn "nginx deaktiviert – Frontend nicht automatisch erreichbar."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Dienste verwalten:${RESET}"
|
||||||
|
echo -e " sudo systemctl status ripster-backend"
|
||||||
|
echo -e " sudo systemctl restart ripster-backend"
|
||||||
|
echo -e " sudo systemctl stop ripster-backend"
|
||||||
|
echo -e " sudo journalctl -u ripster-backend -f"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Konfiguration:${RESET} $INSTALL_DIR/backend/.env"
|
||||||
|
echo -e " ${BOLD}Datenbank:${RESET} $INSTALL_DIR/backend/data/ripster.db"
|
||||||
|
echo -e " ${BOLD}Logs:${RESET} $INSTALL_DIR/backend/logs/"
|
||||||
|
echo -e " ${BOLD}Aktualisieren:${RESET} sudo bash $INSTALL_DIR/install.sh --reinstall"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
missing_tools=()
|
||||||
|
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
||||||
|
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
||||||
|
command_exists mediainfo || missing_tools+=("mediainfo")
|
||||||
|
command_exists cdparanoia || missing_tools+=("cdparanoia")
|
||||||
|
command_exists flac || missing_tools+=("flac")
|
||||||
|
command_exists lame || missing_tools+=("lame")
|
||||||
|
command_exists opusenc || missing_tools+=("opusenc")
|
||||||
|
command_exists oggenc || missing_tools+=("oggenc")
|
||||||
|
|
||||||
|
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
||||||
|
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
|
||||||
|
for t in "${missing_tools[@]}"; do
|
||||||
|
echo -e " ${YELLOW}✗${RESET} $t"
|
||||||
|
done
|
||||||
|
echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.9.1-6",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.9.1-6",
|
"version": "0.10.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.1-6",
|
"version": "0.10.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_OWNER="Mboehmlaender"
|
||||||
|
REPO_NAME="ripster"
|
||||||
|
REPO_RAW_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}"
|
||||||
|
BRANCHES_API_URL="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches?per_page=100"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Verwendung:
|
||||||
|
bash setup.sh [Optionen]
|
||||||
|
|
||||||
|
Optionen (wie install.sh):
|
||||||
|
--branch <branch> Branch direkt setzen (ohne Auswahlmenue)
|
||||||
|
--dir <pfad> Installationsverzeichnis
|
||||||
|
--user <benutzer> Systembenutzer fuer den Dienst
|
||||||
|
--port <port> Backend-Port
|
||||||
|
--host <hostname> Hostname/IP fuer die Weboberflaeche
|
||||||
|
--no-makemkv MakeMKV-Installation ueberspringen
|
||||||
|
--no-handbrake HandBrake-Installation ueberspringen
|
||||||
|
--no-nginx Nginx-Einrichtung ueberspringen
|
||||||
|
--reinstall Vorhandene Installation aktualisieren
|
||||||
|
-h, --help Hilfe anzeigen
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
SELECTED_BRANCH=""
|
||||||
|
FORWARDED_ARGS=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--branch)
|
||||||
|
[[ $# -ge 2 ]] || { echo "Fehlender Wert fuer --branch" >&2; exit 1; }
|
||||||
|
SELECTED_BRANCH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dir|--user|--port|--host)
|
||||||
|
[[ $# -ge 2 ]] || { echo "Fehlender Wert fuer $1" >&2; exit 1; }
|
||||||
|
FORWARDED_ARGS+=("$1" "$2")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--no-makemkv|--no-handbrake|--no-nginx|--reinstall)
|
||||||
|
FORWARDED_ARGS+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unbekannter Parameter: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
fetch_url() {
|
||||||
|
local url="$1"
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
curl -fsSL "$url"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v wget >/dev/null 2>&1; then
|
||||||
|
wget -qO- "$url"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Weder curl noch wget gefunden. Bitte eines davon installieren." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
download_file() {
|
||||||
|
local url="$1"
|
||||||
|
local target="$2"
|
||||||
|
fetch_url "$url" > "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
select_branch() {
|
||||||
|
local branches_json
|
||||||
|
local -a branches
|
||||||
|
local selection
|
||||||
|
|
||||||
|
branches_json="$(fetch_url "$BRANCHES_API_URL")"
|
||||||
|
mapfile -t branches < <(
|
||||||
|
printf '%s\n' "$branches_json" \
|
||||||
|
| grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \
|
||||||
|
| sed -E 's/"name"[[:space:]]*:[[:space:]]*"([^"]+)"/\1/'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ ${#branches[@]} -eq 0 ]]; then
|
||||||
|
echo "Keine Branches gefunden oder API-Antwort ungültig." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$SELECTED_BRANCH" ]]; then
|
||||||
|
local found=false
|
||||||
|
for branch in "${branches[@]}"; do
|
||||||
|
if [[ "$branch" == "$SELECTED_BRANCH" ]]; then
|
||||||
|
found=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "$found" == false ]]; then
|
||||||
|
echo "Branch '$SELECTED_BRANCH' nicht gefunden." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
echo "Kein interaktives Terminal für die Branch-Auswahl verfügbar." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Verfügbare Branches:"
|
||||||
|
for i in "${!branches[@]}"; do
|
||||||
|
printf " %2d) %s\n" "$((i + 1))" "${branches[$i]}"
|
||||||
|
done
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
read -r -p "Bitte Branch auswählen [1-${#branches[@]}]: " selection
|
||||||
|
if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#branches[@]} )); then
|
||||||
|
SELECTED_BRANCH="${branches[$((selection - 1))]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "Ungültige Auswahl."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
select_branch
|
||||||
|
|
||||||
|
INSTALL_SCRIPT="${TMP_DIR}/install.sh"
|
||||||
|
INSTALL_URL="${REPO_RAW_BASE}/${SELECTED_BRANCH}/install.sh"
|
||||||
|
|
||||||
|
echo "Lade install.sh aus Branch '${SELECTED_BRANCH}'..."
|
||||||
|
download_file "$INSTALL_URL" "$INSTALL_SCRIPT"
|
||||||
|
chmod +x "$INSTALL_SCRIPT"
|
||||||
|
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
|
||||||
|
else
|
||||||
|
if ! command -v sudo >/dev/null 2>&1; then
|
||||||
|
echo "sudo nicht gefunden. Bitte als root ausführen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user