Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01766785b4 | |||
| 30b066ece8 | |||
| fbd439f318 | |||
| 3c694d06df | |||
| cc20dc8120 | |||
| ca2bd76572 | |||
| 7d6c154909 | |||
| 52ef155c7c | |||
| a957dfea73 | |||
| 9c0af285ea | |||
| 25d5339ada | |||
| 3e191a654e | |||
| b15af7c061 | |||
| a471de6422 | |||
| 9d789f302a | |||
| 1fb8dd85fb | |||
| 4897d3940c | |||
| 652731ae98 | |||
| f628fdac50 | |||
| 96839ac05b | |||
| e56cff43a9 | |||
| 5d79a34905 | |||
| 5d83d02d9f | |||
| f52befd4c4 | |||
| b5ca397d98 | |||
| 6378a9a4cb | |||
| c3875803ff | |||
| d75d9eb4c8 | |||
| 59bcb54492 | |||
| 241b097ea9 | |||
| e140a9fa8c | |||
| 5580d3be98 | |||
| 43dfdbf33e | |||
| 24b63d390a | |||
| 49fcca72af | |||
| ba91f83722 | |||
| 466e7a7a3d | |||
| e67c0d316d | |||
| 1da5ee3e34 | |||
| 4d377f3eb4 | |||
| df708485b5 | |||
| b6cac5efb4 | |||
| f38081649f | |||
| 5b41f728c5 | |||
| 7948dd298c | |||
| ee6603ffad | |||
| 93ed0e6eb2 | |||
| 778fabb2e5 | |||
| 5d8796404c | |||
| a2b9f3625c | |||
| 1e30f00b45 | |||
| 519b7a30ef | |||
| 3ae883326a | |||
| 46f415d778 | |||
| d473f296ff | |||
| e0bba8cbfc | |||
| 715dfbbc38 | |||
| 5cf869eaca | |||
| 3dd043689e | |||
| 882dad57e2 | |||
| 15f1a49378 | |||
| 871e39bba2 | |||
| d8a6b4c56d | |||
| 16e76d70c9 | |||
| 45a19c7a12 | |||
| 8af4da5a08 | |||
| c13ce5a50b |
@@ -8,7 +8,8 @@
|
||||
"Bash(mkdocs build --strict)",
|
||||
"Read(//mnt/external/media/**)",
|
||||
"WebFetch(domain:www.makemkv.com)",
|
||||
"Bash(node --check backend/src/services/pipelineService.js)"
|
||||
"Bash(node --check backend/src/services/pipelineService.js)",
|
||||
"Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -80,11 +80,6 @@ Thumbs.db
|
||||
# Scripts
|
||||
# ----------------------------
|
||||
/scripts/
|
||||
/deploy-ripster.sh
|
||||
/setup.sh
|
||||
/install.sh
|
||||
/install-dev.sh
|
||||
/build-handbrake-nvdec.sh
|
||||
/gitea_setup.sh
|
||||
/gitea_install.sh
|
||||
/release.sh
|
||||
/Audible_Tool
|
||||
/AddOns
|
||||
101
README.md
101
README.md
@@ -4,6 +4,13 @@ Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit Ma
|
||||
|
||||
---
|
||||
|
||||
## Statushinweis: CD-Ripping (experimentell)
|
||||
|
||||
- Die grundlegende CD-Ripping-Funktion im ersten Durchgang funktioniert.
|
||||
- Das Frontend ist dafür noch nicht vollständig angepasst.
|
||||
- Funktionen wie Restart, bestimmte Ansichten und Folge-Workflows können aktuell eingeschränkt sein oder fehlschlagen.
|
||||
- CD-Ripping wird weiterentwickelt und sollte derzeit als experimentell betrachtet werden.
|
||||
|
||||
## Was Ripster kann
|
||||
|
||||
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
|
||||
@@ -24,21 +31,17 @@ Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit Ma
|
||||
- Frontend: React, Vite, PrimeReact
|
||||
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- Ausführliche Dokumentation: https://mboehmlaender.github.io/ripster/
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- **Produktion (empfohlen mit `install.sh`)**:
|
||||
- unterstütztes Debian/Ubuntu-System
|
||||
- root-Rechte + Internetzugang
|
||||
- optisches Laufwerk (oder gemountete Quelle)
|
||||
- `install.sh` installiert Node.js und die benötigten Tools automatisch
|
||||
- **Entwicklung (`./start.sh`, `npm run dev`)**:
|
||||
- Node.js `>= 20.19.0` (siehe [.nvmrc](.nvmrc))
|
||||
- installierte CLI-Tools im `PATH`:
|
||||
- `makemkvcon`
|
||||
- `HandBrakeCLI`
|
||||
- `mediainfo`
|
||||
- Debian 11/12 oder Ubuntu 22.04/24.04
|
||||
- root-Rechte + Internetzugang
|
||||
- optisches Laufwerk (oder gemountete Quelle)
|
||||
|
||||
## Schnellstart (Produktion)
|
||||
## Schnellstart (`install.sh`)
|
||||
|
||||
Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich):
|
||||
|
||||
@@ -47,65 +50,37 @@ wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
Das Skript fragt interaktiv, ob HandBrake als Standard-Version (apt) oder mit GPU/NVDEC-Unterstützung (gebündeltes Binary) installiert werden soll.
|
||||
Alternativ direkt per Pipe:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
`install.sh` übernimmt u. a.:
|
||||
|
||||
- Node.js 20 (falls nötig)
|
||||
- Basistools inkl. `mediainfo`
|
||||
- CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
|
||||
- MakeMKV
|
||||
- HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding)
|
||||
- nginx
|
||||
- Repository-Checkout, npm-Install, Frontend-Build, systemd-Service (`ripster-backend`)
|
||||
|
||||
Danach ist Ripster unter `http://<Server-IP>` erreichbar.
|
||||
|
||||
Wichtige Optionen:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --branch dev # anderen Branch installieren
|
||||
sudo bash install.sh --branch dev # Branch wählen (Default im Skript: dev)
|
||||
sudo bash install.sh --dir /opt/ripster # Installationspfad
|
||||
sudo bash install.sh --user ripster # Service-User
|
||||
sudo bash install.sh --port 3001 # Backend-Port
|
||||
sudo bash install.sh --host 192.168.1.10 # Host/IP für nginx/CORS
|
||||
sudo bash install.sh --no-makemkv # MakeMKV überspringen
|
||||
sudo bash install.sh --no-handbrake # HandBrake überspringen
|
||||
sudo bash install.sh --no-nginx # nginx-Setup überspringen
|
||||
sudo bash install.sh --reinstall # Update (Daten bleiben erhalten)
|
||||
```
|
||||
|
||||
## Entwicklungsumgebung
|
||||
|
||||
Für lokale Entwicklung mit Hot-Reload:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
`start.sh` erledigt:
|
||||
|
||||
1. Node-Version prüfen/umschalten (`nvm`/`npx node@20` Fallback)
|
||||
2. Dependencies installieren (Root, Backend, Frontend)
|
||||
3. Dev-Umgebung starten (`backend` + `frontend`)
|
||||
|
||||
Danach:
|
||||
|
||||
- Frontend: `http://localhost:5173`
|
||||
- Backend API: `http://localhost:3001/api`
|
||||
|
||||
Stoppen: laufenden Prozess mit `Ctrl+C` im Terminal beenden.
|
||||
|
||||
## Manueller Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm --prefix backend install
|
||||
npm --prefix frontend install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Einzeln starten:
|
||||
|
||||
```bash
|
||||
npm run dev:backend
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
Frontend Build:
|
||||
|
||||
```bash
|
||||
npm run build:frontend
|
||||
```
|
||||
|
||||
Backend (ohne Dev-Mode):
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
sudo bash install.sh --help # Hilfe anzeigen
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
@@ -3,3 +3,10 @@ DB_PATH=./data/ripster.db
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
LOG_DIR=./logs
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
|
||||
# Leer lassen = relativ zum data/-Verzeichnis der DB (data/output/raw etc.)
|
||||
DEFAULT_RAW_DIR=
|
||||
DEFAULT_MOVIE_DIR=
|
||||
DEFAULT_CD_DIR=
|
||||
DEFAULT_DOWNLOAD_DIR=
|
||||
|
||||
1045
backend/package-lock.json
generated
1045
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.10.2-5",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
@@ -8,9 +8,11 @@
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.1.1",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.18.0"
|
||||
|
||||
@@ -3,11 +3,28 @@ const path = require('path');
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const rawDbPath = process.env.DB_PATH || path.join(rootDir, 'data', 'ripster.db');
|
||||
const rawLogDir = process.env.LOG_DIR || path.join(rootDir, 'logs');
|
||||
const resolvedDbPath = path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath);
|
||||
const dataDir = path.dirname(resolvedDbPath);
|
||||
|
||||
function resolveOutputPath(envValue, ...subParts) {
|
||||
const raw = String(envValue || '').trim();
|
||||
if (raw) {
|
||||
return path.isAbsolute(raw) ? raw : path.resolve(rootDir, raw);
|
||||
}
|
||||
return path.join(dataDir, ...subParts);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
||||
dbPath: path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath),
|
||||
dbPath: resolvedDbPath,
|
||||
dataDir,
|
||||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
|
||||
logLevel: process.env.LOG_LEVEL || 'info'
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
|
||||
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
|
||||
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
|
||||
defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads')
|
||||
};
|
||||
|
||||
@@ -38,25 +38,11 @@ const LEGACY_PROFILE_SETTING_MIGRATIONS = [
|
||||
profileKeys: ['output_extension_bluray', 'output_extension_dvd']
|
||||
},
|
||||
{
|
||||
legacyKey: 'filename_template',
|
||||
profileKeys: ['filename_template_bluray', 'filename_template_dvd']
|
||||
},
|
||||
{
|
||||
legacyKey: 'output_folder_template',
|
||||
profileKeys: ['output_folder_template_bluray', 'output_folder_template_dvd']
|
||||
legacyKey: 'output_template',
|
||||
profileKeys: ['output_template_bluray', 'output_template_dvd']
|
||||
}
|
||||
];
|
||||
const INSTALL_PATH_SETTING_DEFAULTS = [
|
||||
{
|
||||
key: 'raw_dir',
|
||||
pathParts: ['output', 'raw'],
|
||||
legacyDefaults: ['data/output/raw', './data/output/raw']
|
||||
},
|
||||
{
|
||||
key: 'movie_dir',
|
||||
pathParts: ['output', 'movies'],
|
||||
legacyDefaults: ['data/output/movies', './data/output/movies']
|
||||
},
|
||||
{
|
||||
key: 'log_dir',
|
||||
pathParts: ['logs'],
|
||||
@@ -540,6 +526,7 @@ async function openAndPrepareDatabase() {
|
||||
await seedFromSchemaFile(dbInstance);
|
||||
await syncInstallPathSettingDefaults(dbInstance);
|
||||
await migrateLegacyProfiledToolSettings(dbInstance);
|
||||
await migrateOutputTemplates(dbInstance);
|
||||
await removeDeprecatedSettings(dbInstance);
|
||||
await migrateSettingsSchemaMetadata(dbInstance);
|
||||
await ensurePipelineStateRow(dbInstance);
|
||||
@@ -736,6 +723,49 @@ async function ensurePipelineStateRow(db) {
|
||||
);
|
||||
}
|
||||
|
||||
async function migrateOutputTemplates(db) {
|
||||
// Combine legacy filename_template_X + output_folder_template_X into output_template_X.
|
||||
// Only sets the new key if it has no user value yet (preserves any existing value).
|
||||
// The last "/" in the combined template separates folder from filename.
|
||||
for (const profile of ['bluray', 'dvd']) {
|
||||
const newKey = `output_template_${profile}`;
|
||||
const filenameKey = `filename_template_${profile}`;
|
||||
const folderKey = `output_folder_template_${profile}`;
|
||||
|
||||
const existing = await db.get(
|
||||
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||
[newKey]
|
||||
);
|
||||
if (existing) {
|
||||
continue; // already set, don't overwrite
|
||||
}
|
||||
|
||||
const filenameRow = await db.get(
|
||||
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||
[filenameKey]
|
||||
);
|
||||
const folderRow = await db.get(
|
||||
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||
[folderKey]
|
||||
);
|
||||
|
||||
const filenameVal = filenameRow ? String(filenameRow.value || '').trim() : '';
|
||||
const folderVal = folderRow ? String(folderRow.value || '').trim() : '';
|
||||
|
||||
if (!filenameVal) {
|
||||
continue; // nothing to migrate
|
||||
}
|
||||
|
||||
const combined = folderVal ? `${folderVal}/${filenameVal}` : `${filenameVal}/${filenameVal}`;
|
||||
await db.run(
|
||||
`INSERT INTO settings_values (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
||||
[newKey, combined]
|
||||
);
|
||||
logger.info('migrate:output-template-combined', { profile, combined });
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDeprecatedSettings(db) {
|
||||
const deprecatedKeys = [
|
||||
'pushover_notify_disc_detected',
|
||||
@@ -748,14 +778,32 @@ async function removeDeprecatedSettings(db) {
|
||||
'output_extension',
|
||||
'filename_template',
|
||||
'output_folder_template',
|
||||
'makemkv_backup_mode'
|
||||
'makemkv_backup_mode',
|
||||
'raw_dir',
|
||||
'movie_dir',
|
||||
'raw_dir_other',
|
||||
'raw_dir_other_owner',
|
||||
'movie_dir_other',
|
||||
'movie_dir_other_owner',
|
||||
'filename_template_bluray',
|
||||
'filename_template_dvd',
|
||||
'output_folder_template_bluray',
|
||||
'output_folder_template_dvd',
|
||||
'output_extension_audiobook'
|
||||
];
|
||||
for (const key of deprecatedKeys) {
|
||||
const result = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||
if (result?.changes > 0) {
|
||||
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||
const valuesResult = await db.run('DELETE FROM settings_values WHERE key = ?', [key]);
|
||||
if (schemaResult?.changes > 0 || valuesResult?.changes > 0) {
|
||||
logger.info('migrate:remove-deprecated-setting', { key });
|
||||
}
|
||||
}
|
||||
|
||||
// Reset raw_dir_cd if it still holds the old hardcoded absolute path from a prior install
|
||||
await db.run(
|
||||
`UPDATE settings_values SET value = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'raw_dir_cd' AND value = '/opt/ripster/backend/data/output/cd'`
|
||||
);
|
||||
}
|
||||
|
||||
// Aktualisiert settings_schema-Metadaten (required, description, validation_json)
|
||||
@@ -775,6 +823,16 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
}
|
||||
];
|
||||
|
||||
// Settings, die von einer Kategorie in eine andere verschoben werden
|
||||
const SETTINGS_CATEGORY_MOVES = [
|
||||
{ key: 'cd_output_template', category: 'Pfade' },
|
||||
{ key: 'output_template_bluray', category: 'Pfade' },
|
||||
{ key: 'output_template_dvd', category: 'Pfade' },
|
||||
{ key: 'output_template_audiobook', category: 'Pfade' },
|
||||
{ key: 'output_chapter_template_audiobook', category: 'Pfade' },
|
||||
{ key: 'audiobook_raw_template', category: 'Pfade' }
|
||||
];
|
||||
|
||||
async function migrateSettingsSchemaMetadata(db) {
|
||||
for (const update of SETTINGS_SCHEMA_METADATA_UPDATES) {
|
||||
const result = await db.run(
|
||||
@@ -791,6 +849,118 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
logger.info('migrate:settings-schema-metadata', { key: update.key });
|
||||
}
|
||||
}
|
||||
for (const move of SETTINGS_CATEGORY_MOVES) {
|
||||
const result = await db.run(
|
||||
`UPDATE settings_schema SET category = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = ? AND category != ?`,
|
||||
[move.category, move.key, move.category]
|
||||
);
|
||||
if (result?.changes > 0) {
|
||||
logger.info('migrate:settings-schema-category-moved', { key: move.key, category: move.category });
|
||||
}
|
||||
}
|
||||
|
||||
const rawDirCdLabel = 'CD RAW-Ordner';
|
||||
const rawDirCdDescription = 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).';
|
||||
const rawDirCdResult = await db.run(
|
||||
`UPDATE settings_schema
|
||||
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'raw_dir_cd' AND (label != ? OR description != ?)`,
|
||||
[rawDirCdLabel, rawDirCdDescription, rawDirCdLabel, rawDirCdDescription]
|
||||
);
|
||||
if (rawDirCdResult?.changes > 0) {
|
||||
logger.info('migrate:settings-schema-cd-raw-updated', {
|
||||
key: 'raw_dir_cd',
|
||||
label: rawDirCdLabel
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate raw_dir_cd_owner label
|
||||
await db.run(
|
||||
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
|
||||
);
|
||||
|
||||
// Add movie_dir_cd if not already present
|
||||
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_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`);
|
||||
|
||||
// Add movie_dir_cd_owner if not already present
|
||||
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_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
|
||||
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_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 ('output_chapter_template_audiobook', 'Pfade', 'Kapitel Template (Audiobook)', 'string', 1, 'Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}', '[]', '{"minLength":1}', 7355)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_chapter_template_audiobook', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}')`);
|
||||
|
||||
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)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('download_dir', 'Pfade', 'Download ZIP-Ordner', 'path', 0, 'Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads).', NULL, '[]', '{}', 118)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir', 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 ('download_dir_owner', 'Pfade', 'Eigentümer Download ZIP-Ordner', 'string', 0, 'Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1185)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`);
|
||||
}
|
||||
|
||||
async function getDb() {
|
||||
|
||||
@@ -10,21 +10,25 @@ const requestLogger = require('./middleware/requestLogger');
|
||||
const settingsRoutes = require('./routes/settingsRoutes');
|
||||
const pipelineRoutes = require('./routes/pipelineRoutes');
|
||||
const historyRoutes = require('./routes/historyRoutes');
|
||||
const downloadRoutes = require('./routes/downloadRoutes');
|
||||
const cronRoutes = require('./routes/cronRoutes');
|
||||
const runtimeRoutes = require('./routes/runtimeRoutes');
|
||||
const wsService = require('./services/websocketService');
|
||||
const pipelineService = require('./services/pipelineService');
|
||||
const cronService = require('./services/cronService');
|
||||
const downloadService = require('./services/downloadService');
|
||||
const diskDetectionService = require('./services/diskDetectionService');
|
||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||
const logger = require('./services/logger').child('BOOT');
|
||||
const { errorToMeta } = require('./utils/errorMeta');
|
||||
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||
|
||||
async function start() {
|
||||
logger.info('backend:start:init');
|
||||
await initDatabase();
|
||||
await pipelineService.init();
|
||||
await cronService.init();
|
||||
await downloadService.init();
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: corsOrigin }));
|
||||
@@ -38,8 +42,10 @@ async function start() {
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/pipeline', pipelineRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
app.use('/api/downloads', downloadRoutes);
|
||||
app.use('/api/crons', cronRoutes);
|
||||
app.use('/api/runtime', runtimeRoutes);
|
||||
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -72,6 +78,8 @@ async function start() {
|
||||
|
||||
server.listen(port, () => {
|
||||
logger.info('backend:listening', { port });
|
||||
// Bestehende Job-Bilder im Hintergrund migrieren (blockiert nicht den Start)
|
||||
migrateExistingThumbnails().catch(() => {});
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
|
||||
69
backend/src/routes/downloadRoutes.js
Normal file
69
backend/src/routes/downloadRoutes.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const express = require('express');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const downloadService = require('../services/downloadService');
|
||||
const logger = require('../services/logger').child('DOWNLOAD_ROUTE');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:downloads', { reqId: req.reqId });
|
||||
const items = await downloadService.listItems();
|
||||
res.json({
|
||||
items,
|
||||
summary: downloadService.getSummary()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/summary',
|
||||
asyncHandler(async (req, res) => {
|
||||
await downloadService.init();
|
||||
res.json({ summary: downloadService.getSummary() });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/history/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const target = String(req.body?.target || 'raw').trim();
|
||||
logger.info('post:downloads:history', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
target
|
||||
});
|
||||
const result = await downloadService.enqueueHistoryJob(jobId, target);
|
||||
res.status(result.created ? 201 : 200).json({
|
||||
...result,
|
||||
summary: downloadService.getSummary()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/file',
|
||||
asyncHandler(async (req, res) => {
|
||||
const descriptor = await downloadService.getDownloadDescriptor(req.params.id);
|
||||
res.download(descriptor.path, descriptor.archiveName);
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('delete:downloads:item', {
|
||||
reqId: req.reqId,
|
||||
id: req.params.id
|
||||
});
|
||||
const result = await downloadService.deleteItem(req.params.id);
|
||||
res.json({
|
||||
...result,
|
||||
summary: downloadService.getSummary()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -91,6 +91,37 @@ router.post(
|
||||
});
|
||||
|
||||
const job = await historyService.assignOmdbMetadata(id, payload);
|
||||
|
||||
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
|
||||
pipelineService.renameJobFolders(id).catch((err) => {
|
||||
logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message });
|
||||
});
|
||||
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/cd/assign',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('post:job:cd:assign', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
mbId: payload?.mbId || null,
|
||||
hasTitle: Boolean(payload?.title),
|
||||
hasArtist: Boolean(payload?.artist),
|
||||
trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0
|
||||
});
|
||||
|
||||
const job = await historyService.assignCdMetadata(id, payload);
|
||||
|
||||
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
|
||||
pipelineService.renameJobFolders(id).catch((err) => {
|
||||
logger.warn('post:job:cd:assign:rename-failed', { id, error: err.message });
|
||||
});
|
||||
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
@@ -112,19 +143,38 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/delete-preview',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase());
|
||||
|
||||
logger.info('get:delete-preview', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
includeRelated
|
||||
});
|
||||
|
||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
|
||||
res.json({ preview });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/delete',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const target = String(req.body?.target || 'none');
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||
|
||||
logger.warn('post:delete-job', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
target
|
||||
target,
|
||||
includeRelated
|
||||
});
|
||||
|
||||
const result = await historyService.deleteJob(id, target);
|
||||
const result = await historyService.deleteJob(id, target, { includeRelated });
|
||||
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
||||
res.json({ ...result, uiReset });
|
||||
})
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
const express = require('express');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const { getDb } = require('../db/database');
|
||||
|
||||
const router = express.Router();
|
||||
const audiobookUpload = multer({
|
||||
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads')
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/state',
|
||||
@@ -46,6 +54,146 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cd/musicbrainz/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query });
|
||||
const results = await pipelineService.searchMusicBrainz(String(query));
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cd/musicbrainz/release/:mbId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const mbId = String(req.params.mbId || '').trim();
|
||||
if (!mbId) {
|
||||
const error = new Error('mbId fehlt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.info('get:cd:musicbrainz:release', { reqId: req.reqId, mbId });
|
||||
const release = await pipelineService.getMusicBrainzReleaseById(mbId);
|
||||
res.json({ release });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cd/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { jobId, title, artist, year, mbId, coverUrl, tracks } = req.body;
|
||||
if (!jobId) {
|
||||
const error = new Error('jobId fehlt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.info('post:cd:select-metadata', { reqId: req.reqId, jobId, title, artist, year, mbId });
|
||||
const job = await pipelineService.selectCdMetadata({
|
||||
jobId: Number(jobId),
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
mbId,
|
||||
coverUrl,
|
||||
tracks
|
||||
});
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cd/start/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const ripConfig = req.body || {};
|
||||
logger.info('post:cd:start', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
format: ripConfig.format,
|
||||
selectedPreEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPreEncodeScriptIds)
|
||||
? ripConfig.selectedPreEncodeScriptIds.length
|
||||
: 0,
|
||||
selectedPostEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPostEncodeScriptIds)
|
||||
? ripConfig.selectedPostEncodeScriptIds.length
|
||||
: 0,
|
||||
selectedPreEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPreEncodeChainIds)
|
||||
? ripConfig.selectedPreEncodeChainIds.length
|
||||
: 0,
|
||||
selectedPostEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPostEncodeChainIds)
|
||||
? ripConfig.selectedPostEncodeChainIds.length
|
||||
: 0
|
||||
});
|
||||
const result = await pipelineService.enqueueOrStartCdAction(
|
||||
jobId,
|
||||
ripConfig,
|
||||
() => pipelineService.startCdRip(jobId, ripConfig)
|
||||
);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
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),
|
||||
mimeType: String(req.file.mimetype || '').trim() || null,
|
||||
tempPath: String(req.file.path || '').trim() || null
|
||||
});
|
||||
const result = await pipelineService.uploadAudiobookFile(req.file, {
|
||||
format: req.body?.format,
|
||||
startImmediately: req.body?.startImmediately
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/audiobook/pending-activation',
|
||||
asyncHandler(async (req, res) => {
|
||||
const db = await getDb();
|
||||
// Jobs die eine Checksum haben, aber noch keine Activation Bytes im Cache
|
||||
const pending = await db.all(`
|
||||
SELECT j.id AS jobId, j.aax_checksum AS checksum
|
||||
FROM jobs j
|
||||
WHERE j.aax_checksum IS NOT NULL
|
||||
AND j.status NOT IN ('DONE', 'ERROR', 'CANCELLED')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM aax_activation_bytes ab WHERE ab.checksum = j.aax_checksum
|
||||
)
|
||||
ORDER BY j.created_at DESC
|
||||
`);
|
||||
res.json({ pending });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/audiobook/start/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const config = req.body || {};
|
||||
logger.info('post:audiobook:start', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
format: config?.format,
|
||||
formatOptions: config?.formatOptions && typeof config.formatOptions === 'object'
|
||||
? config.formatOptions
|
||||
: null
|
||||
});
|
||||
const result = await pipelineService.startAudiobookWithConfig(jobId, config);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ const pipelineService = require('../services/pipelineService');
|
||||
const wsService = require('../services/websocketService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const userPresetService = require('../services/userPresetService');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -29,6 +30,15 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/effective-paths',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:settings:effective-paths', { reqId: req.reqId });
|
||||
const paths = await settingsService.getEffectivePaths();
|
||||
res.json(paths);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/handbrake-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -366,4 +376,28 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/activation-bytes',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:settings:activation-bytes', { reqId: req.reqId });
|
||||
const entries = await activationBytesService.listCachedEntries();
|
||||
res.json({ entries });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/activation-bytes',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { checksum, activationBytes } = req.body || {};
|
||||
if (!checksum || !activationBytes) {
|
||||
const error = new Error('checksum und activationBytes sind erforderlich');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.debug('post:settings:activation-bytes', { reqId: req.reqId, checksum });
|
||||
const saved = await activationBytesService.saveActivationBytes(checksum, activationBytes);
|
||||
res.json({ success: true, checksum, activationBytes: saved });
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
79
backend/src/services/activationBytesService.js
Normal file
79
backend/src/services/activationBytesService.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('ActivationBytes');
|
||||
|
||||
const FIXED_KEY = Buffer.from([0x77, 0x21, 0x4d, 0x4b, 0x19, 0x6a, 0x87, 0xcd, 0x52, 0x00, 0x45, 0xfd, 0x20, 0xa5, 0x1d, 0x67]);
|
||||
const AAX_CHECKSUM_OFFSET = 653;
|
||||
const AAX_CHECKSUM_LENGTH = 20;
|
||||
|
||||
function sha1(data) {
|
||||
return crypto.createHash('sha1').update(data).digest();
|
||||
}
|
||||
|
||||
function verifyActivationBytes(activationBytesHex, expectedChecksumHex) {
|
||||
const bytes = Buffer.from(activationBytesHex, 'hex');
|
||||
const ik = sha1(Buffer.concat([FIXED_KEY, bytes]));
|
||||
const iv = sha1(Buffer.concat([FIXED_KEY, ik, bytes]));
|
||||
const checksum = sha1(Buffer.concat([ik.subarray(0, 16), iv.subarray(0, 16)]));
|
||||
return checksum.toString('hex') === expectedChecksumHex;
|
||||
}
|
||||
|
||||
function readAaxChecksum(filePath) {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(AAX_CHECKSUM_LENGTH);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, AAX_CHECKSUM_LENGTH, AAX_CHECKSUM_OFFSET);
|
||||
if (bytesRead !== AAX_CHECKSUM_LENGTH) {
|
||||
throw new Error(`Konnte Checksum nicht lesen (nur ${bytesRead} Bytes)`);
|
||||
}
|
||||
return buf.toString('hex');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupCached(checksum) {
|
||||
const db = await getDb();
|
||||
const row = await db.get('SELECT activation_bytes FROM aax_activation_bytes WHERE checksum = ?', checksum);
|
||||
return row ? row.activation_bytes : null;
|
||||
}
|
||||
|
||||
async function saveActivationBytes(checksum, activationBytesHex) {
|
||||
const normalized = String(activationBytesHex || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{8}$/.test(normalized)) {
|
||||
throw new Error('Activation Bytes müssen genau 8 Hex-Zeichen (4 Bytes) sein');
|
||||
}
|
||||
if (!verifyActivationBytes(normalized, checksum)) {
|
||||
throw new Error('Activation Bytes passen nicht zur Checksum – bitte nochmals prüfen');
|
||||
}
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
'INSERT OR REPLACE INTO aax_activation_bytes (checksum, activation_bytes) VALUES (?, ?)',
|
||||
checksum,
|
||||
normalized
|
||||
);
|
||||
logger.info({ checksum, activationBytes: normalized }, 'Activation Bytes manuell gespeichert');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveActivationBytes(filePath) {
|
||||
const checksum = readAaxChecksum(filePath);
|
||||
logger.info({ checksum }, 'AAX Checksum gelesen');
|
||||
|
||||
const cached = await lookupCached(checksum);
|
||||
if (cached) {
|
||||
logger.info({ checksum }, 'Activation Bytes aus lokalem Cache');
|
||||
return { checksum, activationBytes: cached };
|
||||
}
|
||||
|
||||
logger.info({ checksum }, 'Keine Activation Bytes im Cache – manuelle Eingabe erforderlich');
|
||||
return { checksum, activationBytes: null };
|
||||
}
|
||||
|
||||
async function listCachedEntries() {
|
||||
const db = await getDb();
|
||||
return db.all('SELECT checksum, activation_bytes, created_at FROM aax_activation_bytes ORDER BY created_at DESC');
|
||||
}
|
||||
|
||||
module.exports = { resolveActivationBytes, readAaxChecksum, saveActivationBytes, verifyActivationBytes, listCachedEntries };
|
||||
819
backend/src/services/audiobookService.js
Normal file
819
backend/src/services/audiobookService.js
Normal file
@@ -0,0 +1,819 @@
|
||||
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})';
|
||||
const DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}';
|
||||
const AUDIOBOOK_FORMAT_DEFAULTS = {
|
||||
m4b: {},
|
||||
flac: {
|
||||
flacCompression: 5
|
||||
},
|
||||
mp3: {
|
||||
mp3Mode: 'cbr',
|
||||
mp3Bitrate: 192,
|
||||
mp3Quality: 4
|
||||
}
|
||||
};
|
||||
|
||||
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 parseOptionalNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseTimebaseToSeconds(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (/^\d+\/\d+$/u.test(raw)) {
|
||||
const [num, den] = raw.split('/').map(Number);
|
||||
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
|
||||
return num / den;
|
||||
}
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function secondsToMs(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.round(parsed * 1000));
|
||||
}
|
||||
|
||||
function ticksToMs(value, timebase) {
|
||||
const ticks = Number(value);
|
||||
const factor = parseTimebaseToSeconds(timebase);
|
||||
if (!Number.isFinite(ticks) || ticks < 0 || !Number.isFinite(factor) || factor <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.round(ticks * factor * 1000));
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(value) {
|
||||
const format = String(value || '').trim().toLowerCase();
|
||||
return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3';
|
||||
}
|
||||
|
||||
function clonePlainObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||||
}
|
||||
|
||||
function clampInteger(value, min, max, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(min, Math.min(max, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function getDefaultFormatOptions(format) {
|
||||
const normalizedFormat = normalizeOutputFormat(format);
|
||||
return clonePlainObject(AUDIOBOOK_FORMAT_DEFAULTS[normalizedFormat]);
|
||||
}
|
||||
|
||||
function normalizeFormatOptions(format, formatOptions = {}) {
|
||||
const normalizedFormat = normalizeOutputFormat(format);
|
||||
const source = clonePlainObject(formatOptions);
|
||||
const defaults = getDefaultFormatOptions(normalizedFormat);
|
||||
|
||||
if (normalizedFormat === 'flac') {
|
||||
return {
|
||||
flacCompression: clampInteger(source.flacCompression, 0, 8, defaults.flacCompression)
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'mp3') {
|
||||
const mp3Mode = String(source.mp3Mode || defaults.mp3Mode || 'cbr').trim().toLowerCase() === 'vbr'
|
||||
? 'vbr'
|
||||
: 'cbr';
|
||||
const allowedBitrates = new Set([128, 160, 192, 256, 320]);
|
||||
const normalizedBitrate = clampInteger(source.mp3Bitrate, 96, 320, defaults.mp3Bitrate);
|
||||
return {
|
||||
mp3Mode,
|
||||
mp3Bitrate: allowedBitrates.has(normalizedBitrate) ? normalizedBitrate : defaults.mp3Bitrate,
|
||||
mp3Quality: clampInteger(source.mp3Quality, 0, 9, defaults.mp3Quality)
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeInputExtension(filePath) {
|
||||
return path.extname(String(filePath || '')).trim().toLowerCase();
|
||||
}
|
||||
|
||||
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 sanitizeTemplateValue(value, fallback = '') {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return sanitizeFileName(normalized);
|
||||
}
|
||||
|
||||
function normalizeChapterTitle(value, index) {
|
||||
const normalized = normalizeText(value);
|
||||
return normalized || `Kapitel ${index}`;
|
||||
}
|
||||
|
||||
function buildChapterList(probe = null) {
|
||||
const chapters = Array.isArray(probe?.chapters) ? probe.chapters : [];
|
||||
return chapters.map((chapter, index) => {
|
||||
const chapterIndex = index + 1;
|
||||
const tags = normalizeTagMap(chapter?.tags);
|
||||
const startSeconds = parseOptionalNumber(chapter?.start_time);
|
||||
const endSeconds = parseOptionalNumber(chapter?.end_time);
|
||||
const startMs = secondsToMs(startSeconds) ?? ticksToMs(chapter?.start, chapter?.time_base) ?? 0;
|
||||
const endMs = secondsToMs(endSeconds) ?? ticksToMs(chapter?.end, chapter?.time_base) ?? 0;
|
||||
const title = normalizeChapterTitle(tags.title || tags.chapter, chapterIndex);
|
||||
return {
|
||||
index: chapterIndex,
|
||||
title,
|
||||
startSeconds: Number((startMs / 1000).toFixed(3)),
|
||||
endSeconds: Number((endMs / 1000).toFixed(3)),
|
||||
startMs,
|
||||
endMs,
|
||||
timeBase: String(chapter?.time_base || '').trim() || null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeChapterList(chapters = [], options = {}) {
|
||||
const source = Array.isArray(chapters) ? chapters : [];
|
||||
const durationMs = Number(options?.durationMs || 0);
|
||||
const fallbackTitle = normalizeText(options?.fallbackTitle || '');
|
||||
const createFallback = options?.createFallback === true;
|
||||
|
||||
const normalized = source.map((chapter, index) => {
|
||||
const chapterIndex = Number(chapter?.index);
|
||||
const safeIndex = Number.isFinite(chapterIndex) && chapterIndex > 0
|
||||
? Math.trunc(chapterIndex)
|
||||
: index + 1;
|
||||
const rawStartMs = parseOptionalNumber(chapter?.startMs)
|
||||
?? secondsToMs(chapter?.startSeconds)
|
||||
?? ticksToMs(chapter?.start, chapter?.timeBase || chapter?.time_base)
|
||||
?? 0;
|
||||
const rawEndMs = parseOptionalNumber(chapter?.endMs)
|
||||
?? secondsToMs(chapter?.endSeconds)
|
||||
?? ticksToMs(chapter?.end, chapter?.timeBase || chapter?.time_base)
|
||||
?? 0;
|
||||
return {
|
||||
index: safeIndex,
|
||||
title: normalizeChapterTitle(chapter?.title, safeIndex),
|
||||
startMs: Math.max(0, rawStartMs),
|
||||
endMs: Math.max(0, rawEndMs)
|
||||
};
|
||||
});
|
||||
|
||||
const repaired = normalized.map((chapter, index) => {
|
||||
const nextStartMs = normalized[index + 1]?.startMs ?? null;
|
||||
let endMs = chapter.endMs;
|
||||
if (!(endMs > chapter.startMs)) {
|
||||
if (Number.isFinite(nextStartMs) && nextStartMs > chapter.startMs) {
|
||||
endMs = nextStartMs;
|
||||
} else if (durationMs > chapter.startMs) {
|
||||
endMs = durationMs;
|
||||
} else {
|
||||
endMs = chapter.startMs;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...chapter,
|
||||
endMs,
|
||||
startSeconds: Number((chapter.startMs / 1000).toFixed(3)),
|
||||
endSeconds: Number((endMs / 1000).toFixed(3)),
|
||||
durationMs: Math.max(0, endMs - chapter.startMs)
|
||||
};
|
||||
}).filter((chapter) => chapter.endMs > chapter.startMs || normalized.length === 1);
|
||||
|
||||
if (repaired.length > 0) {
|
||||
return repaired;
|
||||
}
|
||||
|
||||
if (createFallback && durationMs > 0) {
|
||||
return [{
|
||||
index: 1,
|
||||
title: fallbackTitle || 'Kapitel 1',
|
||||
startMs: 0,
|
||||
endMs: durationMs,
|
||||
startSeconds: 0,
|
||||
endSeconds: Number((durationMs / 1000).toFixed(3)),
|
||||
durationMs
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function looksLikeDescription(value) {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return normalized.length >= 120 || /[.!?]\s/u.test(normalized);
|
||||
}
|
||||
|
||||
function detectCoverStream(probe = null) {
|
||||
const streams = Array.isArray(probe?.streams) ? probe.streams : [];
|
||||
for (const stream of streams) {
|
||||
const codecType = String(stream?.codec_type || '').trim().toLowerCase();
|
||||
const codecName = String(stream?.codec_name || '').trim().toLowerCase();
|
||||
const dispositionAttachedPic = Number(stream?.disposition?.attached_pic || 0) === 1;
|
||||
const mimetype = String(stream?.tags?.mimetype || '').trim().toLowerCase();
|
||||
const looksLikeImageStream = codecType === 'video'
|
||||
&& (dispositionAttachedPic || mimetype.startsWith('image/') || ['jpeg', 'jpg', 'png', 'mjpeg'].includes(codecName));
|
||||
|
||||
if (!looksLikeImageStream) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const streamIndex = Number(stream?.index);
|
||||
return {
|
||||
streamIndex: Number.isFinite(streamIndex) ? Math.trunc(streamIndex) : 0,
|
||||
codecName: codecName || null,
|
||||
mimetype: mimetype || null,
|
||||
attachedPic: dispositionAttachedPic
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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, ['author', 'artist', 'writer', 'album_artist', 'composer']) || 'Unknown Author';
|
||||
const description = pickTag(tags, [
|
||||
'description',
|
||||
'synopsis',
|
||||
'summary',
|
||||
'long_description',
|
||||
'longdescription',
|
||||
'publisher_summary',
|
||||
'publishersummary',
|
||||
'comment'
|
||||
]) || null;
|
||||
let narrator = pickTag(tags, ['narrator', 'performer', 'album_artist']) || null;
|
||||
if (narrator && (narrator === author || narrator === description || looksLikeDescription(narrator))) {
|
||||
narrator = null;
|
||||
}
|
||||
const series = pickTag(tags, ['series', 'grouping', 'series_title', 'show']) || null;
|
||||
const part = pickTag(tags, ['part', 'part_number', 'disc', 'discnumber', 'volume']) || null;
|
||||
const year = parseOptionalYear(pickTag(tags, ['date', 'year', 'creation_time']));
|
||||
const durationSeconds = Number(format.duration || 0);
|
||||
const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
? Math.round(durationSeconds * 1000)
|
||||
: 0;
|
||||
const chapters = normalizeChapterList(buildChapterList(probe), {
|
||||
durationMs,
|
||||
fallbackTitle: title,
|
||||
createFallback: false
|
||||
});
|
||||
const cover = detectCoverStream(probe);
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
narrator,
|
||||
description,
|
||||
series,
|
||||
part,
|
||||
year,
|
||||
album: title,
|
||||
artist: author,
|
||||
durationMs,
|
||||
chapters,
|
||||
cover,
|
||||
hasEmbeddedCover: Boolean(cover),
|
||||
tags
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateTokenKey(rawKey) {
|
||||
const key = String(rawKey || '').trim().toLowerCase();
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
if (key === 'artist') {
|
||||
return 'author';
|
||||
}
|
||||
if (key === 'chapternr' || key === 'chapternumberpadded' || key === 'chapternopadded') {
|
||||
return 'chapterNr';
|
||||
}
|
||||
if (key === 'chapterno' || key === 'chapternumber' || key === 'chapternum') {
|
||||
return 'chapterNo';
|
||||
}
|
||||
if (key === 'chaptertitle') {
|
||||
return 'chapterTitle';
|
||||
}
|
||||
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, chapter = null) {
|
||||
const chapterIndex = Number(chapter?.index || chapter?.chapterNo || 0);
|
||||
const safeChapterIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 ? Math.trunc(chapterIndex) : 1;
|
||||
const author = sanitizeTemplateValue(metadata.author || metadata.artist || 'Unknown Author', 'Unknown Author');
|
||||
const title = sanitizeTemplateValue(metadata.title || metadata.album || 'Unknown Audiobook', 'Unknown Audiobook');
|
||||
const narrator = sanitizeTemplateValue(metadata.narrator || '');
|
||||
const series = sanitizeTemplateValue(metadata.series || '');
|
||||
const part = sanitizeTemplateValue(metadata.part || '');
|
||||
const chapterTitle = sanitizeTemplateValue(chapter?.title || `Kapitel ${safeChapterIndex}`, `Kapitel ${safeChapterIndex}`);
|
||||
const year = metadata.year ? String(metadata.year) : '';
|
||||
return {
|
||||
author,
|
||||
title,
|
||||
narrator,
|
||||
series,
|
||||
part,
|
||||
year,
|
||||
format: format ? String(format).trim().toLowerCase() : '',
|
||||
chapterNr: String(safeChapterIndex).padStart(2, '0'),
|
||||
chapterNo: String(safeChapterIndex),
|
||||
chapterTitle
|
||||
};
|
||||
}
|
||||
|
||||
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 findCommonDirectory(paths = []) {
|
||||
const segmentsList = (Array.isArray(paths) ? paths : [])
|
||||
.map((entry) => String(entry || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => path.resolve(entry).split(path.sep).filter((segment, index, list) => !(index === 0 && list[0] === '')));
|
||||
|
||||
if (segmentsList.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const common = [...segmentsList[0]];
|
||||
for (let index = 1; index < segmentsList.length; index += 1) {
|
||||
const next = segmentsList[index];
|
||||
let matchLength = 0;
|
||||
while (matchLength < common.length && matchLength < next.length && common[matchLength] === next[matchLength]) {
|
||||
matchLength += 1;
|
||||
}
|
||||
common.length = matchLength;
|
||||
if (common.length === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (common.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.join(path.sep, ...common);
|
||||
}
|
||||
|
||||
function buildChapterOutputPlan(
|
||||
metadata,
|
||||
chapters,
|
||||
movieBaseDir,
|
||||
chapterTemplate = DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
|
||||
outputFormat = 'mp3'
|
||||
) {
|
||||
const normalizedFormat = normalizeOutputFormat(outputFormat);
|
||||
const normalizedChapters = normalizeChapterList(chapters, {
|
||||
durationMs: metadata?.durationMs,
|
||||
fallbackTitle: metadata?.title || metadata?.album || 'Audiobook',
|
||||
createFallback: true
|
||||
});
|
||||
const outputFiles = normalizedChapters.map((chapter, index) => {
|
||||
const values = buildTemplateValues(metadata, normalizedFormat, chapter);
|
||||
const fallbackBaseName = `${values.chapterNr} ${values.chapterTitle}`.trim() || `Kapitel ${index + 1}`;
|
||||
const { folderParts, baseName } = resolveTemplatePathParts(chapterTemplate, values, fallbackBaseName);
|
||||
const outputPath = path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`);
|
||||
return {
|
||||
chapter,
|
||||
outputPath
|
||||
};
|
||||
});
|
||||
const outputDir = findCommonDirectory(outputFiles.map((entry) => path.dirname(entry.outputPath)))
|
||||
|| String(movieBaseDir || '').trim()
|
||||
|| '.';
|
||||
|
||||
return {
|
||||
outputDir,
|
||||
outputFiles,
|
||||
chapters: normalizedChapters,
|
||||
format: 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 pushMetadataArg(args, key, value) {
|
||||
const normalizedKey = String(key || '').trim();
|
||||
const normalizedValue = normalizeText(value);
|
||||
if (!normalizedKey || !normalizedValue) {
|
||||
return;
|
||||
}
|
||||
args.push('-metadata', `${normalizedKey}=${normalizedValue}`);
|
||||
}
|
||||
|
||||
function buildMetadataArgs(metadata = {}, options = {}) {
|
||||
const source = metadata && typeof metadata === 'object' ? metadata : {};
|
||||
const titleOverride = normalizeText(options?.title || '');
|
||||
const albumOverride = normalizeText(options?.album || '');
|
||||
const trackNo = Number(options?.trackNo || 0);
|
||||
const trackTotal = Number(options?.trackTotal || 0);
|
||||
const args = [];
|
||||
const bookTitle = normalizeText(source.title || source.album || '');
|
||||
const author = normalizeText(source.author || source.artist || '');
|
||||
|
||||
pushMetadataArg(args, 'title', titleOverride || bookTitle);
|
||||
pushMetadataArg(args, 'album', albumOverride || bookTitle);
|
||||
pushMetadataArg(args, 'artist', author);
|
||||
pushMetadataArg(args, 'album_artist', author);
|
||||
pushMetadataArg(args, 'author', author);
|
||||
pushMetadataArg(args, 'narrator', source.narrator);
|
||||
pushMetadataArg(args, 'performer', source.narrator);
|
||||
pushMetadataArg(args, 'grouping', source.series);
|
||||
pushMetadataArg(args, 'series', source.series);
|
||||
pushMetadataArg(args, 'disc', source.part);
|
||||
pushMetadataArg(args, 'description', source.description);
|
||||
pushMetadataArg(args, 'comment', source.description);
|
||||
if (source.year) {
|
||||
pushMetadataArg(args, 'date', String(source.year));
|
||||
pushMetadataArg(args, 'year', String(source.year));
|
||||
}
|
||||
if (Number.isFinite(trackNo) && trackNo > 0) {
|
||||
const formattedTrack = Number.isFinite(trackTotal) && trackTotal > 0
|
||||
? `${Math.trunc(trackNo)}/${Math.trunc(trackTotal)}`
|
||||
: String(Math.trunc(trackNo));
|
||||
pushMetadataArg(args, 'track', formattedTrack);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildCodecArgs(format, normalizedOptions) {
|
||||
if (format === 'm4b') {
|
||||
return ['-c:a', 'copy'];
|
||||
}
|
||||
if (format === 'flac') {
|
||||
return ['-codec:a', 'flac', '-compression_level', String(normalizedOptions.flacCompression)];
|
||||
}
|
||||
if (normalizedOptions.mp3Mode === 'vbr') {
|
||||
return ['-codec:a', 'libmp3lame', '-q:a', String(normalizedOptions.mp3Quality)];
|
||||
}
|
||||
return ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`];
|
||||
}
|
||||
|
||||
function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}, options = {}) {
|
||||
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const format = normalizeOutputFormat(outputFormat);
|
||||
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
|
||||
const extra = options && typeof options === 'object' ? options : {};
|
||||
const commonArgs = [
|
||||
'-y',
|
||||
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
|
||||
'-i', inputPath
|
||||
];
|
||||
if (extra.chapterMetadataPath) {
|
||||
commonArgs.push('-f', 'ffmetadata', '-i', extra.chapterMetadataPath);
|
||||
}
|
||||
commonArgs.push(
|
||||
'-map', '0:a:0?',
|
||||
'-map_metadata', '0',
|
||||
'-map_chapters', extra.chapterMetadataPath ? '1' : '0',
|
||||
'-vn',
|
||||
'-sn',
|
||||
'-dn'
|
||||
);
|
||||
const metadataArgs = buildMetadataArgs(extra.metadata, extra.metadataOptions);
|
||||
const codecArgs = buildCodecArgs(format, normalizedOptions);
|
||||
return {
|
||||
cmd,
|
||||
args: [...commonArgs, ...codecArgs, ...metadataArgs, outputPath],
|
||||
metadataArgs,
|
||||
formatOptions: normalizedOptions
|
||||
};
|
||||
}
|
||||
|
||||
function formatSecondsArg(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return '0';
|
||||
}
|
||||
return parsed.toFixed(3).replace(/\.?0+$/u, '');
|
||||
}
|
||||
|
||||
function buildChapterEncodeCommand(
|
||||
ffmpegCommand,
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputFormat = 'mp3',
|
||||
formatOptions = {},
|
||||
metadata = {},
|
||||
chapter = {},
|
||||
chapterTotal = 1,
|
||||
options = {}
|
||||
) {
|
||||
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const format = normalizeOutputFormat(outputFormat);
|
||||
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
|
||||
const extra = options && typeof options === 'object' ? options : {};
|
||||
const safeChapter = normalizeChapterList([chapter], {
|
||||
durationMs: metadata?.durationMs,
|
||||
fallbackTitle: metadata?.title || 'Kapitel',
|
||||
createFallback: true
|
||||
})[0];
|
||||
const durationSeconds = Number(((safeChapter?.durationMs || 0) / 1000).toFixed(3));
|
||||
const metadataArgs = buildMetadataArgs(metadata, {
|
||||
title: safeChapter?.title,
|
||||
album: metadata?.title || metadata?.album || null,
|
||||
trackNo: safeChapter?.index || 1,
|
||||
trackTotal: chapterTotal
|
||||
});
|
||||
const codecArgs = buildCodecArgs(format, normalizedOptions);
|
||||
return {
|
||||
cmd,
|
||||
args: [
|
||||
'-y',
|
||||
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
|
||||
'-i', inputPath,
|
||||
'-ss', formatSecondsArg(safeChapter?.startSeconds),
|
||||
'-t', formatSecondsArg(durationSeconds),
|
||||
'-map', '0:a:0?',
|
||||
'-map_metadata', '-1',
|
||||
'-map_chapters', '-1',
|
||||
'-vn',
|
||||
'-sn',
|
||||
'-dn',
|
||||
...codecArgs,
|
||||
...metadataArgs,
|
||||
outputPath
|
||||
],
|
||||
metadataArgs,
|
||||
formatOptions: normalizedOptions
|
||||
};
|
||||
}
|
||||
|
||||
function escapeFfmetadataValue(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/=/g, '\\=')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/#/g, '\\#')
|
||||
.replace(/\r?\n/g, ' ');
|
||||
}
|
||||
|
||||
function buildChapterMetadataContent(chapters = [], metadata = {}) {
|
||||
const normalizedChapters = normalizeChapterList(chapters, {
|
||||
durationMs: metadata?.durationMs,
|
||||
fallbackTitle: metadata?.title || metadata?.album || 'Audiobook',
|
||||
createFallback: true
|
||||
});
|
||||
|
||||
const chapterBlocks = normalizedChapters.map((chapter) => {
|
||||
const startMs = Math.max(0, Math.round(chapter.startMs || 0));
|
||||
const endMs = Math.max(startMs, Math.round(chapter.endMs || startMs));
|
||||
return [
|
||||
'[CHAPTER]',
|
||||
'TIMEBASE=1/1000',
|
||||
`START=${startMs}`,
|
||||
`END=${endMs}`,
|
||||
`title=${escapeFfmetadataValue(chapter.title || `Kapitel ${chapter.index || 1}`)}`
|
||||
].join('\n');
|
||||
}).join('\n\n');
|
||||
|
||||
return `;FFMETADATA1\n\n${chapterBlocks}`;
|
||||
}
|
||||
|
||||
function buildCoverExtractionCommand(ffmpegCommand, inputPath, outputPath, cover = null) {
|
||||
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const streamIndex = Number(cover?.streamIndex);
|
||||
const streamSpecifier = Number.isFinite(streamIndex) && streamIndex >= 0
|
||||
? `0:${Math.trunc(streamIndex)}`
|
||||
: '0:v:0';
|
||||
return {
|
||||
cmd,
|
||||
args: [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-map', streamSpecifier,
|
||||
'-an',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-frames:v', '1',
|
||||
'-c:v', 'mjpeg',
|
||||
'-q:v', '2',
|
||||
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,
|
||||
DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
|
||||
AUDIOBOOK_FORMAT_DEFAULTS,
|
||||
normalizeOutputFormat,
|
||||
getDefaultFormatOptions,
|
||||
normalizeFormatOptions,
|
||||
isSupportedInputFile,
|
||||
buildMetadataFromProbe,
|
||||
normalizeChapterList,
|
||||
buildRawStoragePaths,
|
||||
buildOutputPath,
|
||||
buildChapterOutputPlan,
|
||||
buildProbeCommand,
|
||||
parseProbeOutput,
|
||||
buildEncodeCommand,
|
||||
buildChapterEncodeCommand,
|
||||
buildChapterMetadataContent,
|
||||
buildCoverExtractionCommand,
|
||||
buildProgressParser
|
||||
};
|
||||
156
backend/src/services/audnexService.js
Normal file
156
backend/src/services/audnexService.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const fs = require('fs');
|
||||
const logger = require('./logger').child('AUDNEX');
|
||||
|
||||
const AUDNEX_BASE_URL = 'https://api.audnex.us';
|
||||
const AUDNEX_TIMEOUT_MS = 10000;
|
||||
const ASIN_PATTERN = /B0[0-9A-Z]{8}/u;
|
||||
|
||||
function normalizeAsin(value) {
|
||||
const raw = String(value || '').trim().toUpperCase();
|
||||
return ASIN_PATTERN.test(raw) ? raw : null;
|
||||
}
|
||||
|
||||
async function extractAsinFromAaxFile(filePath) {
|
||||
const sourcePath = String(filePath || '').trim();
|
||||
if (!sourcePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let printableWindow = '';
|
||||
let settled = false;
|
||||
const stream = fs.createReadStream(sourcePath, { highWaterMark: 64 * 1024 });
|
||||
|
||||
const finish = (value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const byte of chunk) {
|
||||
if (byte >= 32 && byte <= 126) {
|
||||
printableWindow = `${printableWindow}${String.fromCharCode(byte)}`.slice(-48);
|
||||
const match = printableWindow.match(/B0[0-9A-Z]{8}/u);
|
||||
if (match?.[0]) {
|
||||
const asin = normalizeAsin(match[0]);
|
||||
if (asin) {
|
||||
logger.info('asin:detected', { filePath: sourcePath, asin });
|
||||
stream.destroy();
|
||||
finish(asin);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
printableWindow = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
if (!settled) {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function audnexFetch(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), AUDNEX_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Ripster/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Audnex Anfrage fehlgeschlagen (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function extractChapterArray(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const candidates = [
|
||||
payload?.chapters,
|
||||
payload?.data?.chapters,
|
||||
payload?.content?.chapters,
|
||||
payload?.results?.chapters
|
||||
];
|
||||
return candidates.find((entry) => Array.isArray(entry)) || [];
|
||||
}
|
||||
|
||||
function normalizeAudnexChapter(entry, index) {
|
||||
const startOffsetMs = Number(
|
||||
entry?.startOffsetMs
|
||||
?? entry?.startMs
|
||||
?? entry?.offsetMs
|
||||
?? 0
|
||||
);
|
||||
const lengthMs = Number(
|
||||
entry?.lengthMs
|
||||
?? entry?.durationMs
|
||||
?? entry?.length
|
||||
?? 0
|
||||
);
|
||||
const title = String(entry?.title || entry?.chapterTitle || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`;
|
||||
const safeStartMs = Number.isFinite(startOffsetMs) && startOffsetMs >= 0 ? Math.round(startOffsetMs) : 0;
|
||||
const safeLengthMs = Number.isFinite(lengthMs) && lengthMs > 0 ? Math.round(lengthMs) : 0;
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
title,
|
||||
startMs: safeStartMs,
|
||||
endMs: safeStartMs + safeLengthMs,
|
||||
startSeconds: Math.round(safeStartMs / 1000),
|
||||
endSeconds: Math.round((safeStartMs + safeLengthMs) / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchChaptersByAsin(asin, region = 'de') {
|
||||
const normalizedAsin = normalizeAsin(asin);
|
||||
if (!normalizedAsin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}/chapters`);
|
||||
url.searchParams.set('region', String(region || 'de').trim() || 'de');
|
||||
logger.info('chapters:fetch:start', { asin: normalizedAsin, url: url.toString() });
|
||||
|
||||
const payload = await audnexFetch(url.toString());
|
||||
const chapters = extractChapterArray(payload)
|
||||
.map((entry, index) => normalizeAudnexChapter(entry, index))
|
||||
.filter((chapter) => chapter.endMs > chapter.startMs && chapter.title);
|
||||
|
||||
logger.info('chapters:fetch:done', { asin: normalizedAsin, count: chapters.length });
|
||||
return chapters;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractAsinFromAaxFile,
|
||||
fetchChaptersByAsin
|
||||
};
|
||||
710
backend/src/services/cdRipService.js
Normal file
710
backend/src/services/cdRipService.js
Normal file
@@ -0,0 +1,710 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const logger = require('./logger').child('CD_RIP');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir } = require('../utils/files');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']);
|
||||
const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}';
|
||||
|
||||
/**
|
||||
* Parse cdparanoia -Q output to extract track information.
|
||||
* Supports both bracket styles shown by different builds:
|
||||
* track 1: 0 (00:00.00) 24218 (05:22.43)
|
||||
* track 1: 0 [00:00.00] 24218 [05:22.43]
|
||||
*/
|
||||
function parseToc(tocOutput) {
|
||||
const lines = String(tocOutput || '').split(/\r?\n/);
|
||||
const tracks = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
||||
if (trackMatch) {
|
||||
const position = Number(trackMatch[1]);
|
||||
const payloadWithoutTimes = String(trackMatch[2] || '')
|
||||
.replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' ');
|
||||
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
||||
if (sectorValues.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startSector = Number(sectorValues[0]);
|
||||
const lengthSector = Number(sectorValues[1]);
|
||||
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
|
||||
continue;
|
||||
}
|
||||
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// duration in seconds: sectors / 75
|
||||
const durationSec = Math.round(lengthSector / 75);
|
||||
tracks.push({
|
||||
position,
|
||||
startSector,
|
||||
lengthSector,
|
||||
durationSec,
|
||||
durationMs: durationSec * 1000
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alternative cdparanoia -Q table style:
|
||||
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
|
||||
// ^ length sectors ^ start sector
|
||||
const tableMatch = line.match(
|
||||
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i
|
||||
);
|
||||
if (!tableMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = Number(tableMatch[1]);
|
||||
const lengthSector = Number(tableMatch[2]);
|
||||
const startSector = Number(tableMatch[3]);
|
||||
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
|
||||
continue;
|
||||
}
|
||||
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const durationSec = Math.round(lengthSector / 75);
|
||||
tracks.push({
|
||||
position,
|
||||
startSector,
|
||||
lengthSector,
|
||||
durationSec,
|
||||
durationMs: durationSec * 1000
|
||||
});
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
async function readToc(devicePath, cmd) {
|
||||
const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
|
||||
logger.info('toc:read', { devicePath, cmd: cdparanoia });
|
||||
try {
|
||||
// Depending on distro/build, TOC can appear on stderr and/or stdout.
|
||||
const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
|
||||
timeout: 15000
|
||||
});
|
||||
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||
logger.info('toc:done', { devicePath, trackCount: tracks.length });
|
||||
return tracks;
|
||||
} catch (error) {
|
||||
// cdparanoia -Q may exit non-zero even when TOC is readable.
|
||||
const stderr = String(error?.stderr || '');
|
||||
const stdout = String(error?.stdout || '');
|
||||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length });
|
||||
return tracks;
|
||||
}
|
||||
logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate);
|
||||
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
|
||||
return `${relativeBasePath}.${ext}`;
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value, fallback = 'unknown') {
|
||||
const raw = String(value == null ? '' : value)
|
||||
.normalize('NFC')
|
||||
.replace(/[\\/:*?"<>|]/g, '-')
|
||||
// Keep umlauts/special letters, but filter heart symbols in filenames.
|
||||
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
|
||||
.replace(/\p{C}+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!raw || raw === '.' || raw === '..') {
|
||||
return fallback;
|
||||
}
|
||||
return raw.slice(0, 180);
|
||||
}
|
||||
|
||||
function normalizeTemplateTokenKey(rawKey) {
|
||||
const key = String(rawKey || '').trim().toLowerCase();
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') {
|
||||
return 'trackNr';
|
||||
}
|
||||
if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') {
|
||||
return 'trackNo';
|
||||
}
|
||||
if (key === 'trackartist' || key === 'track_artist') {
|
||||
return 'trackArtist';
|
||||
}
|
||||
if (key === 'albumartist') {
|
||||
return 'albumArtist';
|
||||
}
|
||||
if (key === 'interpret') {
|
||||
return 'artist';
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function cleanupRenderedTemplate(value) {
|
||||
return String(value || '')
|
||||
.replace(/\(\s*\)/g, '')
|
||||
.replace(/\[\s*]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderOutputTemplate(template, values) {
|
||||
const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE;
|
||||
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
|
||||
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
|
||||
const rawValue = values[normalizedKey];
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return '';
|
||||
}
|
||||
return String(rawValue);
|
||||
});
|
||||
return cleanupRenderedTemplate(rendered);
|
||||
}
|
||||
|
||||
function buildTemplateValues(track, meta, format = null) {
|
||||
const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1;
|
||||
const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`);
|
||||
const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist');
|
||||
const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist');
|
||||
const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album');
|
||||
const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), '');
|
||||
return {
|
||||
artist: albumArtist,
|
||||
albumArtist,
|
||||
trackArtist,
|
||||
album,
|
||||
year,
|
||||
title: trackTitle,
|
||||
trackNr: String(trackNo).padStart(2, '0'),
|
||||
trackNo: String(trackNo),
|
||||
format: format ? String(format).trim().toLowerCase() : ''
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) {
|
||||
const values = buildTemplateValues(track, meta, format);
|
||||
const rendered = renderOutputTemplate(outputTemplate, values)
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
const parts = rendered
|
||||
.split('/')
|
||||
.map((part) => sanitizePathSegment(part, 'unknown'))
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return path.join(...parts);
|
||||
}
|
||||
|
||||
function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||
const sampleTrack = {
|
||||
position: 1,
|
||||
title: 'Track 1'
|
||||
};
|
||||
const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate);
|
||||
const relativeDir = path.dirname(relativeBasePath);
|
||||
if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) {
|
||||
return baseDir;
|
||||
}
|
||||
return path.join(baseDir, relativeDir);
|
||||
}
|
||||
|
||||
function splitPathSegments(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
|
||||
const outputSegments = splitPathSegments(outputBaseDir);
|
||||
const relativeSegments = splitPathSegments(relativeDir);
|
||||
if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) {
|
||||
return false;
|
||||
}
|
||||
const offset = outputSegments.length - relativeSegments.length;
|
||||
for (let i = 0; i < relativeSegments.length; i++) {
|
||||
if (outputSegments[offset + i] !== relativeSegments[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function stripLeadingRelativeDir(relativeFilePath, relativeDir) {
|
||||
const fileSegments = splitPathSegments(relativeFilePath);
|
||||
const dirSegments = splitPathSegments(relativeDir);
|
||||
if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) {
|
||||
return relativeFilePath;
|
||||
}
|
||||
for (let i = 0; i < dirSegments.length; i++) {
|
||||
if (fileSegments[i] !== dirSegments[i]) {
|
||||
return relativeFilePath;
|
||||
}
|
||||
}
|
||||
return path.join(...fileSegments.slice(dirSegments.length));
|
||||
}
|
||||
|
||||
function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format);
|
||||
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
|
||||
const relativeDir = path.dirname(relativeBasePath);
|
||||
let relativeFilePath = `${relativeBasePath}.${ext}`;
|
||||
if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) {
|
||||
if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) {
|
||||
relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir);
|
||||
}
|
||||
}
|
||||
const outFile = path.join(outputBaseDir, relativeFilePath);
|
||||
return {
|
||||
outFile,
|
||||
relativeFilePath,
|
||||
outFilename: path.basename(relativeFilePath)
|
||||
};
|
||||
}
|
||||
|
||||
function buildCancelledError() {
|
||||
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
error.statusCode = 409;
|
||||
return error;
|
||||
}
|
||||
|
||||
function assertNotCancelled(isCancelled) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
throw buildCancelledError();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExitCode(error) {
|
||||
const code = Number(error?.code);
|
||||
if (Number.isFinite(code)) {
|
||||
return Math.trunc(code);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function quoteShellArg(value) {
|
||||
const text = String(value == null ? '' : value);
|
||||
if (!text) {
|
||||
return "''";
|
||||
}
|
||||
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
return `'${text.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function formatCommandLine(cmd, args = []) {
|
||||
const normalizedArgs = Array.isArray(args) ? args : [];
|
||||
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
|
||||
}
|
||||
|
||||
function copyFilePreservingRaw(sourcePath, targetPath) {
|
||||
const rawSource = String(sourcePath || '').trim();
|
||||
const rawTarget = String(targetPath || '').trim();
|
||||
if (!rawSource || !rawTarget) {
|
||||
return;
|
||||
}
|
||||
const source = path.resolve(rawSource);
|
||||
const target = path.resolve(rawTarget);
|
||||
if (source === target) {
|
||||
return;
|
||||
}
|
||||
fs.copyFileSync(source, target);
|
||||
}
|
||||
|
||||
async function runProcessTracked({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
onStdoutLine,
|
||||
onStderrLine,
|
||||
context,
|
||||
onProcessHandle,
|
||||
isCancelled
|
||||
}) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
onStdoutLine,
|
||||
onStderrLine,
|
||||
context
|
||||
});
|
||||
if (typeof onProcessHandle === 'function') {
|
||||
onProcessHandle(handle);
|
||||
}
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
handle.cancel();
|
||||
}
|
||||
try {
|
||||
return await handle.promise;
|
||||
} catch (error) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
throw buildCancelledError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rip and encode a CD.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.jobId - Job ID for logging
|
||||
* @param {string} options.devicePath - e.g. /dev/sr0
|
||||
* @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia
|
||||
* @param {string} options.rawWavDir - temp dir for WAV files
|
||||
* @param {string} options.outputDir - final output dir
|
||||
* @param {string} options.format - wav|flac|mp3|opus|ogg
|
||||
* @param {object} options.formatOptions - encoder-specific options
|
||||
* @param {number[]} options.selectedTracks - track positions to rip (empty = all)
|
||||
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
||||
* @param {object} options.meta - album metadata {title, artist, year}
|
||||
* @param {string} options.outputTemplate - template for relative output path without extension
|
||||
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
||||
* @param {Function} options.onLog - (level, msg) => void
|
||||
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
|
||||
* @param {Function} options.isCancelled - returns true when user requested cancellation
|
||||
* @param {object} options.context - passed to spawnTrackedProcess
|
||||
*/
|
||||
async function ripAndEncode(options) {
|
||||
const {
|
||||
jobId,
|
||||
devicePath,
|
||||
cdparanoiaCmd = 'cdparanoia',
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
format = 'flac',
|
||||
formatOptions = {},
|
||||
selectedTracks = [],
|
||||
tracks = [],
|
||||
meta = {},
|
||||
outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||
onProgress,
|
||||
onLog,
|
||||
onProcessHandle,
|
||||
isCancelled,
|
||||
context
|
||||
} = options;
|
||||
|
||||
if (!SUPPORTED_FORMATS.has(format)) {
|
||||
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
||||
}
|
||||
|
||||
const tracksToRip = selectedTracks.length > 0
|
||||
? tracks.filter((t) => selectedTracks.includes(t.position))
|
||||
: tracks;
|
||||
|
||||
if (tracksToRip.length === 0) {
|
||||
throw new Error('Keine Tracks zum Rippen ausgewählt.');
|
||||
}
|
||||
|
||||
await ensureDir(rawWavDir);
|
||||
await ensureDir(outputDir);
|
||||
|
||||
logger.info('rip:start', {
|
||||
jobId,
|
||||
devicePath,
|
||||
format,
|
||||
trackCount: tracksToRip.length
|
||||
});
|
||||
|
||||
const log = (level, msg) => {
|
||||
logger[level] && logger[level](msg, { jobId });
|
||||
onLog && onLog(level, msg);
|
||||
};
|
||||
|
||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
const ripArgs = ['-d', devicePath, String(track.position), wavFile];
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackEvent: 'start',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: (i / tracksToRip.length) * 50
|
||||
});
|
||||
|
||||
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
||||
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
|
||||
|
||||
try {
|
||||
await runProcessTracked({
|
||||
cmd: cdparanoiaCmd,
|
||||
args: ripArgs,
|
||||
cwd: rawWavDir,
|
||||
onStderrLine(line) {
|
||||
const parsed = parseCdParanoiaProgress(line);
|
||||
if (parsed && parsed.percent !== null) {
|
||||
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackEvent: 'progress',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: parsed.percent,
|
||||
percent: overallPercent
|
||||
});
|
||||
}
|
||||
},
|
||||
context,
|
||||
onProcessHandle,
|
||||
isCancelled
|
||||
});
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||||
);
|
||||
}
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'rip',
|
||||
trackEvent: 'complete',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: ((i + 1) / tracksToRip.length) * 50
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} gerippt.`);
|
||||
}
|
||||
|
||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||
if (format === 'wav') {
|
||||
// Keep RAW WAVs in place and copy them to the final output structure.
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackEvent: 'start',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||
});
|
||||
ensureDir(path.dirname(outFile));
|
||||
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||||
copyFilePreservingRaw(wavFile, outFile);
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackEvent: 'complete',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||
});
|
||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||
}
|
||||
return { outputDir, format, trackCount: tracksToRip.length };
|
||||
}
|
||||
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
|
||||
}
|
||||
|
||||
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
|
||||
ensureDir(path.dirname(outFile));
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackEvent: 'start',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 0,
|
||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||
});
|
||||
|
||||
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
||||
|
||||
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
|
||||
log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`);
|
||||
|
||||
try {
|
||||
await runProcessTracked({
|
||||
cmd: encodeArgs.cmd,
|
||||
args: encodeArgs.args,
|
||||
cwd: rawWavDir,
|
||||
onStdoutLine() {},
|
||||
onStderrLine() {},
|
||||
context,
|
||||
onProcessHandle,
|
||||
isCancelled
|
||||
});
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||||
);
|
||||
}
|
||||
|
||||
// Safety net: some encoders (e.g. older flac without --no-delete-input-file) may remove
|
||||
// the source WAV. Restore it from the encoded output so the RAW folder stays filled.
|
||||
if (!fs.existsSync(wavFile) && fs.existsSync(outFile)) {
|
||||
try {
|
||||
fs.copyFileSync(outFile, wavFile);
|
||||
log('info', `Track ${track.position}: WAV-Quelldatei vom Encoder gelöscht – aus Output wiederhergestellt.`);
|
||||
} catch (restoreErr) {
|
||||
log('warn', `Track ${track.position}: WAV-Wiederherstellung fehlgeschlagen: ${restoreErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress && onProgress({
|
||||
phase: 'encode',
|
||||
trackEvent: 'complete',
|
||||
trackIndex: i + 1,
|
||||
trackTotal: tracksToRip.length,
|
||||
trackPosition: track.position,
|
||||
trackPercent: 100,
|
||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} encodiert.`);
|
||||
}
|
||||
|
||||
return { outputDir, format, trackCount: tracksToRip.length };
|
||||
}
|
||||
|
||||
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
|
||||
const artist = track?.artist || meta?.artist || '';
|
||||
const album = meta?.title || '';
|
||||
const year = meta?.year ? String(meta.year) : '';
|
||||
const trackTitle = track.title || `Track ${track.position}`;
|
||||
const trackNum = String(track.position);
|
||||
|
||||
if (format === 'flac') {
|
||||
const level = Number(opts.flacCompression ?? 5);
|
||||
const clampedLevel = Math.max(0, Math.min(8, level));
|
||||
return {
|
||||
cmd: 'flac',
|
||||
args: [
|
||||
`--compression-level-${clampedLevel}`,
|
||||
'--no-delete-input-file', // flac deletes input WAV by default; keep RAW folder filled
|
||||
'--tag', `TITLE=${trackTitle}`,
|
||||
'--tag', `ARTIST=${artist}`,
|
||||
'--tag', `ALBUM=${album}`,
|
||||
'--tag', `DATE=${year}`,
|
||||
'--tag', `TRACKNUMBER=${trackNum}`,
|
||||
wavFile,
|
||||
'-o', outFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (format === 'mp3') {
|
||||
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
|
||||
const args = ['--id3v2-only', '--noreplaygain'];
|
||||
if (mode === 'vbr') {
|
||||
const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)));
|
||||
args.push('-V', String(quality));
|
||||
} else {
|
||||
const bitrate = Number(opts.mp3Bitrate ?? 192);
|
||||
args.push('-b', String(bitrate));
|
||||
}
|
||||
args.push(
|
||||
'--tt', trackTitle,
|
||||
'--ta', artist,
|
||||
'--tl', album,
|
||||
'--ty', year,
|
||||
'--tn', trackNum,
|
||||
wavFile,
|
||||
outFile
|
||||
);
|
||||
return { cmd: 'lame', args };
|
||||
}
|
||||
|
||||
if (format === 'opus') {
|
||||
const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160)));
|
||||
const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10)));
|
||||
return {
|
||||
cmd: 'opusenc',
|
||||
args: [
|
||||
'--bitrate', String(bitrate),
|
||||
'--comp', String(complexity),
|
||||
'--title', trackTitle,
|
||||
'--artist', artist,
|
||||
'--album', album,
|
||||
'--date', year,
|
||||
'--tracknumber', trackNum,
|
||||
wavFile,
|
||||
outFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (format === 'ogg') {
|
||||
const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)));
|
||||
return {
|
||||
cmd: 'oggenc',
|
||||
args: [
|
||||
'-q', String(quality),
|
||||
'-t', trackTitle,
|
||||
'-a', artist,
|
||||
'-l', album,
|
||||
'-d', year,
|
||||
'-N', trackNum,
|
||||
'-o', outFile,
|
||||
wavFile
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unbekanntes Format: ${format}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseToc,
|
||||
readToc,
|
||||
ripAndEncode,
|
||||
buildOutputDir,
|
||||
buildOutputFilename,
|
||||
DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||
SUPPORTED_FORMATS
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const notificationService = require('./notificationService');
|
||||
const settingsService = require('./settingsService');
|
||||
const wsService = require('./websocketService');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
// Maximale Zeilen pro Log-Eintrag (Output-Truncation)
|
||||
@@ -252,33 +253,57 @@ async function runCronJob(job) {
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const { spawn } = require('child_process');
|
||||
const child = spawn(prepared.cmd, prepared.args, {
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
const processHandle = spawnTrackedProcess({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
context: { source: 'cron', cronJobId: job.id, scriptId: script.id },
|
||||
onStdoutLine: (line) => {
|
||||
const next = stdout.length <= MAX_OUTPUT_CHARS
|
||||
? `${stdout}${line}\n`
|
||||
: stdout;
|
||||
stdout = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
|
||||
stdoutTruncated = stdoutTruncated || next.length > MAX_OUTPUT_CHARS;
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
const next = stderr.length <= MAX_OUTPUT_CHARS
|
||||
? `${stderr}${line}\n`
|
||||
: stderr;
|
||||
stderr = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
|
||||
stderrTruncated = stderrTruncated || next.length > MAX_OUTPUT_CHARS;
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const result = await processHandle.promise;
|
||||
exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
|
||||
} catch (error) {
|
||||
exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
|
||||
if (exitCode === null) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
output = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
||||
output = [stdout, stderr].filter(Boolean).join('\n');
|
||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||
success = result.code === 0;
|
||||
if (!success) errorMessage = `Exit-Code ${result.code}`;
|
||||
success = exitCode === 0;
|
||||
if (!success) errorMessage = `Exit-Code ${exitCode}`;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode: result.code,
|
||||
exitCode,
|
||||
message: success ? null : errorMessage,
|
||||
output: output || null,
|
||||
stdout: result.stdout || null,
|
||||
stderr: result.stderr || null,
|
||||
stdout: stdout || null,
|
||||
stderr: stderr || null,
|
||||
stdoutTruncated,
|
||||
stderrTruncated,
|
||||
errorMessage: success ? null : (errorMessage || null)
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,9 +4,42 @@ const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('DISK');
|
||||
const { parseToc } = require('./cdRipService');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_POLL_INTERVAL_MS = 4000;
|
||||
const MIN_POLL_INTERVAL_MS = 1000;
|
||||
const MAX_POLL_INTERVAL_MS = 60000;
|
||||
|
||||
function toBoolean(value, fallback = false) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||
return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function clampPollIntervalMs(rawValue) {
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
const clamped = Math.max(MIN_POLL_INTERVAL_MS, Math.min(MAX_POLL_INTERVAL_MS, Math.trunc(parsed)));
|
||||
return clamped || DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function flattenDevices(nodes, acc = []) {
|
||||
for (const node of nodes || []) {
|
||||
@@ -52,14 +85,14 @@ function normalizeMediaProfile(rawValue) {
|
||||
) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') {
|
||||
return 'other';
|
||||
if (value === 'cd' || value === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSpecificMediaProfile(value) {
|
||||
return value === 'bluray' || value === 'dvd';
|
||||
return value === 'bluray' || value === 'dvd' || value === 'cd';
|
||||
}
|
||||
|
||||
function inferMediaProfileFromTextParts(parts) {
|
||||
@@ -82,6 +115,9 @@ function inferMediaProfileFromTextParts(parts) {
|
||||
|
||||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||
const fstype = String(rawFsType || '').trim().toLowerCase();
|
||||
if (fstype === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
const model = String(rawModel || '').trim().toLowerCase();
|
||||
const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model);
|
||||
const hasDvdModelMarker = /dvd/.test(model);
|
||||
@@ -142,7 +178,7 @@ function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (hasFlag('ID_CDROM_MEDIA_CD')) {
|
||||
return 'other';
|
||||
return 'cd';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -155,6 +191,7 @@ class DiskDetectionService extends EventEmitter {
|
||||
this.lastDetected = null;
|
||||
this.lastPresent = false;
|
||||
this.deviceLocks = new Map();
|
||||
this.pollingSuspended = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -175,24 +212,47 @@ class DiskDetectionService extends EventEmitter {
|
||||
logger.info('stop');
|
||||
}
|
||||
|
||||
suspendPolling() {
|
||||
if (!this.pollingSuspended) {
|
||||
this.pollingSuspended = true;
|
||||
logger.info('polling:suspended');
|
||||
}
|
||||
}
|
||||
|
||||
resumePolling() {
|
||||
if (this.pollingSuspended) {
|
||||
this.pollingSuspended = false;
|
||||
logger.info('polling:resumed');
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNext(delayMs) {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer = setTimeout(async () => {
|
||||
let nextDelay = 4000;
|
||||
let nextDelay = DEFAULT_POLL_INTERVAL_MS;
|
||||
|
||||
try {
|
||||
const map = await settingsService.getSettingsMap();
|
||||
nextDelay = Number(map.disc_poll_interval_ms || 4000);
|
||||
nextDelay = clampPollIntervalMs(map.disc_poll_interval_ms);
|
||||
const autoDetectionEnabled = toBoolean(map.disc_auto_detection_enabled, true);
|
||||
logger.debug('poll:tick', {
|
||||
driveMode: map.drive_mode,
|
||||
driveDevice: map.drive_device,
|
||||
nextDelay
|
||||
nextDelay,
|
||||
autoDetectionEnabled,
|
||||
suspended: this.pollingSuspended
|
||||
});
|
||||
const detected = await this.detectDisc(map);
|
||||
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
||||
if (this.pollingSuspended) {
|
||||
logger.debug('poll:skip:suspended', { nextDelay });
|
||||
} else if (autoDetectionEnabled) {
|
||||
const detected = await this.detectDisc(map);
|
||||
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
||||
} else {
|
||||
logger.debug('poll:skip:auto-detection-disabled', { nextDelay });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('poll:error', { error: errorToMeta(error) });
|
||||
this.emit('error', error);
|
||||
@@ -493,22 +553,74 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
|
||||
async checkMediaPresent(devicePath) {
|
||||
let blkidType = null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||
const type = String(stdout || '').trim().toLowerCase();
|
||||
const has = type.length > 0;
|
||||
logger.debug('blkid:result', { devicePath, hasMedia: has, type });
|
||||
return {
|
||||
hasMedia: has,
|
||||
type: type || null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(error) });
|
||||
return {
|
||||
hasMedia: false,
|
||||
type: null
|
||||
};
|
||||
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
||||
} catch (_error) {
|
||||
// blkid failed – could mean no disc, or an audio CD (no filesystem type)
|
||||
}
|
||||
|
||||
if (blkidType) {
|
||||
logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType });
|
||||
return { hasMedia: true, type: blkidType };
|
||||
}
|
||||
|
||||
// blkid found nothing – audio CDs have no filesystem, so fall back to udevadm
|
||||
try {
|
||||
const { stdout } = await execFileAsync('udevadm', [
|
||||
'info',
|
||||
'--query=property',
|
||||
'--name',
|
||||
devicePath
|
||||
]);
|
||||
const props = {};
|
||||
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||
const idx = line.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1');
|
||||
const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1');
|
||||
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
|
||||
if (hasCD && !hasDVD && !hasBD) {
|
||||
logger.debug('udevadm:audio-cd', { devicePath });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
} catch (_udevError) {
|
||||
// udevadm not available or failed – ignore
|
||||
}
|
||||
|
||||
// Last resort: cdparanoia can read the TOC of audio CDs directly.
|
||||
// Useful when udev media flags are not propagated (e.g. VM passthrough).
|
||||
// Some builds return non-zero even when TOC output exists, so parse both
|
||||
// stdout/stderr and treat valid TOC lines as "audio CD present".
|
||||
// Keep compatibility with previous behavior: exit 0 counts as media even
|
||||
// when TOC output format cannot be parsed.
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
|
||||
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
} catch (cdError) {
|
||||
const stderr = String(cdError?.stderr || '');
|
||||
const stdout = String(cdError?.stdout || '');
|
||||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
// cdparanoia failed and no TOC output could be parsed.
|
||||
}
|
||||
|
||||
logger.debug('blkid:no-media-or-fail', { devicePath });
|
||||
return { hasMedia: false, type: null };
|
||||
}
|
||||
|
||||
async getDiscLabel(devicePath) {
|
||||
@@ -560,6 +672,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
|
||||
async inferMediaProfile(devicePath, hints = {}) {
|
||||
// Audio CDs have no filesystem – short-circuit immediately
|
||||
if (String(hints?.fstype || '').trim().toLowerCase() === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
|
||||
const explicit = normalizeMediaProfile(hints?.mediaProfile);
|
||||
if (isSpecificMediaProfile(explicit)) {
|
||||
return explicit;
|
||||
|
||||
537
backend/src/services/downloadService.js
Normal file
537
backend/src/services/downloadService.js
Normal file
@@ -0,0 +1,537 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
const archiver = require('archiver');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const wsService = require('./websocketService');
|
||||
const logger = require('./logger').child('DOWNLOADS');
|
||||
|
||||
function safeJsonParse(raw, fallback = null) {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDownloadId(value) {
|
||||
const raw = String(value || '').trim();
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (['queued', 'processing', 'ready', 'failed'].includes(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function normalizeTarget(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (raw === 'raw') {
|
||||
return 'raw';
|
||||
}
|
||||
if (raw === 'output') {
|
||||
return 'output';
|
||||
}
|
||||
return 'output';
|
||||
}
|
||||
|
||||
function normalizeDateString(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(raw);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
function normalizeNumber(value, fallback = null) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function compareCreatedDesc(a, b) {
|
||||
const left = String(a?.createdAt || '');
|
||||
const right = String(b?.createdAt || '');
|
||||
return right.localeCompare(left) || String(b?.id || '').localeCompare(String(a?.id || ''));
|
||||
}
|
||||
|
||||
function applyOwnerToPath(targetPath, ownerSpec) {
|
||||
const spec = String(ownerSpec || '').trim();
|
||||
if (!targetPath || !spec) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = spawnSync('chown', [spec, targetPath], { timeout: 15000 });
|
||||
if (result.status !== 0) {
|
||||
logger.warn('download:chown:failed', {
|
||||
targetPath,
|
||||
spec,
|
||||
stderr: String(result.stderr || '').trim() || null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('download:chown:error', {
|
||||
targetPath,
|
||||
spec,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadService {
|
||||
constructor() {
|
||||
this.items = new Map();
|
||||
this.activeTasks = new Map();
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this._init();
|
||||
}
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
async _init() {
|
||||
const settings = await settingsService.getEffectiveSettingsMap(null);
|
||||
const downloadDir = String(settings?.download_dir || '').trim();
|
||||
const owner = String(settings?.download_dir_owner || '').trim() || null;
|
||||
await fs.promises.mkdir(downloadDir, { recursive: true });
|
||||
applyOwnerToPath(downloadDir, owner);
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(downloadDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
logger.warn('download:init:readdir-failed', {
|
||||
downloadDir,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
entries = [];
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
||||
continue;
|
||||
}
|
||||
const metaPath = path.join(downloadDir, entry.name);
|
||||
const parsed = safeJsonParse(await fs.promises.readFile(metaPath, 'utf-8').catch(() => null), null);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const item = this._normalizeLoadedItem(parsed, downloadDir);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (item.status === 'queued' || item.status === 'processing') {
|
||||
item.status = 'failed';
|
||||
item.errorMessage = 'ZIP-Erstellung wurde durch einen Server-Neustart unterbrochen.';
|
||||
item.finishedAt = nowIso;
|
||||
changed = true;
|
||||
await this._safeUnlink(item.partialPath);
|
||||
} else if (item.status === 'ready') {
|
||||
const exists = await this._pathExists(item.archivePath);
|
||||
if (!exists) {
|
||||
item.status = 'failed';
|
||||
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
|
||||
item.finishedAt = nowIso;
|
||||
item.sizeBytes = null;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.items.set(item.id, item);
|
||||
if (changed) {
|
||||
await this._persistItem(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeLoadedItem(rawItem, fallbackDir) {
|
||||
const id = normalizeDownloadId(rawItem?.id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const downloadDir = String(rawItem?.downloadDir || fallbackDir || '').trim();
|
||||
if (!downloadDir) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
kind: String(rawItem?.kind || 'history').trim() || 'history',
|
||||
jobId: normalizeNumber(rawItem?.jobId, null),
|
||||
target: normalizeTarget(rawItem?.target),
|
||||
label: String(rawItem?.label || (rawItem?.target === 'raw' ? 'RAW' : 'Encode')).trim() || 'Download',
|
||||
displayTitle: String(rawItem?.displayTitle || '').trim() || null,
|
||||
sourcePath: String(rawItem?.sourcePath || '').trim() || null,
|
||||
sourceType: String(rawItem?.sourceType || '').trim() === 'file' ? 'file' : 'directory',
|
||||
sourceMtimeMs: normalizeNumber(rawItem?.sourceMtimeMs, null),
|
||||
sourceModifiedAt: normalizeDateString(rawItem?.sourceModifiedAt),
|
||||
entryName: String(rawItem?.entryName || '').trim() || null,
|
||||
archiveName: String(rawItem?.archiveName || `${id}.zip`).trim() || `${id}.zip`,
|
||||
downloadDir,
|
||||
archivePath: String(rawItem?.archivePath || path.join(downloadDir, `${id}.zip`)).trim(),
|
||||
partialPath: String(rawItem?.partialPath || path.join(downloadDir, `${id}.partial.zip`)).trim(),
|
||||
metaPath: String(rawItem?.metaPath || path.join(downloadDir, `${id}.json`)).trim(),
|
||||
ownerSpec: String(rawItem?.ownerSpec || '').trim() || null,
|
||||
status: normalizeStatus(rawItem?.status),
|
||||
createdAt: normalizeDateString(rawItem?.createdAt) || new Date().toISOString(),
|
||||
startedAt: normalizeDateString(rawItem?.startedAt),
|
||||
finishedAt: normalizeDateString(rawItem?.finishedAt),
|
||||
errorMessage: String(rawItem?.errorMessage || '').trim() || null,
|
||||
sizeBytes: normalizeNumber(rawItem?.sizeBytes, null)
|
||||
};
|
||||
}
|
||||
|
||||
_serializeItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
jobId: item.jobId,
|
||||
target: item.target,
|
||||
label: item.label,
|
||||
displayTitle: item.displayTitle,
|
||||
sourcePath: item.sourcePath,
|
||||
sourceType: item.sourceType,
|
||||
archiveName: item.archiveName,
|
||||
downloadDir: item.downloadDir,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt,
|
||||
startedAt: item.startedAt,
|
||||
finishedAt: item.finishedAt,
|
||||
errorMessage: item.errorMessage,
|
||||
sizeBytes: item.sizeBytes,
|
||||
downloadUrl: item.status === 'ready' ? `/api/downloads/${encodeURIComponent(item.id)}/file` : null
|
||||
};
|
||||
}
|
||||
|
||||
getSummary() {
|
||||
const items = Array.from(this.items.values());
|
||||
const queuedCount = items.filter((item) => item.status === 'queued').length;
|
||||
const processingCount = items.filter((item) => item.status === 'processing').length;
|
||||
const readyCount = items.filter((item) => item.status === 'ready').length;
|
||||
const failedCount = items.filter((item) => item.status === 'failed').length;
|
||||
|
||||
return {
|
||||
totalCount: items.length,
|
||||
queuedCount,
|
||||
processingCount,
|
||||
activeCount: queuedCount + processingCount,
|
||||
readyCount,
|
||||
failedCount
|
||||
};
|
||||
}
|
||||
|
||||
_broadcastUpdate(reason, item = null) {
|
||||
wsService.broadcast('DOWNLOADS_UPDATED', {
|
||||
reason: String(reason || 'updated').trim() || 'updated',
|
||||
summary: this.getSummary(),
|
||||
item: item ? this._serializeItem(item) : null
|
||||
});
|
||||
}
|
||||
|
||||
async listItems() {
|
||||
await this.init();
|
||||
return Array.from(this.items.values())
|
||||
.sort(compareCreatedDesc)
|
||||
.map((item) => this._serializeItem(item));
|
||||
}
|
||||
|
||||
async getItem(id) {
|
||||
await this.init();
|
||||
const normalizedId = normalizeDownloadId(id);
|
||||
if (!normalizedId || !this.items.has(normalizedId)) {
|
||||
const error = new Error('Download nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return this.items.get(normalizedId);
|
||||
}
|
||||
|
||||
async enqueueHistoryJob(jobId, target) {
|
||||
await this.init();
|
||||
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target);
|
||||
const settings = await settingsService.getEffectiveSettingsMap(null);
|
||||
const downloadDir = String(settings?.download_dir || '').trim();
|
||||
const ownerSpec = String(settings?.download_dir_owner || '').trim() || null;
|
||||
await fs.promises.mkdir(downloadDir, { recursive: true });
|
||||
applyOwnerToPath(downloadDir, ownerSpec);
|
||||
|
||||
const reusable = await this._findReusableHistoryItem(descriptor, downloadDir);
|
||||
if (reusable) {
|
||||
return {
|
||||
item: this._serializeItem(reusable),
|
||||
reused: true,
|
||||
created: false
|
||||
};
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const nowIso = new Date().toISOString();
|
||||
const item = {
|
||||
id,
|
||||
kind: 'history',
|
||||
jobId: descriptor.jobId,
|
||||
target: descriptor.target,
|
||||
label: descriptor.target === 'raw' ? 'RAW' : 'Encode',
|
||||
displayTitle: descriptor.displayTitle,
|
||||
sourcePath: descriptor.sourcePath,
|
||||
sourceType: descriptor.sourceType,
|
||||
sourceMtimeMs: descriptor.sourceMtimeMs,
|
||||
sourceModifiedAt: descriptor.sourceModifiedAt,
|
||||
entryName: descriptor.entryName,
|
||||
archiveName: descriptor.archiveName,
|
||||
downloadDir,
|
||||
archivePath: path.join(downloadDir, `${id}.zip`),
|
||||
partialPath: path.join(downloadDir, `${id}.partial.zip`),
|
||||
metaPath: path.join(downloadDir, `${id}.json`),
|
||||
ownerSpec,
|
||||
status: 'queued',
|
||||
createdAt: nowIso,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
errorMessage: null,
|
||||
sizeBytes: null
|
||||
};
|
||||
|
||||
this.items.set(id, item);
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('queued', item);
|
||||
|
||||
setImmediate(() => {
|
||||
void this._startArchiveJob(id);
|
||||
});
|
||||
|
||||
return {
|
||||
item: this._serializeItem(item),
|
||||
reused: false,
|
||||
created: true
|
||||
};
|
||||
}
|
||||
|
||||
async _findReusableHistoryItem(descriptor, downloadDir) {
|
||||
for (const item of this.items.values()) {
|
||||
if (item.kind !== 'history') {
|
||||
continue;
|
||||
}
|
||||
if (item.jobId !== descriptor.jobId || item.target !== descriptor.target) {
|
||||
continue;
|
||||
}
|
||||
if (item.sourcePath !== descriptor.sourcePath || item.sourceMtimeMs !== descriptor.sourceMtimeMs) {
|
||||
continue;
|
||||
}
|
||||
if (item.downloadDir !== downloadDir) {
|
||||
continue;
|
||||
}
|
||||
if (!['queued', 'processing', 'ready'].includes(item.status)) {
|
||||
continue;
|
||||
}
|
||||
if (item.status === 'ready' && !(await this._pathExists(item.archivePath))) {
|
||||
item.status = 'failed';
|
||||
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
|
||||
item.finishedAt = new Date().toISOString();
|
||||
item.sizeBytes = null;
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('failed', item);
|
||||
continue;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async _startArchiveJob(id) {
|
||||
const item = this.items.get(id);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (this.activeTasks.has(id)) {
|
||||
return this.activeTasks.get(id);
|
||||
}
|
||||
|
||||
const promise = this._runArchiveJob(item)
|
||||
.catch((error) => {
|
||||
logger.warn('download:job:failed', {
|
||||
id,
|
||||
archiveName: item.archiveName,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.activeTasks.delete(id);
|
||||
});
|
||||
|
||||
this.activeTasks.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async _runArchiveJob(item) {
|
||||
item.status = 'processing';
|
||||
item.startedAt = new Date().toISOString();
|
||||
item.finishedAt = null;
|
||||
item.errorMessage = null;
|
||||
item.sizeBytes = null;
|
||||
await this._safeUnlink(item.partialPath);
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('processing', item);
|
||||
|
||||
await fs.promises.mkdir(item.downloadDir, { recursive: true });
|
||||
applyOwnerToPath(item.downloadDir, item.ownerSpec);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const output = fs.createWriteStream(item.partialPath);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
const finishError = (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
output.destroy();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
output.on('close', () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve();
|
||||
});
|
||||
output.on('error', finishError);
|
||||
archive.on('warning', finishError);
|
||||
archive.on('error', finishError);
|
||||
|
||||
archive.pipe(output);
|
||||
if (item.sourceType === 'directory') {
|
||||
archive.directory(item.sourcePath, item.entryName);
|
||||
} else {
|
||||
archive.file(item.sourcePath, { name: item.entryName });
|
||||
}
|
||||
|
||||
try {
|
||||
const finalizeResult = archive.finalize();
|
||||
if (finalizeResult && typeof finalizeResult.catch === 'function') {
|
||||
finalizeResult.catch(finishError);
|
||||
}
|
||||
} catch (error) {
|
||||
finishError(error);
|
||||
}
|
||||
}).catch(async (error) => {
|
||||
await this._safeUnlink(item.partialPath);
|
||||
item.status = 'failed';
|
||||
item.finishedAt = new Date().toISOString();
|
||||
item.errorMessage = error?.message || 'ZIP-Erstellung fehlgeschlagen.';
|
||||
item.sizeBytes = null;
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('failed', item);
|
||||
throw error;
|
||||
});
|
||||
|
||||
await fs.promises.rename(item.partialPath, item.archivePath);
|
||||
applyOwnerToPath(item.archivePath, item.ownerSpec);
|
||||
|
||||
const stat = await fs.promises.stat(item.archivePath);
|
||||
item.status = 'ready';
|
||||
item.finishedAt = new Date().toISOString();
|
||||
item.errorMessage = null;
|
||||
item.sizeBytes = stat.size;
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('ready', item);
|
||||
}
|
||||
|
||||
async getDownloadDescriptor(id) {
|
||||
const item = await this.getItem(id);
|
||||
if (item.status !== 'ready') {
|
||||
const error = new Error('ZIP-Datei ist noch nicht fertig.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
const exists = await this._pathExists(item.archivePath);
|
||||
if (!exists) {
|
||||
item.status = 'failed';
|
||||
item.finishedAt = new Date().toISOString();
|
||||
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
|
||||
item.sizeBytes = null;
|
||||
await this._persistItem(item);
|
||||
this._broadcastUpdate('failed', item);
|
||||
const error = new Error('ZIP-Datei wurde nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
path: item.archivePath,
|
||||
archiveName: item.archiveName
|
||||
};
|
||||
}
|
||||
|
||||
async deleteItem(id) {
|
||||
const item = await this.getItem(id);
|
||||
if (item.status === 'queued' || item.status === 'processing' || this.activeTasks.has(item.id)) {
|
||||
const error = new Error('Laufende ZIP-Jobs können nicht gelöscht werden.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this._safeUnlink(item.archivePath);
|
||||
await this._safeUnlink(item.partialPath);
|
||||
await this._safeUnlink(item.metaPath);
|
||||
this.items.delete(item.id);
|
||||
this._broadcastUpdate('deleted', item);
|
||||
return {
|
||||
deleted: true,
|
||||
id: item.id
|
||||
};
|
||||
}
|
||||
|
||||
async _persistItem(item) {
|
||||
const next = {
|
||||
...item,
|
||||
metaPath: item.metaPath,
|
||||
archivePath: item.archivePath,
|
||||
partialPath: item.partialPath
|
||||
};
|
||||
const tmpMetaPath = `${item.metaPath}.tmp`;
|
||||
await fs.promises.writeFile(tmpMetaPath, JSON.stringify(next, null, 2), 'utf-8');
|
||||
await fs.promises.rename(tmpMetaPath, item.metaPath);
|
||||
applyOwnerToPath(item.metaPath, item.ownerSpec);
|
||||
}
|
||||
|
||||
async _safeUnlink(targetPath) {
|
||||
if (!targetPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.promises.rm(targetPath, { force: true });
|
||||
} catch (_error) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
async _pathExists(targetPath) {
|
||||
if (!targetPath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DownloadService();
|
||||
@@ -20,14 +20,14 @@ const RELEVANT_SETTINGS_KEYS = new Set([
|
||||
'hardware_monitoring_enabled',
|
||||
'hardware_monitoring_interval_ms',
|
||||
'raw_dir',
|
||||
'raw_dir_bluray',
|
||||
'raw_dir_dvd',
|
||||
'raw_dir_cd',
|
||||
'movie_dir',
|
||||
'movie_dir_bluray',
|
||||
'movie_dir_dvd',
|
||||
'log_dir'
|
||||
]);
|
||||
const MONITORED_PATH_DEFINITIONS = [
|
||||
{ key: 'raw_dir', label: 'RAW-Verzeichnis' },
|
||||
{ key: 'movie_dir', label: 'Movie-Verzeichnis' },
|
||||
{ key: 'log_dir', label: 'Log-Verzeichnis' }
|
||||
];
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -53,6 +53,10 @@ function toBoolean(value) {
|
||||
return Boolean(normalized);
|
||||
}
|
||||
|
||||
function normalizePathSetting(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function clampIntervalMs(rawValue) {
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
@@ -392,10 +396,43 @@ class HardwareMonitorService {
|
||||
}
|
||||
|
||||
buildMonitoredPaths(settingsMap = {}) {
|
||||
return MONITORED_PATH_DEFINITIONS.map((definition) => ({
|
||||
...definition,
|
||||
path: String(settingsMap?.[definition.key] || '').trim()
|
||||
}));
|
||||
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||
const bluray = settingsService.resolveEffectiveToolSettings(sourceMap, 'bluray');
|
||||
const dvd = settingsService.resolveEffectiveToolSettings(sourceMap, 'dvd');
|
||||
const cd = settingsService.resolveEffectiveToolSettings(sourceMap, 'cd');
|
||||
const blurayRawPath = normalizePathSetting(bluray?.raw_dir);
|
||||
const dvdRawPath = normalizePathSetting(dvd?.raw_dir);
|
||||
const cdRawPath = normalizePathSetting(cd?.raw_dir);
|
||||
const blurayMoviePath = normalizePathSetting(bluray?.movie_dir);
|
||||
const dvdMoviePath = normalizePathSetting(dvd?.movie_dir);
|
||||
const monitoredPaths = [];
|
||||
|
||||
const addPath = (key, label, monitoredPath) => {
|
||||
monitoredPaths.push({
|
||||
key,
|
||||
label,
|
||||
path: normalizePathSetting(monitoredPath)
|
||||
});
|
||||
};
|
||||
|
||||
if (blurayRawPath && dvdRawPath && blurayRawPath !== dvdRawPath) {
|
||||
addPath('raw_dir_bluray', 'RAW-Verzeichnis (Blu-ray)', blurayRawPath);
|
||||
addPath('raw_dir_dvd', 'RAW-Verzeichnis (DVD)', dvdRawPath);
|
||||
} else {
|
||||
addPath('raw_dir', 'RAW-Verzeichnis', blurayRawPath || dvdRawPath || sourceMap.raw_dir);
|
||||
}
|
||||
addPath('raw_dir_cd', 'CD RAW-Ordner', cdRawPath || sourceMap.raw_dir_cd);
|
||||
|
||||
if (blurayMoviePath && dvdMoviePath && blurayMoviePath !== dvdMoviePath) {
|
||||
addPath('movie_dir_bluray', 'Movie-Verzeichnis (Blu-ray)', blurayMoviePath);
|
||||
addPath('movie_dir_dvd', 'Movie-Verzeichnis (DVD)', dvdMoviePath);
|
||||
} else {
|
||||
addPath('movie_dir', 'Movie-Verzeichnis', blurayMoviePath || dvdMoviePath || sourceMap.movie_dir);
|
||||
}
|
||||
|
||||
addPath('log_dir', 'Log-Verzeichnis', sourceMap.log_dir);
|
||||
|
||||
return monitoredPaths;
|
||||
}
|
||||
|
||||
pathsSignature(paths = []) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
169
backend/src/services/musicBrainzService.js
Normal file
169
backend/src/services/musicBrainzService.js
Normal file
@@ -0,0 +1,169 @@
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('MUSICBRAINZ');
|
||||
|
||||
const MB_BASE = 'https://musicbrainz.org/ws/2';
|
||||
const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)';
|
||||
const MB_TIMEOUT_MS = 10000;
|
||||
|
||||
async function mbFetch(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': MB_USER_AGENT
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) {
|
||||
throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelease(release) {
|
||||
if (!release) {
|
||||
return null;
|
||||
}
|
||||
const artistCredit = Array.isArray(release['artist-credit'])
|
||||
? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ')
|
||||
: null;
|
||||
const date = String(release.date || '').trim();
|
||||
const yearMatch = date.match(/\b(\d{4})\b/);
|
||||
const year = yearMatch ? Number(yearMatch[1]) : null;
|
||||
|
||||
const media = Array.isArray(release.media) ? release.media : [];
|
||||
const normalizedTracks = media.flatMap((medium, mediumIdx) => {
|
||||
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
|
||||
return mediumTracks.map((track, trackIdx) => {
|
||||
const rawPosition = String(track.position || track.number || '').trim();
|
||||
const parsedPosition = Number.parseInt(rawPosition, 10);
|
||||
const fallbackPosition = mediumIdx * 100 + trackIdx + 1;
|
||||
const position = Number.isFinite(parsedPosition) && parsedPosition > 0
|
||||
? parsedPosition
|
||||
: fallbackPosition;
|
||||
return {
|
||||
position,
|
||||
number: String(track.number || track.position || ''),
|
||||
title: String(track.title || ''),
|
||||
durationMs: Number(track.length || 0) || null,
|
||||
rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [],
|
||||
rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : []
|
||||
};
|
||||
});
|
||||
}).map((track) => {
|
||||
const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit)
|
||||
? track.rawTrackArtistCredit
|
||||
: [];
|
||||
const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit)
|
||||
? track.rawRecordingArtistCredit
|
||||
: [];
|
||||
const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
|
||||
const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
|
||||
return {
|
||||
position: track.position,
|
||||
number: track.number,
|
||||
title: track.title,
|
||||
durationMs: track.durationMs,
|
||||
artist: artistFromTrack || artistFromRecording || artistCredit || null
|
||||
};
|
||||
});
|
||||
|
||||
// Always generate the CAA URL when an id is present; the browser/onError
|
||||
// handles 404s for releases that have no front cover.
|
||||
const coverArtUrl = release.id
|
||||
? `https://coverartarchive.org/release/${release.id}/front-250`
|
||||
: null;
|
||||
|
||||
return {
|
||||
mbId: String(release.id || ''),
|
||||
title: String(release.title || ''),
|
||||
artist: artistCredit || null,
|
||||
year,
|
||||
date,
|
||||
country: String(release.country || '').trim() || null,
|
||||
label: Array.isArray(release['label-info'])
|
||||
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
|
||||
: null,
|
||||
coverArtUrl,
|
||||
tracks: normalizedTracks
|
||||
};
|
||||
}
|
||||
|
||||
class MusicBrainzService {
|
||||
async isEnabled() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return settings.musicbrainz_enabled !== 'false';
|
||||
}
|
||||
|
||||
async searchByTitle(query) {
|
||||
const q = String(query || '').trim();
|
||||
if (!q) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
if (!enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info('search:start', { query: q });
|
||||
|
||||
const url = new URL(`${MB_BASE}/release`);
|
||||
url.searchParams.set('query', q);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
url.searchParams.set('limit', '10');
|
||||
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
|
||||
|
||||
try {
|
||||
const data = await mbFetch(url.toString());
|
||||
const releases = Array.isArray(data.releases) ? data.releases : [];
|
||||
const results = releases.map(normalizeRelease).filter(Boolean);
|
||||
logger.info('search:done', { query: q, count: results.length });
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async searchByDiscLabel(discLabel) {
|
||||
return this.searchByTitle(discLabel);
|
||||
}
|
||||
|
||||
async getReleaseById(mbId) {
|
||||
const id = String(mbId || '').trim();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('getById:start', { mbId: id });
|
||||
|
||||
const url = new URL(`${MB_BASE}/release/${id}`);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
|
||||
|
||||
try {
|
||||
const data = await mbFetch(url.toString());
|
||||
const result = normalizeRelease(data);
|
||||
logger.info('getById:done', { mbId: id, title: result?.title });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MusicBrainzService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,5 +109,6 @@ function spawnTrackedProcess({
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
spawnTrackedProcess
|
||||
spawnTrackedProcess,
|
||||
streamLines
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ const wsService = require('./websocketService');
|
||||
const MAX_RECENT_ACTIVITIES = 120;
|
||||
const MAX_ACTIVITY_OUTPUT_CHARS = 12000;
|
||||
const MAX_ACTIVITY_TEXT_CHARS = 2000;
|
||||
const OUTPUT_BROADCAST_THROTTLE_MS = 180;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -28,12 +29,52 @@ function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS
|
||||
return null;
|
||||
}
|
||||
if (text.length > maxChars) {
|
||||
const suffix = trim ? ' ...[gekürzt]' : '\n...[gekürzt]';
|
||||
text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`;
|
||||
if (trim) {
|
||||
const suffix = ' ...[gekürzt]';
|
||||
text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`;
|
||||
} else {
|
||||
const prefix = '...[gekürzt]\n';
|
||||
text = `${prefix}${text.slice(-Math.max(0, maxChars - prefix.length))}`;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeOutputChunk(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
const normalized = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
|
||||
}
|
||||
|
||||
function appendOutputTail(currentValue, chunk, maxChars = MAX_ACTIVITY_OUTPUT_CHARS) {
|
||||
const normalizedChunk = normalizeOutputChunk(chunk);
|
||||
const currentText = currentValue == null ? '' : String(currentValue);
|
||||
if (!normalizedChunk) {
|
||||
return {
|
||||
value: currentText || null,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
|
||||
const combined = `${currentText}${normalizedChunk}`;
|
||||
if (combined.length <= maxChars) {
|
||||
return {
|
||||
value: combined,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: combined.slice(-maxChars),
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeActivity(input = {}) {
|
||||
const source = input && typeof input === 'object' ? input : {};
|
||||
const normalizedOutcome = normalizeText(source.outcome, { trim: true, maxChars: 40 });
|
||||
@@ -61,6 +102,7 @@ function sanitizeActivity(input = {}) {
|
||||
output: normalizeText(source.output, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
stdout: normalizeText(source.stdout, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
stderr: normalizeText(source.stderr, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
outputTruncated: Boolean(source.outputTruncated),
|
||||
stdoutTruncated: Boolean(source.stdoutTruncated),
|
||||
stderrTruncated: Boolean(source.stderrTruncated),
|
||||
startedAt: source.startedAt || nowIso(),
|
||||
@@ -77,6 +119,7 @@ class RuntimeActivityService {
|
||||
this.active = new Map();
|
||||
this.recent = [];
|
||||
this.controls = new Map();
|
||||
this.outputBroadcastTimer = null;
|
||||
}
|
||||
|
||||
buildSnapshot() {
|
||||
@@ -92,9 +135,23 @@ class RuntimeActivityService {
|
||||
}
|
||||
|
||||
broadcastSnapshot() {
|
||||
if (this.outputBroadcastTimer) {
|
||||
clearTimeout(this.outputBroadcastTimer);
|
||||
this.outputBroadcastTimer = null;
|
||||
}
|
||||
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
|
||||
}
|
||||
|
||||
scheduleOutputBroadcast() {
|
||||
if (this.outputBroadcastTimer) {
|
||||
return;
|
||||
}
|
||||
this.outputBroadcastTimer = setTimeout(() => {
|
||||
this.outputBroadcastTimer = null;
|
||||
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
|
||||
}, OUTPUT_BROADCAST_THROTTLE_MS);
|
||||
}
|
||||
|
||||
startActivity(type, payload = {}) {
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
@@ -134,6 +191,35 @@ class RuntimeActivityService {
|
||||
return next;
|
||||
}
|
||||
|
||||
appendActivityOutput(activityId, patch = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = this.active.get(id);
|
||||
const nextOutput = appendOutputTail(current.output, patch?.output, MAX_ACTIVITY_OUTPUT_CHARS);
|
||||
const nextStdout = appendOutputTail(current.stdout, patch?.stdout, MAX_ACTIVITY_OUTPUT_CHARS);
|
||||
const nextStderr = appendOutputTail(current.stderr, patch?.stderr, MAX_ACTIVITY_OUTPUT_CHARS);
|
||||
const next = sanitizeActivity({
|
||||
...current,
|
||||
...patch,
|
||||
id: current.id,
|
||||
type: current.type,
|
||||
status: current.status,
|
||||
startedAt: current.startedAt,
|
||||
output: nextOutput.value,
|
||||
stdout: nextStdout.value,
|
||||
stderr: nextStderr.value,
|
||||
outputTruncated: Boolean(current.outputTruncated || patch?.outputTruncated || nextOutput.truncated),
|
||||
stdoutTruncated: Boolean(current.stdoutTruncated || patch?.stdoutTruncated || nextStdout.truncated),
|
||||
stderrTruncated: Boolean(current.stderrTruncated || patch?.stderrTruncated || nextStderr.truncated)
|
||||
});
|
||||
this.active.set(id, next);
|
||||
this.scheduleOutputBroadcast();
|
||||
return next;
|
||||
}
|
||||
|
||||
completeActivity(activityId, payload = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SCRIPT_CHAINS');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const CHAIN_NAME_MAX_LENGTH = 120;
|
||||
@@ -76,6 +76,28 @@ function terminateChildProcess(child, { immediate = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function appendTailText(currentValue, nextChunk, maxChars = 12000) {
|
||||
const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
if (!chunk) {
|
||||
return {
|
||||
value: currentValue || '',
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`;
|
||||
const combined = `${String(currentValue || '')}${normalizedChunk}`;
|
||||
if (combined.length <= maxChars) {
|
||||
return {
|
||||
value: combined,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: combined.slice(-maxChars),
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function validateSteps(rawSteps) {
|
||||
const steps = Array.isArray(rawSteps) ? rawSteps : [];
|
||||
const errors = [];
|
||||
@@ -615,29 +637,58 @@ class ScriptChainService {
|
||||
scriptName: script.name,
|
||||
source: context?.source || 'chain'
|
||||
});
|
||||
const run = await new Promise((resolve, reject) => {
|
||||
const child = spawn(prepared.cmd, prepared.args, {
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true
|
||||
});
|
||||
controlState.activeChild = child;
|
||||
controlState.activeChildTermination = null;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||
child.on('error', (error) => {
|
||||
controlState.activeChild = null;
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
const termination = controlState.activeChildTermination;
|
||||
controlState.activeChild = null;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
const processHandle = spawnTrackedProcess({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
context: { source: context?.source || 'chain', chainId: chain.id, scriptId: script.id },
|
||||
onStart: (child) => {
|
||||
controlState.activeChild = child;
|
||||
controlState.activeChildTermination = null;
|
||||
resolve({ code, signal, stdout, stderr, termination });
|
||||
});
|
||||
},
|
||||
onStdoutLine: (line) => {
|
||||
const next = appendTailText(stdout, line);
|
||||
stdout = next.value;
|
||||
stdoutTruncated = stdoutTruncated || next.truncated;
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
const next = appendTailText(stderr, line);
|
||||
stderr = next.value;
|
||||
stderrTruncated = stderrTruncated || next.truncated;
|
||||
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
let runError = null;
|
||||
let exitCode = 0;
|
||||
let signal = null;
|
||||
try {
|
||||
const result = await processHandle.promise;
|
||||
exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
|
||||
signal = result?.signal || null;
|
||||
} catch (error) {
|
||||
runError = error;
|
||||
exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
|
||||
signal = error?.signal || null;
|
||||
}
|
||||
const termination = controlState.activeChildTermination;
|
||||
controlState.activeChild = null;
|
||||
controlState.activeChildTermination = null;
|
||||
if (runError && exitCode === null && !termination) {
|
||||
throw runError;
|
||||
}
|
||||
const run = {
|
||||
code: exitCode,
|
||||
signal,
|
||||
stdout,
|
||||
stderr,
|
||||
stdoutTruncated,
|
||||
stderrTruncated,
|
||||
termination
|
||||
};
|
||||
controlState.currentStepType = null;
|
||||
|
||||
if (run.termination === 'skip') {
|
||||
@@ -648,7 +699,11 @@ class ScriptChainService {
|
||||
skipped: true,
|
||||
currentStep: null,
|
||||
message: 'Schritt übersprungen',
|
||||
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null
|
||||
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
|
||||
stdout: run.stdout || null,
|
||||
stderr: run.stderr || null,
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated)
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
@@ -678,6 +733,10 @@ class ScriptChainService {
|
||||
currentStep: null,
|
||||
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
|
||||
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
|
||||
stdout: run.stdout || null,
|
||||
stderr: run.stderr || null,
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated),
|
||||
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
@@ -709,6 +768,8 @@ class ScriptChainService {
|
||||
output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
|
||||
stderr: success ? null : (run.stderr || null),
|
||||
stdout: success ? null : (run.stdout || null),
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated),
|
||||
errorMessage: success ? null : `Fehler (Exit ${run.code})`
|
||||
});
|
||||
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
|
||||
|
||||
@@ -4,14 +4,39 @@ const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SCRIPTS');
|
||||
const settingsService = require('./settingsService');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { streamLines } = require('./processRunner');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const SCRIPT_NAME_MAX_LENGTH = 120;
|
||||
const SCRIPT_BODY_MAX_LENGTH = 200000;
|
||||
const SCRIPT_TEST_TIMEOUT_MS = 120000;
|
||||
const SCRIPT_TEST_TIMEOUT_SETTING_KEY = 'script_test_timeout_ms';
|
||||
const DEFAULT_SCRIPT_TEST_TIMEOUT_MS = 0;
|
||||
const SCRIPT_TEST_TIMEOUT_MS = (() => {
|
||||
const parsed = Number(process.env.RIPSTER_SCRIPT_TEST_TIMEOUT_MS);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.max(0, Math.trunc(parsed));
|
||||
}
|
||||
return DEFAULT_SCRIPT_TEST_TIMEOUT_MS;
|
||||
})();
|
||||
const SCRIPT_OUTPUT_MAX_CHARS = 150000;
|
||||
|
||||
function normalizeScriptTestTimeoutMs(rawValue, fallbackMs = SCRIPT_TEST_TIMEOUT_MS) {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.max(0, Math.trunc(parsed));
|
||||
}
|
||||
if (fallbackMs === null || fallbackMs === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsedFallback = Number(fallbackMs);
|
||||
if (Number.isFinite(parsedFallback)) {
|
||||
return Math.max(0, Math.trunc(parsedFallback));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeScriptId(rawValue) {
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
@@ -182,8 +207,17 @@ function killChildProcessTree(child, signal = 'SIGTERM') {
|
||||
}
|
||||
}
|
||||
|
||||
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) {
|
||||
function runProcessCapture({
|
||||
cmd,
|
||||
args,
|
||||
timeoutMs = SCRIPT_TEST_TIMEOUT_MS,
|
||||
cwd = process.cwd(),
|
||||
onChild = null,
|
||||
onStdoutLine = null,
|
||||
onStderrLine = null
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const effectiveTimeoutMs = normalizeScriptTestTimeoutMs(timeoutMs, SCRIPT_TEST_TIMEOUT_MS);
|
||||
const startedAt = Date.now();
|
||||
let ended = false;
|
||||
const child = spawn(cmd, args, {
|
||||
@@ -206,15 +240,18 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
||||
let stderrTruncated = false;
|
||||
let timedOut = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killChildProcessTree(child, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!ended) {
|
||||
killChildProcessTree(child, 'SIGKILL');
|
||||
}
|
||||
}, 2000);
|
||||
}, Math.max(1000, Number(timeoutMs || SCRIPT_TEST_TIMEOUT_MS)));
|
||||
let timeout = null;
|
||||
if (effectiveTimeoutMs > 0) {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killChildProcessTree(child, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!ended) {
|
||||
killChildProcessTree(child, 'SIGKILL');
|
||||
}
|
||||
}, 2000);
|
||||
}, effectiveTimeoutMs);
|
||||
}
|
||||
|
||||
const onData = (streamName, chunk) => {
|
||||
if (streamName === 'stdout') {
|
||||
@@ -231,15 +268,26 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
||||
child.stdout?.on('data', (chunk) => onData('stdout', chunk));
|
||||
child.stderr?.on('data', (chunk) => onData('stderr', chunk));
|
||||
|
||||
if (child.stdout && typeof onStdoutLine === 'function') {
|
||||
streamLines(child.stdout, onStdoutLine);
|
||||
}
|
||||
if (child.stderr && typeof onStderrLine === 'function') {
|
||||
streamLines(child.stderr, onStderrLine);
|
||||
}
|
||||
|
||||
child.on('error', (error) => {
|
||||
ended = true;
|
||||
clearTimeout(timeout);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
ended = true;
|
||||
clearTimeout(timeout);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const endedAt = Date.now();
|
||||
resolve({
|
||||
code: Number.isFinite(Number(code)) ? Number(code) : null,
|
||||
@@ -255,6 +303,23 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveScriptTestTimeoutMs(options = {}) {
|
||||
const timeoutFromOptions = normalizeScriptTestTimeoutMs(options?.timeoutMs, null);
|
||||
if (timeoutFromOptions !== null) {
|
||||
return timeoutFromOptions;
|
||||
}
|
||||
try {
|
||||
const settingsMap = await settingsService.getSettingsMap();
|
||||
return normalizeScriptTestTimeoutMs(
|
||||
settingsMap?.[SCRIPT_TEST_TIMEOUT_SETTING_KEY],
|
||||
SCRIPT_TEST_TIMEOUT_MS
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('script:test-timeout:settings-read-failed', { error: errorToMeta(error) });
|
||||
return SCRIPT_TEST_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
class ScriptService {
|
||||
async listScripts() {
|
||||
const db = await getDb();
|
||||
@@ -506,8 +571,7 @@ class ScriptService {
|
||||
|
||||
async testScript(scriptId, options = {}) {
|
||||
const script = await this.getScriptById(scriptId);
|
||||
const timeoutMs = Number(options?.timeoutMs);
|
||||
const effectiveTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS;
|
||||
const effectiveTimeoutMs = await resolveScriptTestTimeoutMs(options);
|
||||
const prepared = await this.createExecutableScriptFile(script, {
|
||||
source: 'settings_test',
|
||||
mode: 'test'
|
||||
@@ -549,6 +613,12 @@ class ScriptService {
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
onChild: (child) => {
|
||||
controlState.child = child;
|
||||
},
|
||||
onStdoutLine: (line) => {
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
const exitCode = Number.isFinite(Number(run.code)) ? Number(run.code) : null;
|
||||
|
||||
@@ -13,6 +13,15 @@ const {
|
||||
const { splitArgs } = require('../utils/commandLine');
|
||||
const { setLogRootDir } = require('./logPathService');
|
||||
|
||||
const {
|
||||
defaultRawDir: DEFAULT_RAW_DIR,
|
||||
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
||||
defaultCdDir: DEFAULT_CD_DIR,
|
||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
||||
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR
|
||||
} = require('../config');
|
||||
|
||||
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||
const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000;
|
||||
const SETTINGS_CACHE_TTL_MS = 15000;
|
||||
@@ -36,27 +45,31 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
|
||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'other'];
|
||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook'];
|
||||
const PROFILED_SETTINGS = {
|
||||
raw_dir: {
|
||||
bluray: 'raw_dir_bluray',
|
||||
dvd: 'raw_dir_dvd',
|
||||
other: 'raw_dir_other'
|
||||
cd: 'raw_dir_cd',
|
||||
audiobook: 'raw_dir_audiobook'
|
||||
},
|
||||
raw_dir_owner: {
|
||||
bluray: 'raw_dir_bluray_owner',
|
||||
dvd: 'raw_dir_dvd_owner',
|
||||
other: 'raw_dir_other_owner'
|
||||
cd: 'raw_dir_cd_owner',
|
||||
audiobook: 'raw_dir_audiobook_owner'
|
||||
},
|
||||
movie_dir: {
|
||||
bluray: 'movie_dir_bluray',
|
||||
dvd: 'movie_dir_dvd',
|
||||
other: 'movie_dir_other'
|
||||
cd: 'movie_dir_cd',
|
||||
audiobook: 'movie_dir_audiobook'
|
||||
},
|
||||
movie_dir_owner: {
|
||||
bluray: 'movie_dir_bluray_owner',
|
||||
dvd: 'movie_dir_dvd_owner',
|
||||
other: 'movie_dir_other_owner'
|
||||
cd: 'movie_dir_cd_owner',
|
||||
audiobook: 'movie_dir_audiobook_owner'
|
||||
},
|
||||
mediainfo_extra_args: {
|
||||
bluray: 'mediainfo_extra_args_bluray',
|
||||
@@ -86,13 +99,10 @@ const PROFILED_SETTINGS = {
|
||||
bluray: 'output_extension_bluray',
|
||||
dvd: 'output_extension_dvd'
|
||||
},
|
||||
filename_template: {
|
||||
bluray: 'filename_template_bluray',
|
||||
dvd: 'filename_template_dvd'
|
||||
},
|
||||
output_folder_template: {
|
||||
bluray: 'output_folder_template_bluray',
|
||||
dvd: 'output_folder_template_dvd'
|
||||
output_template: {
|
||||
bluray: 'output_template_bluray',
|
||||
dvd: 'output_template_dvd',
|
||||
audiobook: 'output_template_audiobook'
|
||||
}
|
||||
};
|
||||
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||
@@ -371,23 +381,26 @@ function normalizeMediaProfileValue(value) {
|
||||
) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
||||
return 'other';
|
||||
if (raw === 'cd' || raw === 'audio_cd') {
|
||||
return 'cd';
|
||||
}
|
||||
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
||||
return 'audiobook';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProfileFallbackOrder(profile) {
|
||||
const normalized = normalizeMediaProfileValue(profile);
|
||||
if (normalized === 'audiobook') {
|
||||
return ['audiobook'];
|
||||
}
|
||||
if (normalized === 'bluray') {
|
||||
return ['bluray', 'dvd'];
|
||||
}
|
||||
if (normalized === 'dvd') {
|
||||
return ['dvd', 'bluray'];
|
||||
}
|
||||
if (normalized === 'other') {
|
||||
return ['dvd', 'bluray'];
|
||||
}
|
||||
return ['dvd', 'bluray'];
|
||||
}
|
||||
|
||||
@@ -692,6 +705,26 @@ class SettingsService {
|
||||
if (hasUsableProfileSpecificValue(selectedProfileValue)) {
|
||||
resolvedValue = selectedProfileValue;
|
||||
}
|
||||
// Fallback to hardcoded install defaults when no setting value is configured
|
||||
if (!hasUsableProfileSpecificValue(resolvedValue)) {
|
||||
if (legacyKey === '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') {
|
||||
if (normalizedRequestedProfile === 'cd') {
|
||||
resolvedValue = DEFAULT_CD_DIR;
|
||||
} else if (normalizedRequestedProfile === 'audiobook') {
|
||||
resolvedValue = DEFAULT_AUDIOBOOK_DIR;
|
||||
} else {
|
||||
resolvedValue = DEFAULT_MOVIE_DIR;
|
||||
}
|
||||
}
|
||||
}
|
||||
effective[legacyKey] = resolvedValue;
|
||||
continue;
|
||||
}
|
||||
@@ -708,6 +741,9 @@ class SettingsService {
|
||||
effective[legacyKey] = resolvedValue;
|
||||
}
|
||||
|
||||
effective.download_dir = String(sourceMap.download_dir || '').trim() || DEFAULT_DOWNLOAD_DIR;
|
||||
effective.download_dir_owner = String(sourceMap.download_dir_owner || '').trim() || null;
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
@@ -716,6 +752,29 @@ class SettingsService {
|
||||
return this.resolveEffectiveToolSettings(map, mediaProfile);
|
||||
}
|
||||
|
||||
async getEffectivePaths() {
|
||||
const map = await this.getSettingsMap();
|
||||
const bluray = this.resolveEffectiveToolSettings(map, 'bluray');
|
||||
const dvd = this.resolveEffectiveToolSettings(map, 'dvd');
|
||||
const cd = this.resolveEffectiveToolSettings(map, 'cd');
|
||||
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
|
||||
return {
|
||||
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||
downloads: { path: bluray.download_dir },
|
||||
defaults: {
|
||||
raw: DEFAULT_RAW_DIR,
|
||||
movies: DEFAULT_MOVIE_DIR,
|
||||
cd: DEFAULT_CD_DIR,
|
||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
||||
downloads: DEFAULT_DOWNLOAD_DIR
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fetchFlatSettingsFromDb() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
@@ -1306,20 +1365,10 @@ class SettingsService {
|
||||
}
|
||||
|
||||
resolveSourceArg(map, deviceInfo = null) {
|
||||
const mode = map.drive_mode;
|
||||
if (mode === 'explicit') {
|
||||
const device = map.drive_device;
|
||||
if (!device) {
|
||||
throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.');
|
||||
}
|
||||
return `dev:${device}`;
|
||||
}
|
||||
|
||||
if (deviceInfo && deviceInfo.index !== undefined && deviceInfo.index !== null) {
|
||||
return `disc:${deviceInfo.index}`;
|
||||
}
|
||||
|
||||
return `disc:${map.makemkv_source_index ?? 0}`;
|
||||
// Single-drive setup: always use MakeMKV's first logical disc device.
|
||||
void map;
|
||||
void deviceInfo;
|
||||
return 'disc:0';
|
||||
}
|
||||
|
||||
async loadHandBrakePresetOptionsFromCli(map = {}) {
|
||||
@@ -1466,4 +1515,10 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SettingsService();
|
||||
const settingsServiceInstance = new SettingsService();
|
||||
settingsServiceInstance.DEFAULT_RAW_DIR = DEFAULT_RAW_DIR;
|
||||
settingsServiceInstance.DEFAULT_MOVIE_DIR = DEFAULT_MOVIE_DIR;
|
||||
settingsServiceInstance.DEFAULT_CD_DIR = DEFAULT_CD_DIR;
|
||||
settingsServiceInstance.DEFAULT_AUDIOBOOK_RAW_DIR = DEFAULT_AUDIOBOOK_RAW_DIR;
|
||||
settingsServiceInstance.DEFAULT_AUDIOBOOK_DIR = DEFAULT_AUDIOBOOK_DIR;
|
||||
module.exports = settingsServiceInstance;
|
||||
|
||||
261
backend/src/services/thumbnailService.js
Normal file
261
backend/src/services/thumbnailService.js
Normal file
@@ -0,0 +1,261 @@
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { dataDir } = require('../config');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('THUMBNAIL');
|
||||
|
||||
const THUMBNAILS_DIR = path.join(dataDir, 'thumbnails');
|
||||
const CACHE_DIR = path.join(THUMBNAILS_DIR, 'cache');
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
function ensureDirs() {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function cacheFilePath(jobId) {
|
||||
return path.join(CACHE_DIR, `job-${jobId}.jpg`);
|
||||
}
|
||||
|
||||
function persistentFilePath(jobId) {
|
||||
return path.join(THUMBNAILS_DIR, `job-${jobId}.jpg`);
|
||||
}
|
||||
|
||||
function localUrl(jobId) {
|
||||
return `/api/thumbnails/job-${jobId}.jpg`;
|
||||
}
|
||||
|
||||
function isLocalUrl(url) {
|
||||
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
|
||||
}
|
||||
|
||||
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirectsLeft <= 0) {
|
||||
return reject(new Error('Zu viele Weiterleitungen beim Bild-Download'));
|
||||
}
|
||||
|
||||
const proto = url.startsWith('https') ? https : http;
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const cleanup = () => {
|
||||
try { file.destroy(); } catch (_) {}
|
||||
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
|
||||
};
|
||||
|
||||
proto.get(url, { timeout: 15000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
file.close(() => {
|
||||
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
|
||||
downloadImage(res.headers.location, destPath, redirectsLeft - 1).then(resolve).catch(reject);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
cleanup();
|
||||
return reject(new Error(`HTTP ${res.statusCode} beim Bild-Download`));
|
||||
}
|
||||
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(() => resolve()));
|
||||
file.on('error', (err) => { cleanup(); reject(err); });
|
||||
}).on('error', (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}).on('timeout', function () {
|
||||
this.destroy();
|
||||
cleanup();
|
||||
reject(new Error('Timeout beim Bild-Download'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das Bild einer extern-URL in den Cache herunter.
|
||||
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
|
||||
* @returns {Promise<string|null>} lokaler Pfad oder null
|
||||
*/
|
||||
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||
if (!posterUrl || isLocalUrl(posterUrl)) return null;
|
||||
|
||||
try {
|
||||
ensureDirs();
|
||||
const dest = cacheFilePath(jobId);
|
||||
await downloadImage(posterUrl, dest);
|
||||
logger.info('thumbnail:cached', { jobId, posterUrl, dest });
|
||||
return dest;
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:cache:failed', { jobId, posterUrl, error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt das gecachte Bild in den persistenten Ordner.
|
||||
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
|
||||
* Wird nach erfolgreichem Rip aufgerufen.
|
||||
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
|
||||
*/
|
||||
function promoteJobThumbnail(jobId) {
|
||||
try {
|
||||
ensureDirs();
|
||||
const src = cacheFilePath(jobId);
|
||||
const dest = persistentFilePath(jobId);
|
||||
|
||||
if (fs.existsSync(src)) {
|
||||
fs.renameSync(src, dest);
|
||||
logger.info('thumbnail:promoted', { jobId, dest });
|
||||
return localUrl(jobId);
|
||||
}
|
||||
|
||||
// Falls kein Cache vorhanden, aber persistente Datei schon existiert
|
||||
if (fs.existsSync(dest)) {
|
||||
return localUrl(jobId);
|
||||
}
|
||||
|
||||
logger.warn('thumbnail:promote:no-source', { jobId });
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:promote:failed', { jobId, error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Pfad zum persistenten Thumbnail-Ordner zurück (für Static-Serving).
|
||||
*/
|
||||
function getThumbnailsDir() {
|
||||
return THUMBNAILS_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopiert das persistente Thumbnail von sourceJobId zu targetJobId.
|
||||
* Wird bei Rip-Neustart genutzt, damit der neue Job ein eigenes Bild hat
|
||||
* und nicht auf die Datei des alten Jobs angewiesen ist.
|
||||
* @returns {string|null} neue lokale URL oder null
|
||||
*/
|
||||
function copyThumbnail(sourceJobId, targetJobId) {
|
||||
try {
|
||||
const src = persistentFilePath(sourceJobId);
|
||||
if (!fs.existsSync(src)) return null;
|
||||
ensureDirs();
|
||||
const dest = persistentFilePath(targetJobId);
|
||||
fs.copyFileSync(src, dest);
|
||||
logger.info('thumbnail:copied', { sourceJobId, targetJobId });
|
||||
return localUrl(targetJobId);
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:copy:failed', { sourceJobId, targetJobId, error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert ein lokal extrahiertes Bild als persistentes Job-Thumbnail.
|
||||
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
|
||||
*/
|
||||
function storeLocalThumbnail(jobId, sourcePath) {
|
||||
try {
|
||||
const src = String(sourcePath || '').trim();
|
||||
if (!src || !fs.existsSync(src)) {
|
||||
return null;
|
||||
}
|
||||
ensureDirs();
|
||||
const dest = persistentFilePath(jobId);
|
||||
fs.copyFileSync(src, dest);
|
||||
logger.info('thumbnail:stored-local', { jobId, sourcePath: src, dest });
|
||||
return localUrl(jobId);
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:store-local:failed', { jobId, sourcePath, error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht Cache- und persistente Thumbnail-Datei eines Jobs.
|
||||
* Wird beim Löschen eines Jobs aufgerufen.
|
||||
*/
|
||||
function deleteThumbnail(jobId) {
|
||||
for (const filePath of [persistentFilePath(jobId), cacheFilePath(jobId)]) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:delete:failed', { jobId, filePath, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migriert bestehende Jobs: lädt alle externen poster_url-Bilder herunter
|
||||
* und speichert sie lokal. Läuft beim Start im Hintergrund, sequenziell
|
||||
* mit kurzem Delay um externe Server nicht zu überlasten.
|
||||
*/
|
||||
async function migrateExistingThumbnails() {
|
||||
try {
|
||||
ensureDirs();
|
||||
const db = await getDb();
|
||||
|
||||
// Alle abgeschlossenen Jobs mit externer poster_url, die noch kein lokales Bild haben
|
||||
const jobs = await db.all(
|
||||
`SELECT id, poster_url FROM jobs
|
||||
WHERE rip_successful = 1
|
||||
AND poster_url IS NOT NULL
|
||||
AND poster_url != ''
|
||||
AND poster_url NOT LIKE '/api/thumbnails/%'
|
||||
ORDER BY id ASC`
|
||||
);
|
||||
|
||||
if (!jobs.length) {
|
||||
logger.info('thumbnail:migrate:nothing-to-do');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('thumbnail:migrate:start', { count: jobs.length });
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const job of jobs) {
|
||||
// Persistente Datei bereits vorhanden? Dann nur DB aktualisieren.
|
||||
const dest = persistentFilePath(job.id);
|
||||
if (fs.existsSync(dest)) {
|
||||
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
|
||||
succeeded++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadImage(job.poster_url, dest);
|
||||
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
|
||||
logger.info('thumbnail:migrate:ok', { jobId: job.id });
|
||||
succeeded++;
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:migrate:failed', { jobId: job.id, url: job.poster_url, error: err.message });
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Kurze Pause zwischen Downloads (externe Server schonen)
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
logger.info('thumbnail:migrate:done', { succeeded, failed, total: jobs.length });
|
||||
} catch (err) {
|
||||
logger.error('thumbnail:migrate:error', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cacheJobThumbnail,
|
||||
promoteJobThumbnail,
|
||||
copyThumbnail,
|
||||
storeLocalThumbnail,
|
||||
deleteThumbnail,
|
||||
getThumbnailsDir,
|
||||
migrateExistingThumbnails,
|
||||
isLocalUrl
|
||||
};
|
||||
@@ -63,7 +63,38 @@ function parseHandBrakeProgress(line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCdParanoiaProgress(line) {
|
||||
// cdparanoia writes progress to stderr with \r overwrites.
|
||||
// Formats seen in the wild:
|
||||
// "Ripping track 1 of 12 progress: ( 34.21%)"
|
||||
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])"
|
||||
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const progressMatch = normalized.match(/progress:\s*\(\s*(\d+(?:\.\d+)?)\s*%\s*\)/i);
|
||||
if (progressMatch) {
|
||||
const trackMatch = normalized.match(/track\s+(\d+)\s+of\s+(\d+)/i);
|
||||
const currentTrack = trackMatch ? Number(trackMatch[1]) : null;
|
||||
const totalTracks = trackMatch ? Number(trackMatch[2]) : null;
|
||||
return {
|
||||
percent: clampPercent(Number(progressMatch[1])),
|
||||
currentTrack,
|
||||
totalTracks,
|
||||
eta: null
|
||||
};
|
||||
}
|
||||
|
||||
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" style – no clear percent here
|
||||
// Fall back to generic percent match
|
||||
const percent = parseGenericPercent(normalized);
|
||||
if (percent !== null) {
|
||||
return { percent, currentTrack: null, totalTracks: null, eta: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMakeMkvProgress,
|
||||
parseHandBrakeProgress
|
||||
parseHandBrakeProgress,
|
||||
parseCdParanoiaProgress
|
||||
};
|
||||
|
||||
195
db/schema.sql
195
db/schema.sql
@@ -24,6 +24,7 @@ CREATE TABLE settings_values (
|
||||
|
||||
CREATE TABLE jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_job_id INTEGER,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
imdb_id TEXT,
|
||||
@@ -46,12 +47,31 @@ CREATE TABLE jobs (
|
||||
encode_plan_json TEXT,
|
||||
encode_input_path TEXT,
|
||||
encode_review_confirmed INTEGER DEFAULT 0,
|
||||
aax_checksum TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
|
||||
CREATE INDEX idx_jobs_parent_job_id ON jobs(parent_job_id);
|
||||
|
||||
CREATE TABLE job_lineage_artifacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
source_job_id INTEGER,
|
||||
media_type TEXT,
|
||||
raw_path TEXT,
|
||||
output_path TEXT,
|
||||
reason TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_job_lineage_artifacts_job_id ON job_lineage_artifacts(job_id);
|
||||
CREATE INDEX idx_job_lineage_artifacts_source_job_id ON job_lineage_artifacts(source_job_id);
|
||||
|
||||
CREATE TABLE scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -145,35 +165,33 @@ CREATE TABLE user_presets (
|
||||
|
||||
CREATE INDEX idx_user_presets_media_type ON user_presets(media_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS aax_activation_bytes (
|
||||
checksum TEXT PRIMARY KEY,
|
||||
activation_bytes TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- Default Settings Seed
|
||||
-- =============================================================================
|
||||
|
||||
-- Pfade – Eigentümer für alternative Verzeichnisse (inline in DynamicSettingsForm gerendert)
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015);
|
||||
VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025);
|
||||
VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_other_owner', 'Pfade', 'Eigentümer Raw-Ordner (Sonstiges)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1035);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_other_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_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115);
|
||||
VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_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_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125);
|
||||
VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_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_other_owner', 'Pfade', 'Eigentümer Film-Ordner (Sonstiges)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_other_owner', NULL);
|
||||
|
||||
-- Laufwerk
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10);
|
||||
@@ -187,43 +205,31 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('makemkv_source_index', 'Laufwerk', 'MakeMKV Source Index', 'number', 1, 'Disc Index im Auto-Modus.', '0', '[]', '{"min":0,"max":20}', 30);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_source_index', '0');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('disc_auto_detection_enabled', 'Laufwerk', 'Automatische Disk-Erkennung', 'boolean', 1, 'Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt.', 'true', '[]', '{}', 35);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_auto_detection_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('disc_poll_interval_ms', 'Laufwerk', 'Polling Intervall (ms)', 'number', 1, 'Intervall für Disk-Erkennung.', '4000', '[]', '{"min":1000,"max":60000}', 40);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_poll_interval_ms', '4000');
|
||||
|
||||
-- Pfade
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir', 'Pfade', 'Raw Ausgabeordner', 'path', 1, 'Zwischenablage für MakeMKV Rip.', 'data/output/raw', '[]', '{"minLength":1}', 100);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir', 'data/output/raw');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_bluray', 'Pfade', 'Raw Ausgabeordner (Blu-ray)', 'path', 0, 'Optionaler RAW-Zielpfad nur für Blu-ray. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 101);
|
||||
VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd', 'Pfade', 'Raw Ausgabeordner (DVD)', 'path', 0, 'Optionaler RAW-Zielpfad nur für DVD. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 102);
|
||||
VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_other', 'Pfade', 'Raw Ausgabeordner (Sonstiges)', 'path', 0, 'Optionaler RAW-Zielpfad nur für Sonstiges. Leer = Fallback auf "Raw Ausgabeordner".', NULL, '[]', '{}', 103);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_other', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir', 'Pfade', 'Film Ausgabeordner', 'path', 1, 'Finale HandBrake Ausgabe.', 'data/output/movies', '[]', '{"minLength":1}', 110);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir', 'data/output/movies');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_bluray', 'Pfade', 'Film Ausgabeordner (Blu-ray)', 'path', 0, 'Optionaler Encode-Zielpfad nur für Blu-ray. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 111);
|
||||
VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_dvd', 'Pfade', 'Film Ausgabeordner (DVD)', 'path', 0, 'Optionaler Encode-Zielpfad nur für DVD. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 112);
|
||||
VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_other', 'Pfade', 'Film Ausgabeordner (Sonstiges)', 'path', 0, 'Optionaler Encode-Zielpfad nur für Sonstiges. Leer = Fallback auf "Film Ausgabeordner".', NULL, '[]', '{}', 113);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_other', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs');
|
||||
@@ -263,17 +269,38 @@ VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart:
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Parallele Jobs', 'number', 1, 'Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue.', '1', '[]', '{"min":1,"max":12}', 225);
|
||||
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pipeline_max_parallel_cd_encodes', 'Tools', 'Max. parallele Audio CD Jobs', 'number', 1, 'Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit.', '2', '[]', '{"min":1,"max":12}', 226);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_cd_encodes', '2');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pipeline_max_total_encodes', 'Tools', 'Max. Encodes gesamt (medienunabhängig)', 'number', 1, 'Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits.', '3', '[]', '{"min":1,"max":24}', 227);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_total_encodes', '3');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pipeline_cd_bypasses_queue', 'Tools', 'Audio CDs: Queue-Reihenfolge überspringen', 'boolean', 1, 'Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin.', 'false', '[]', '{}', 228);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_cd_bypasses_queue', 'false');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('script_test_timeout_ms', 'Tools', 'Script-Test Timeout (ms)', 'number', 1, 'Timeout fuer Script-Tests in den Settings. 0 = kein Timeout.', '0', '[]', '{"min":0,"max":86400000}', 229);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('script_test_timeout_ms', '0');
|
||||
|
||||
-- Migration: Label für bestehende Installationen aktualisieren
|
||||
UPDATE settings_schema SET label = 'Max. parallele Film/Video Encodes', description = 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.' WHERE key = 'pipeline_max_parallel_jobs' AND label = 'Parallele Jobs';
|
||||
|
||||
-- Tools – Blu-ray
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('mediainfo_extra_args_bluray', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (Blu-ray).', NULL, '[]', '{}', 300);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args_bluray', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_rip_mode_bluray', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.', 'backup', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 305);
|
||||
VALUES ('makemkv_rip_mode_bluray', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).', 'backup', '[{"label":"Backup","value":"backup"},{"label":"MKV","value":"mkv"}]', '{}', 305);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_bluray', 'backup');
|
||||
UPDATE settings_schema SET default_value = 'backup', description = 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).' WHERE key = 'makemkv_rip_mode_bluray';
|
||||
UPDATE settings_values SET value = 'backup' WHERE key = 'makemkv_rip_mode_bluray';
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_analyze_extra_args_bluray', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (Blu-ray).', NULL, '[]', '{}', 310);
|
||||
@@ -296,12 +323,8 @@ VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Datei
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('filename_template_bluray', 'Tools', 'Dateiname Template', 'string', 1, 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (Blu-ray).', '${title} (${year})', '[]', '{"minLength":1}', 335);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('filename_template_bluray', '${title} (${year})');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_folder_template_bluray', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (Blu-ray).', NULL, '[]', '{}', 340);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_bluray', NULL);
|
||||
VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})');
|
||||
|
||||
-- Tools – DVD
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -309,8 +332,10 @@ VALUES ('mediainfo_extra_args_dvd', 'Tools', 'Mediainfo Extra Args', 'string', 0
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_rip_mode_dvd', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner.', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 505);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'mkv');
|
||||
VALUES ('makemkv_rip_mode_dvd', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'backup: vollständige Disc-Struktur im RAW-Ordner (einzig gültige Option für DVDs).', 'backup', '[{"label":"Backup","value":"backup"}]', '{}', 505);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'backup');
|
||||
UPDATE settings_schema SET default_value = 'backup', description = 'backup: vollständige Disc-Struktur im RAW-Ordner (einzig gültige Option für DVDs).', options_json = '[{"label":"Backup","value":"backup"}]' WHERE key = 'makemkv_rip_mode_dvd';
|
||||
UPDATE settings_values SET value = 'backup' WHERE key = 'makemkv_rip_mode_dvd';
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_analyze_extra_args_dvd', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (DVD).', NULL, '[]', '{}', 510);
|
||||
@@ -333,12 +358,80 @@ VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiend
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('filename_template_dvd', 'Tools', 'Dateiname Template', 'string', 1, 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (DVD).', '${title} (${year})', '[]', '{"minLength":1}', 535);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('filename_template_dvd', '${title} (${year})');
|
||||
VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})');
|
||||
|
||||
-- Tools – CD
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
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_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_folder_template_dvd', 'Tools', 'Ordnername Template', 'string', 0, 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).', NULL, '[]', '{}', 540);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_folder_template_dvd', NULL);
|
||||
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)
|
||||
VALUES (
|
||||
'cd_output_template',
|
||||
'Pfade',
|
||||
'CD Output Template',
|
||||
'string',
|
||||
1,
|
||||
'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.',
|
||||
'{artist} - {album} ({year})/{trackNr} {artist} - {title}',
|
||||
'[]',
|
||||
'{"minLength":1}',
|
||||
235
|
||||
);
|
||||
INSERT OR IGNORE INTO settings_values (key, value)
|
||||
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_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
|
||||
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);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD RAW-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_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_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
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);
|
||||
|
||||
-- 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
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -349,7 +442,15 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
|
||||
|
||||
-- Benachrichtigungen
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false');
|
||||
|
||||
@@ -180,6 +180,7 @@ Beispiel `/etc/nginx/sites-available/ripster`:
|
||||
server {
|
||||
listen 80;
|
||||
server_name ripster.local;
|
||||
client_max_body_size 8G;
|
||||
|
||||
root /opt/ripster/frontend/dist;
|
||||
index index.html;
|
||||
@@ -212,6 +213,8 @@ sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Hinweis: Fuer groessere Uploads wie `.aax`-Audiobooks muss `client_max_body_size` ausreichend hoch gesetzt sein. Im mitgelieferten Beispiel sind `8G` hinterlegt.
|
||||
|
||||
---
|
||||
|
||||
## Datenbank-Backup
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.10.2-5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.10.2-5",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.10.2-5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,28 +1,237 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from './api/client';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import DatabasePage from './pages/DatabasePage';
|
||||
import DownloadsPage from './pages/DownloadsPage';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(100, parsed));
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 'n/a';
|
||||
}
|
||||
if (parsed === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let unitIndex = 0;
|
||||
let current = parsed;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const digits = unitIndex <= 1 ? 0 : 2;
|
||||
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function createInitialAudiobookUploadState() {
|
||||
return {
|
||||
phase: 'idle',
|
||||
fileName: null,
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
progressPercent: 0,
|
||||
statusText: null,
|
||||
errorMessage: null,
|
||||
jobId: null,
|
||||
startedAt: null,
|
||||
finishedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
function getAudiobookUploadTagMeta(phase) {
|
||||
const normalized = String(phase || '').trim().toLowerCase();
|
||||
if (normalized === 'uploading') {
|
||||
return { label: 'Upload läuft', severity: 'warning' };
|
||||
}
|
||||
if (normalized === 'processing') {
|
||||
return { label: 'Server verarbeitet', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'completed') {
|
||||
return { label: 'Bereit', severity: 'success' };
|
||||
}
|
||||
if (normalized === 'error') {
|
||||
return { label: 'Fehler', severity: 'danger' };
|
||||
}
|
||||
return { label: 'Inaktiv', severity: 'secondary' };
|
||||
}
|
||||
|
||||
function getDownloadIndicatorMeta(summary) {
|
||||
const activeCount = Number(summary?.activeCount || 0);
|
||||
const failedCount = Number(summary?.failedCount || 0);
|
||||
const totalCount = Number(summary?.totalCount || 0);
|
||||
|
||||
if (activeCount > 0) {
|
||||
return {
|
||||
icon: 'pi pi-spinner pi-spin',
|
||||
label: activeCount === 1 ? '1 ZIP aktiv' : `${activeCount} ZIPs aktiv`,
|
||||
className: 'zip-status-indicator-active'
|
||||
};
|
||||
}
|
||||
if (totalCount > 0) {
|
||||
return {
|
||||
icon: 'pi pi-check',
|
||||
label: failedCount > 0 ? 'ZIP-Jobs beendet' : 'ZIPs fertig',
|
||||
className: 'zip-status-indicator-ready'
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: 'pi pi-download',
|
||||
label: 'ZIPs',
|
||||
className: 'zip-status-indicator-idle'
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const appVersion = __APP_VERSION__;
|
||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
|
||||
const [downloadSummary, setDownloadSummary] = useState(null);
|
||||
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const globalToastRef = useRef(null);
|
||||
|
||||
const refreshPipeline = async () => {
|
||||
const response = await api.getPipelineState();
|
||||
setPipeline(response.pipeline);
|
||||
setHardwareMonitoring(response?.hardwareMonitoring || null);
|
||||
return response;
|
||||
};
|
||||
|
||||
const clearAudiobookUpload = () => {
|
||||
setAudiobookUpload(createInitialAudiobookUploadState());
|
||||
};
|
||||
|
||||
const handleAudiobookUpload = async (file, payload = {}) => {
|
||||
if (!file) {
|
||||
throw new Error('Bitte zuerst eine AAX-Datei auswählen.');
|
||||
}
|
||||
|
||||
const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0
|
||||
? Number(file.size)
|
||||
: 0;
|
||||
|
||||
setAudiobookUpload({
|
||||
phase: 'uploading',
|
||||
fileName: String(file.name || '').trim() || 'upload.aax',
|
||||
loadedBytes: 0,
|
||||
totalBytes: fallbackTotalBytes,
|
||||
progressPercent: 0,
|
||||
statusText: 'AAX-Datei wird hochgeladen ...',
|
||||
errorMessage: null,
|
||||
jobId: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await api.uploadAudiobook(file, payload, {
|
||||
onProgress: ({ loaded, total, percent }) => {
|
||||
const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0
|
||||
? Number(loaded)
|
||||
: 0;
|
||||
const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0
|
||||
? Number(total)
|
||||
: fallbackTotalBytes;
|
||||
const nextPercent = Number.isFinite(Number(percent))
|
||||
? clampPercent(Number(percent))
|
||||
: (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0);
|
||||
const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal;
|
||||
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: transferComplete ? 'processing' : 'uploading',
|
||||
loadedBytes: nextLoaded,
|
||||
totalBytes: nextTotal,
|
||||
progressPercent: nextPercent,
|
||||
statusText: transferComplete
|
||||
? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...'
|
||||
: 'AAX-Datei wird hochgeladen ...'
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||
await refreshPipeline().catch(() => null);
|
||||
setDashboardJobsRefreshToken((prev) => prev + 1);
|
||||
setHistoryJobsRefreshToken((prev) => prev + 1);
|
||||
if (uploadedJobId) {
|
||||
setPendingDashboardJobId(uploadedJobId);
|
||||
}
|
||||
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: 'completed',
|
||||
loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes,
|
||||
totalBytes: prev.totalBytes || fallbackTotalBytes,
|
||||
progressPercent: 100,
|
||||
statusText: uploadedJobId
|
||||
? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.`
|
||||
: 'Upload abgeschlossen.',
|
||||
errorMessage: null,
|
||||
jobId: uploadedJobId,
|
||||
finishedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
setAudiobookUpload((prev) => ({
|
||||
...prev,
|
||||
phase: 'error',
|
||||
errorMessage: error?.message || 'Upload fehlgeschlagen.',
|
||||
statusText: error?.message || 'Upload fehlgeschlagen.',
|
||||
finishedAt: new Date().toISOString()
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDashboardJobFocusConsumed = (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setPendingDashboardJobId((prev) => (
|
||||
normalizeJobId(prev) === normalizedJobId ? null : prev
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshPipeline().catch(() => null);
|
||||
api.getDownloadsSummary()
|
||||
.then((response) => {
|
||||
setDownloadSummary(response?.summary || null);
|
||||
})
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
@@ -34,26 +243,46 @@ function App() {
|
||||
if (message.type === 'PIPELINE_PROGRESS') {
|
||||
const payload = message.payload;
|
||||
const progressJobId = payload?.activeJobId;
|
||||
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
|
||||
? payload.contextPatch
|
||||
: null;
|
||||
setPipeline((prev) => {
|
||||
const next = { ...prev };
|
||||
// Update per-job progress map so concurrent jobs don't overwrite each other.
|
||||
if (progressJobId != null) {
|
||||
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
||||
const mergedJobContext = contextPatch
|
||||
? {
|
||||
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||
? previousJobProgress.context
|
||||
: {}),
|
||||
...contextPatch
|
||||
}
|
||||
: (previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||
? previousJobProgress.context
|
||||
: undefined);
|
||||
next.jobProgress = {
|
||||
...(prev?.jobProgress || {}),
|
||||
[progressJobId]: {
|
||||
...previousJobProgress,
|
||||
state: payload.state,
|
||||
progress: payload.progress,
|
||||
eta: payload.eta,
|
||||
statusText: payload.statusText
|
||||
statusText: payload.statusText,
|
||||
...(mergedJobContext !== undefined ? { context: mergedJobContext } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
// Update global snapshot fields only for the primary active job.
|
||||
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
||||
next.state = payload.state ?? prev?.state;
|
||||
next.progress = payload.progress ?? prev?.progress;
|
||||
next.eta = payload.eta ?? prev?.eta;
|
||||
next.statusText = payload.statusText ?? prev?.statusText;
|
||||
if (contextPatch) {
|
||||
next.context = {
|
||||
...(prev?.context && typeof prev.context === 'object' ? prev.context : {}),
|
||||
...contextPatch
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -77,23 +306,77 @@ function App() {
|
||||
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
|
||||
setHardwareMonitoring(message.payload || null);
|
||||
}
|
||||
|
||||
if (message.type === 'DOWNLOADS_UPDATED') {
|
||||
const summary = message.payload?.summary && typeof message.payload.summary === 'object'
|
||||
? message.payload.summary
|
||||
: null;
|
||||
const reason = String(message.payload?.reason || '').trim().toLowerCase();
|
||||
const item = message.payload?.item && typeof message.payload.item === 'object'
|
||||
? message.payload.item
|
||||
: null;
|
||||
|
||||
if (summary) {
|
||||
setDownloadSummary(summary);
|
||||
}
|
||||
setDownloadsRefreshToken((prev) => prev + 1);
|
||||
|
||||
if (reason === 'ready' && item) {
|
||||
globalToastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'ZIP fertig',
|
||||
detail: `${item.archiveName || 'ZIP-Datei'} steht jetzt auf der Downloads-Seite bereit.`,
|
||||
life: 4500
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'failed' && item) {
|
||||
globalToastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'ZIP fehlgeschlagen',
|
||||
detail: item.errorMessage || `${item.archiveName || 'ZIP-Datei'} konnte nicht erstellt werden.`,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
{ label: 'Historie', path: '/history' }
|
||||
{ label: 'Historie', path: '/history' },
|
||||
{ label: 'Downloads', path: '/downloads' }
|
||||
];
|
||||
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
||||
const uploadProgress = clampPercent(audiobookUpload?.progressPercent);
|
||||
const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase);
|
||||
const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
|
||||
const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
|
||||
const uploadBytesLabel = uploadTotalBytes > 0
|
||||
? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}`
|
||||
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
|
||||
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
|
||||
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
|
||||
const isDashboardRoute = location.pathname === '/';
|
||||
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Toast ref={globalToastRef} position="top-right" />
|
||||
|
||||
<header className="app-header">
|
||||
<div className="brand-block">
|
||||
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||
<div className="brand-copy">
|
||||
<h1>Ripster</h1>
|
||||
<p>Disc Ripping Control Center</p>
|
||||
<div className="brand-meta">
|
||||
<p>Disc Ripping Control Center</p>
|
||||
<span className="app-version" aria-label={`Version ${appVersion}`}>
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nav-buttons">
|
||||
@@ -106,9 +389,73 @@ function App() {
|
||||
outlined={location.pathname !== item.path}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`zip-status-indicator ${downloadIndicator.className}`}
|
||||
onClick={() => navigate('/downloads')}
|
||||
title="Downloads-Seite oeffnen"
|
||||
>
|
||||
<i className={downloadIndicator.icon} aria-hidden="true" />
|
||||
<span>{downloadIndicator.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showAudiobookUploadBanner ? (
|
||||
<section className={`app-upload-banner phase-${uploadPhase}`}>
|
||||
<div className="app-upload-banner-copy">
|
||||
<div className="app-upload-banner-head">
|
||||
<strong>Audiobook Upload</strong>
|
||||
<Tag value={uploadTagMeta.label} severity={uploadTagMeta.severity} />
|
||||
</div>
|
||||
<small>{audiobookUpload?.statusText || 'Upload aktiv.'}</small>
|
||||
{audiobookUpload?.fileName ? <small>Datei: {audiobookUpload.fileName}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="app-upload-banner-progress"
|
||||
aria-label={`Audiobook Upload ${Math.round(uploadProgress)} Prozent`}
|
||||
>
|
||||
<ProgressBar value={uploadProgress} showValue={false} />
|
||||
<small>
|
||||
{uploadPhase === 'processing'
|
||||
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
|
||||
: uploadBytesLabel
|
||||
? `${Math.round(uploadProgress)}% | ${uploadBytesLabel}`
|
||||
: `${Math.round(uploadProgress)}%`}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="app-upload-banner-actions">
|
||||
{hasUploadedJob && !isDashboardRoute ? (
|
||||
<Button
|
||||
label="Zum Dashboard"
|
||||
icon="pi pi-arrow-right"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => {
|
||||
const targetJobId = normalizeJobId(audiobookUpload?.jobId);
|
||||
if (targetJobId) {
|
||||
setPendingDashboardJobId(targetJobId);
|
||||
}
|
||||
navigate('/');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{canDismissUploadBanner ? (
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
aria-label="Upload-Hinweis schliessen"
|
||||
onClick={clearAudiobookUpload}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -119,11 +466,17 @@ function App() {
|
||||
hardwareMonitoring={hardwareMonitoring}
|
||||
lastDiscEvent={lastDiscEvent}
|
||||
refreshPipeline={refreshPipeline}
|
||||
audiobookUpload={audiobookUpload}
|
||||
onAudiobookUpload={handleAudiobookUpload}
|
||||
jobsRefreshToken={dashboardJobsRefreshToken}
|
||||
pendingExpandedJobId={pendingDashboardJobId}
|
||||
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||
<Route path="/downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||
<Route path="/database" element={<DatabasePage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -58,12 +58,12 @@ async function requestCachedGet(path, options = {}) {
|
||||
|
||||
if (!forceRefresh && current && current.value !== undefined) {
|
||||
if (current.expiresAt > now) {
|
||||
return current.value;
|
||||
return Promise.resolve(current.value);
|
||||
}
|
||||
if (!current.promise) {
|
||||
void refreshCachedGet(path, ttlMs);
|
||||
}
|
||||
return current.value;
|
||||
return Promise.resolve(current.value);
|
||||
}
|
||||
|
||||
if (!forceRefresh && current?.promise) {
|
||||
@@ -78,11 +78,13 @@ function afterMutationInvalidate(prefixes = []) {
|
||||
}
|
||||
|
||||
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}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
headers: mergedHeaders,
|
||||
...options
|
||||
});
|
||||
|
||||
@@ -109,6 +111,186 @@ async function request(path, options = {}) {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function resolveFilenameFromDisposition(contentDisposition, fallback = 'download.zip') {
|
||||
const raw = String(contentDisposition || '').trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const encodedMatch = raw.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1]);
|
||||
} catch (_error) {
|
||||
// ignore malformed content-disposition values
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = raw.match(/filename\s*=\s*"([^"]+)"/i) || raw.match(/filename\s*=\s*([^;]+)/i);
|
||||
if (plainMatch?.[1]) {
|
||||
return String(plainMatch[1]).trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function download(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: options?.headers || {},
|
||||
method: options?.method || 'GET'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorPayload = null;
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
errorPayload = await response.json();
|
||||
message = errorPayload?.error?.message || message;
|
||||
} catch (_error) {
|
||||
// ignore parse errors
|
||||
}
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.details = errorPayload?.error?.details || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const fallbackFilename = String(options?.filename || 'download.zip').trim() || 'download.zip';
|
||||
const filename = resolveFilenameFromDisposition(response.headers.get('content-disposition'), fallbackFilename);
|
||||
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
|
||||
return {
|
||||
filename,
|
||||
sizeBytes: blob.size
|
||||
};
|
||||
}
|
||||
|
||||
async function requestWithXhr(path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const method = String(options?.method || 'GET').trim().toUpperCase() || 'GET';
|
||||
const url = `${API_BASE}${path}`;
|
||||
const headers = options?.headers && typeof options.headers === 'object' ? options.headers : {};
|
||||
const signal = options?.signal;
|
||||
const onUploadProgress = typeof options?.onUploadProgress === 'function'
|
||||
? options.onUploadProgress
|
||||
: null;
|
||||
|
||||
let finished = false;
|
||||
let abortListener = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (signal && abortListener) {
|
||||
signal.removeEventListener('abort', abortListener);
|
||||
}
|
||||
};
|
||||
|
||||
const settle = (callback) => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
cleanup();
|
||||
callback();
|
||||
};
|
||||
|
||||
xhr.open(method, url, true);
|
||||
xhr.responseType = 'text';
|
||||
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
xhr.setRequestHeader(key, String(value));
|
||||
});
|
||||
|
||||
if (onUploadProgress && xhr.upload) {
|
||||
xhr.upload.onprogress = (event) => {
|
||||
const loaded = Number(event?.loaded || 0);
|
||||
const total = Number(event?.total || 0);
|
||||
const hasKnownTotal = Boolean(event?.lengthComputable && total > 0);
|
||||
onUploadProgress({
|
||||
loaded,
|
||||
total: hasKnownTotal ? total : null,
|
||||
percent: hasKnownTotal ? (loaded / total) * 100 : null
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
settle(() => {
|
||||
reject(new Error('Netzwerkfehler'));
|
||||
});
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
settle(() => {
|
||||
const error = new Error('Request abgebrochen.');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
settle(() => {
|
||||
const contentType = xhr.getResponseHeader('content-type') || '';
|
||||
const rawText = xhr.responseText || '';
|
||||
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
let errorPayload = null;
|
||||
let message = `HTTP ${xhr.status}`;
|
||||
try {
|
||||
errorPayload = rawText ? JSON.parse(rawText) : null;
|
||||
message = errorPayload?.error?.message || message;
|
||||
} catch (_error) {
|
||||
// ignore parse errors
|
||||
}
|
||||
const error = new Error(message);
|
||||
error.status = xhr.status;
|
||||
error.details = errorPayload?.error?.details || null;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
resolve(rawText ? JSON.parse(rawText) : {});
|
||||
} catch (_error) {
|
||||
reject(new Error('Ungültige JSON-Antwort vom Server.'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(rawText);
|
||||
});
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
xhr.abort();
|
||||
return;
|
||||
}
|
||||
abortListener = () => {
|
||||
if (!finished) {
|
||||
xhr.abort();
|
||||
}
|
||||
};
|
||||
signal.addEventListener('abort', abortListener, { once: true });
|
||||
}
|
||||
|
||||
xhr.send(options?.body ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getSettings(options = {}) {
|
||||
return requestCachedGet('/settings', {
|
||||
@@ -116,6 +298,29 @@ export const api = {
|
||||
forceRefresh: options.forceRefresh
|
||||
});
|
||||
},
|
||||
getEffectivePaths(options = {}) {
|
||||
return requestCachedGet('/settings/effective-paths', {
|
||||
ttlMs: 30 * 1000,
|
||||
forceRefresh: options.forceRefresh
|
||||
});
|
||||
},
|
||||
getActivationBytes(options = {}) {
|
||||
return requestCachedGet('/settings/activation-bytes', {
|
||||
ttlMs: 0,
|
||||
forceRefresh: options.forceRefresh ?? true
|
||||
});
|
||||
},
|
||||
async saveActivationBytes(checksum, activationBytes) {
|
||||
const result = await request('/settings/activation-bytes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ checksum, activationBytes })
|
||||
});
|
||||
afterMutationInvalidate(['/settings/activation-bytes']);
|
||||
return result;
|
||||
},
|
||||
getPendingActivation() {
|
||||
return request('/pipeline/audiobook/pending-activation');
|
||||
},
|
||||
getHandBrakePresets(options = {}) {
|
||||
return requestCachedGet('/settings/handbrake-presets', {
|
||||
ttlMs: 10 * 60 * 1000,
|
||||
@@ -273,6 +478,56 @@ export const api = {
|
||||
searchOmdb(q) {
|
||||
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
||||
},
|
||||
searchMusicBrainz(q) {
|
||||
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
|
||||
},
|
||||
getMusicBrainzRelease(mbId) {
|
||||
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
|
||||
},
|
||||
async selectCdMetadata(payload) {
|
||||
const result = await request('/pipeline/cd/select-metadata', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
async startCdRip(jobId, ripConfig) {
|
||||
const result = await request(`/pipeline/cd/start/${jobId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ripConfig || {})
|
||||
});
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
async uploadAudiobook(file, payload = {}, options = {}) {
|
||||
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 requestWithXhr('/pipeline/audiobook/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
});
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
async startAudiobook(jobId, payload = {}) {
|
||||
const result = await request(`/pipeline/audiobook/start/${jobId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
async selectMetadata(payload) {
|
||||
const result = await request('/pipeline/select-metadata', {
|
||||
method: 'POST',
|
||||
@@ -407,6 +662,14 @@ export const api = {
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
async assignJobCdMetadata(jobId, payload = {}) {
|
||||
const result = await request(`/history/${jobId}/cd/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
async deleteJobFiles(jobId, target = 'both') {
|
||||
const result = await request(`/history/${jobId}/delete-files`, {
|
||||
method: 'POST',
|
||||
@@ -415,14 +678,41 @@ export const api = {
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
async deleteJobEntry(jobId, target = 'none') {
|
||||
getJobDeletePreview(jobId, options = {}) {
|
||||
const includeRelated = options?.includeRelated !== false;
|
||||
const query = new URLSearchParams();
|
||||
query.set('includeRelated', includeRelated ? '1' : '0');
|
||||
return request(`/history/${jobId}/delete-preview?${query.toString()}`);
|
||||
},
|
||||
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
||||
const includeRelated = Boolean(options?.includeRelated);
|
||||
const result = await request(`/history/${jobId}/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target })
|
||||
body: JSON.stringify({ target, includeRelated })
|
||||
});
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
requestJobArchive(jobId, target = 'raw') {
|
||||
return request(`/downloads/history/${jobId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target })
|
||||
});
|
||||
},
|
||||
getDownloads() {
|
||||
return request('/downloads');
|
||||
},
|
||||
getDownloadsSummary() {
|
||||
return request('/downloads/summary');
|
||||
},
|
||||
downloadPreparedArchive(downloadId) {
|
||||
return download(`/downloads/${encodeURIComponent(downloadId)}/file`);
|
||||
},
|
||||
deleteDownload(downloadId) {
|
||||
return request(`/downloads/${encodeURIComponent(downloadId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
getJob(jobId, options = {}) {
|
||||
const query = new URLSearchParams();
|
||||
const includeLiveLog = Boolean(options.includeLiveLog);
|
||||
|
||||
362
frontend/src/components/AudiobookConfigPanel.jsx
Normal file
362
frontend/src/components/AudiobookConfigPanel.jsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Slider } from 'primereact/slider';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
|
||||
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeFormat(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
return AUDIOBOOK_FORMATS.some((entry) => entry.value === raw) ? raw : 'mp3';
|
||||
}
|
||||
|
||||
function isFieldVisible(field, values) {
|
||||
if (!field?.showWhen) {
|
||||
return true;
|
||||
}
|
||||
return values?.[field.showWhen.field] === field.showWhen.value;
|
||||
}
|
||||
|
||||
function buildFormatOptions(format, existingOptions = {}) {
|
||||
return {
|
||||
...getDefaultAudiobookFormatOptions(format),
|
||||
...(existingOptions && typeof existingOptions === 'object' ? existingOptions : {})
|
||||
};
|
||||
}
|
||||
|
||||
function formatChapterTime(secondsValue) {
|
||||
const totalSeconds = Number(secondsValue || 0);
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) {
|
||||
return '-';
|
||||
}
|
||||
const rounded = Math.max(0, Math.round(totalSeconds));
|
||||
const hours = Math.floor(rounded / 3600);
|
||||
const minutes = Math.floor((rounded % 3600) / 60);
|
||||
const seconds = rounded % 60;
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function truncateDescription(value, maxLength = 220) {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized || normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, maxLength).trim()}...`;
|
||||
}
|
||||
|
||||
function normalizeChapterTitle(value, index) {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
return normalized || `Kapitel ${index}`;
|
||||
}
|
||||
|
||||
function normalizeEditableChapters(chapters = []) {
|
||||
const source = Array.isArray(chapters) ? chapters : [];
|
||||
return source.map((chapter, index) => {
|
||||
const safeIndex = Number(chapter?.index);
|
||||
const resolvedIndex = Number.isFinite(safeIndex) && safeIndex > 0 ? Math.trunc(safeIndex) : index + 1;
|
||||
return {
|
||||
index: resolvedIndex,
|
||||
title: normalizeChapterTitle(chapter?.title, resolvedIndex),
|
||||
startSeconds: Number(chapter?.startSeconds || 0),
|
||||
endSeconds: Number(chapter?.endSeconds || 0),
|
||||
startMs: Number(chapter?.startMs || 0),
|
||||
endMs: Number(chapter?.endMs || 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function FormatField({ field, value, onChange, disabled }) {
|
||||
if (field.type === 'slider') {
|
||||
return (
|
||||
<div className="cd-format-field">
|
||||
<label>
|
||||
{field.label}: <strong>{value}</strong>
|
||||
</label>
|
||||
{field.description ? <small>{field.description}</small> : null}
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={(event) => onChange(field.key, event.value)}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step || 1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<div className="cd-format-field">
|
||||
<label>{field.label}</label>
|
||||
{field.description ? <small>{field.description}</small> : null}
|
||||
<Dropdown
|
||||
value={value}
|
||||
options={field.options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => onChange(field.key, event.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AudiobookConfigPanel({
|
||||
pipeline,
|
||||
onStart,
|
||||
onCancel,
|
||||
onRetry,
|
||||
busy
|
||||
}) {
|
||||
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase() || 'IDLE';
|
||||
const jobId = normalizeJobId(context?.jobId);
|
||||
const metadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
|
||||
? context.selectedMetadata
|
||||
: {};
|
||||
const audiobookConfig = context?.audiobookConfig && typeof context.audiobookConfig === 'object'
|
||||
? context.audiobookConfig
|
||||
: (context?.mediaInfoReview && typeof context.mediaInfoReview === 'object' ? context.mediaInfoReview : {});
|
||||
const initialFormat = normalizeFormat(audiobookConfig?.format);
|
||||
const chapters = Array.isArray(metadata?.chapters)
|
||||
? metadata.chapters
|
||||
: (Array.isArray(context?.chapters) ? context.chapters : []);
|
||||
const [format, setFormat] = useState(initialFormat);
|
||||
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
|
||||
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
|
||||
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const nextFormat = normalizeFormat(audiobookConfig?.format);
|
||||
setFormat(nextFormat);
|
||||
setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions));
|
||||
}, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditableChapters(normalizeEditableChapters(chapters));
|
||||
}, [jobId, JSON.stringify(chapters || [])]);
|
||||
|
||||
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
|
||||
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
|
||||
const isRunning = state === 'ENCODING';
|
||||
const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
|
||||
const outputPath = String(context?.outputPath || '').trim() || null;
|
||||
const statusLabel = getStatusLabel(state);
|
||||
const statusSeverity = getStatusSeverity(state);
|
||||
const description = String(metadata?.description || '').trim();
|
||||
const descriptionPreview = truncateDescription(description);
|
||||
const posterUrl = String(metadata?.poster || '').trim() || null;
|
||||
|
||||
const visibleFields = useMemo(
|
||||
() => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []),
|
||||
[schema, formatOptions]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="audiobook-config-panel">
|
||||
<div className="audiobook-config-head">
|
||||
<div className="audiobook-config-summary">
|
||||
{posterUrl ? (
|
||||
<div className="audiobook-config-cover">
|
||||
<img src={posterUrl} alt={metadata?.title || 'Audiobook Cover'} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="device-meta">
|
||||
<div><strong>Titel:</strong> {metadata?.title || '-'}</div>
|
||||
<div><strong>Autor:</strong> {metadata?.author || '-'}</div>
|
||||
<div><strong>Sprecher:</strong> {metadata?.narrator || '-'}</div>
|
||||
<div><strong>Serie:</strong> {metadata?.series || '-'}</div>
|
||||
<div><strong>Teil:</strong> {metadata?.part || '-'}</div>
|
||||
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
|
||||
<div><strong>Kapitel:</strong> {editableChapters.length || '-'}</div>
|
||||
{descriptionPreview ? (
|
||||
<div className="audiobook-description-preview">
|
||||
<strong>Beschreibung:</strong>
|
||||
<span>{descriptionPreview}</span>
|
||||
{description.length > descriptionPreview.length ? (
|
||||
<Button
|
||||
type="button"
|
||||
label="Vollständig anzeigen"
|
||||
icon="pi pi-external-link"
|
||||
text
|
||||
size="small"
|
||||
onClick={() => setDescriptionDialogVisible(true)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audiobook-config-tags">
|
||||
<Tag value={statusLabel} severity={statusSeverity} />
|
||||
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
|
||||
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
|
||||
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audiobook-config-grid">
|
||||
<div className="audiobook-config-settings">
|
||||
<div className="cd-format-field">
|
||||
<label>Ausgabeformat</label>
|
||||
<Dropdown
|
||||
value={format}
|
||||
options={AUDIOBOOK_FORMATS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => {
|
||||
const nextFormat = normalizeFormat(event.value);
|
||||
setFormat(nextFormat);
|
||||
setFormatOptions(buildFormatOptions(nextFormat, {}));
|
||||
}}
|
||||
disabled={busy || isRunning}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visibleFields.map((field) => (
|
||||
<FormatField
|
||||
key={`${format}-${field.key}`}
|
||||
field={field}
|
||||
value={formatOptions?.[field.key] ?? field.default ?? null}
|
||||
onChange={(key, nextValue) => {
|
||||
setFormatOptions((prev) => ({
|
||||
...prev,
|
||||
[key]: nextValue
|
||||
}));
|
||||
}}
|
||||
disabled={busy || isRunning}
|
||||
/>
|
||||
))}
|
||||
|
||||
<small>
|
||||
<code>m4b</code> erzeugt eine Datei mit bearbeitbaren Kapiteln. <code>mp3</code> und <code>flac</code> werden kapitelweise als einzelne Dateien erzeugt.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="audiobook-config-chapters">
|
||||
<h4>Kapitel</h4>
|
||||
{editableChapters.length === 0 ? (
|
||||
<small>Keine Kapitel in der Quelle erkannt.</small>
|
||||
) : (
|
||||
<div className="audiobook-chapter-list">
|
||||
{editableChapters.map((chapter, index) => (
|
||||
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
|
||||
<div className="audiobook-chapter-row-head">
|
||||
<strong>#{chapter.index || index + 1}</strong>
|
||||
<small>
|
||||
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
|
||||
</small>
|
||||
</div>
|
||||
<InputText
|
||||
value={chapter.title}
|
||||
onChange={(event) => {
|
||||
const nextTitle = event.target.value;
|
||||
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
|
||||
entryIndex === index
|
||||
? { ...entry, title: nextTitle }
|
||||
: entry
|
||||
)));
|
||||
}}
|
||||
disabled={busy || isRunning}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRunning ? (
|
||||
<div className="dashboard-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.round(progress)}%`}>
|
||||
<ProgressBar value={progress} showValue={false} />
|
||||
<small>{Math.round(progress)}%</small>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{outputPath ? (
|
||||
<div className="audiobook-output-path">
|
||||
<strong>Ausgabe:</strong> <code>{outputPath}</code>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="actions-row">
|
||||
{canStart ? (
|
||||
<Button
|
||||
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onStart?.({
|
||||
format,
|
||||
formatOptions,
|
||||
chapters: editableChapters.map((chapter, index) => ({
|
||||
index: chapter.index || index + 1,
|
||||
title: normalizeChapterTitle(chapter.title, chapter.index || index + 1),
|
||||
startSeconds: chapter.startSeconds,
|
||||
endSeconds: chapter.endSeconds,
|
||||
startMs: chapter.startMs,
|
||||
endMs: chapter.endMs
|
||||
}))
|
||||
})}
|
||||
loading={busy}
|
||||
disabled={!jobId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isRunning ? (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-stop"
|
||||
severity="danger"
|
||||
onClick={() => onCancel?.()}
|
||||
loading={busy}
|
||||
disabled={!jobId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(state === 'ERROR' || state === 'CANCELLED') ? (
|
||||
<Button
|
||||
label="Retry-Job anlegen"
|
||||
icon="pi pi-refresh"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => onRetry?.()}
|
||||
loading={busy}
|
||||
disabled={!jobId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
header="Beschreibung"
|
||||
visible={descriptionDialogVisible}
|
||||
style={{ width: 'min(48rem, 92vw)' }}
|
||||
onHide={() => setDescriptionDialogVisible(false)}
|
||||
>
|
||||
<div className="audiobook-description-dialog">
|
||||
<p>{description || 'Keine Beschreibung vorhanden.'}</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
313
frontend/src/components/CdMetadataDialog.jsx
Normal file
313
frontend/src/components/CdMetadataDialog.jsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
|
||||
function CoverThumb({ url, alt }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [url]);
|
||||
if (!url || failed) {
|
||||
return <div className="poster-thumb-lg poster-fallback">-</div>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className="poster-thumb-lg"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const COVER_PRELOAD_TIMEOUT_MS = 3000;
|
||||
|
||||
function preloadCoverImage(url) {
|
||||
const src = String(url || '').trim();
|
||||
if (!src) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
};
|
||||
const done = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const timer = window.setTimeout(done, COVER_PRELOAD_TIMEOUT_MS);
|
||||
image.onload = () => {
|
||||
window.clearTimeout(timer);
|
||||
done();
|
||||
};
|
||||
image.onerror = () => {
|
||||
window.clearTimeout(timer);
|
||||
done();
|
||||
};
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CdMetadataDialog({
|
||||
visible,
|
||||
context,
|
||||
onHide,
|
||||
onSubmit,
|
||||
onSearch,
|
||||
onFetchRelease,
|
||||
busy
|
||||
}) {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [searchBusy, setSearchBusy] = useState(false);
|
||||
const searchRunRef = useRef(0);
|
||||
|
||||
// Manual metadata inputs
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [manualArtist, setManualArtist] = useState('');
|
||||
const [manualYear, setManualYear] = useState(null);
|
||||
|
||||
// Track titles are pre-filled from MusicBrainz and edited in the next step.
|
||||
const [trackTitles, setTrackTitles] = useState({});
|
||||
|
||||
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
setSelected(null);
|
||||
setQuery('');
|
||||
setManualTitle('');
|
||||
setManualArtist('');
|
||||
setManualYear(null);
|
||||
setResults([]);
|
||||
setSearchBusy(false);
|
||||
|
||||
const titles = {};
|
||||
for (const t of tocTracks) {
|
||||
titles[t.position] = t.title || `Track ${t.position}`;
|
||||
}
|
||||
setTrackTitles(titles);
|
||||
}, [visible, context]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setManualTitle(selected.title || '');
|
||||
setManualArtist(selected.artist || '');
|
||||
setManualYear(selected.year || null);
|
||||
|
||||
// Pre-fill track titles from the MusicBrainz result
|
||||
if (Array.isArray(selected.tracks) && selected.tracks.length > 0) {
|
||||
const titles = {};
|
||||
for (const t of selected.tracks) {
|
||||
if (t.position <= tocTracks.length) {
|
||||
titles[t.position] = t.title || `Track ${t.position}`;
|
||||
}
|
||||
}
|
||||
// Fill any remaining tracks not in MB result
|
||||
for (const t of tocTracks) {
|
||||
if (!titles[t.position]) {
|
||||
titles[t.position] = t.title || `Track ${t.position}`;
|
||||
}
|
||||
}
|
||||
setTrackTitles(titles);
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) {
|
||||
return;
|
||||
}
|
||||
setSearchBusy(true);
|
||||
const searchRunId = searchRunRef.current + 1;
|
||||
searchRunRef.current = searchRunId;
|
||||
try {
|
||||
const searchResults = await onSearch(trimmedQuery);
|
||||
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
|
||||
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
|
||||
if (searchRunRef.current !== searchRunId) {
|
||||
return;
|
||||
}
|
||||
setResults(normalizedResults);
|
||||
setSelected(null);
|
||||
} finally {
|
||||
if (searchRunRef.current === searchRunId) {
|
||||
setSearchBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
let releaseDetails = selected;
|
||||
if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') {
|
||||
const fetched = await onFetchRelease(selected.mbId);
|
||||
if (fetched && typeof fetched === 'object') {
|
||||
releaseDetails = fetched;
|
||||
}
|
||||
}
|
||||
|
||||
const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : [];
|
||||
const releaseTracksByPosition = new Map();
|
||||
releaseTracks.forEach((track, index) => {
|
||||
const parsedPosition = Number(track?.position);
|
||||
const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0
|
||||
? Math.trunc(parsedPosition)
|
||||
: index + 1;
|
||||
if (!releaseTracksByPosition.has(normalizedPosition)) {
|
||||
releaseTracksByPosition.set(normalizedPosition, track);
|
||||
}
|
||||
});
|
||||
|
||||
const tracks = tocTracks.map((t, index) => {
|
||||
const position = Number(t.position);
|
||||
const byPosition = releaseTracksByPosition.get(position);
|
||||
const byIndex = releaseTracks[index];
|
||||
return {
|
||||
position,
|
||||
title: normalizeTrackText(
|
||||
byPosition?.title
|
||||
|| byIndex?.title
|
||||
|| trackTitles[t.position]
|
||||
) || `Track ${t.position}`,
|
||||
artist: normalizeTrackText(
|
||||
byPosition?.artist
|
||||
|| byIndex?.artist
|
||||
|| manualArtist.trim()
|
||||
|| releaseDetails?.artist
|
||||
) || null,
|
||||
selected: true
|
||||
};
|
||||
});
|
||||
|
||||
const payload = {
|
||||
jobId: context.jobId,
|
||||
title: manualTitle.trim() || context?.detectedTitle || 'Audio CD',
|
||||
artist: manualArtist.trim() || null,
|
||||
year: manualYear || null,
|
||||
mbId: releaseDetails?.mbId || selected?.mbId || null,
|
||||
coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null,
|
||||
tracks
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
const mbTitleBody = (row) => (
|
||||
<div className="mb-result-row">
|
||||
<CoverThumb url={row.coverArtUrl} alt={row.title} />
|
||||
<div>
|
||||
<div><strong>{row.title}</strong></div>
|
||||
<small>{row.artist}{row.year ? ` | ${row.year}` : ''}</small>
|
||||
{row.label ? <small> | {row.label}</small> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header="CD-Metadaten auswählen"
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
style={{ width: '58rem', maxWidth: '97vw' }}
|
||||
className="cd-metadata-dialog"
|
||||
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
|
||||
modal
|
||||
>
|
||||
{/* MusicBrainz search */}
|
||||
<div className="search-row">
|
||||
<InputText
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Album / Interpret suchen"
|
||||
/>
|
||||
<Button
|
||||
label="MusicBrainz Suche"
|
||||
icon="pi pi-search"
|
||||
onClick={handleSearch}
|
||||
loading={busy || searchBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{results.length > 0 ? (
|
||||
<div className="table-scroll-wrap table-scroll-medium">
|
||||
<DataTable
|
||||
value={results}
|
||||
selectionMode="single"
|
||||
selection={selected}
|
||||
onSelectionChange={(e) => setSelected(e.value)}
|
||||
dataKey="mbId"
|
||||
size="small"
|
||||
scrollable
|
||||
scrollHeight="16rem"
|
||||
emptyMessage="Keine Treffer"
|
||||
>
|
||||
<Column header="Album" body={mbTitleBody} />
|
||||
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
|
||||
<Column field="country" header="Land" style={{ width: '6rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Manual metadata */}
|
||||
<h4 style={{ marginTop: '1rem', marginBottom: '0.5rem' }}>Metadaten</h4>
|
||||
<div className="metadata-grid">
|
||||
<InputText
|
||||
value={manualTitle}
|
||||
onChange={(e) => setManualTitle(e.target.value)}
|
||||
placeholder="Album-Titel"
|
||||
/>
|
||||
<InputText
|
||||
value={manualArtist}
|
||||
onChange={(e) => setManualArtist(e.target.value)}
|
||||
placeholder="Interpret / Band"
|
||||
/>
|
||||
<InputNumber
|
||||
value={manualYear}
|
||||
onValueChange={(e) => setManualYear(e.value)}
|
||||
placeholder="Jahr"
|
||||
useGrouping={false}
|
||||
min={1900}
|
||||
max={2100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track selection/editing moved to CD-Rip configuration panel */}
|
||||
{tocTracks.length > 0 ? (
|
||||
<small style={{ display: 'block', marginTop: '0.9rem' }}>
|
||||
{tocTracks.length} Tracks erkannt. Auswahl/Feinschliff (Checkboxen, Interpret, Titel, Länge) erfolgt im nächsten Schritt in der Job-Übersicht.
|
||||
</small>
|
||||
) : null}
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: '1rem' }}>
|
||||
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
|
||||
<Button
|
||||
label="Weiter"
|
||||
icon="pi pi-arrow-right"
|
||||
onClick={handleSubmit}
|
||||
loading={busy}
|
||||
disabled={!manualTitle.trim() && !context?.detectedTitle}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1279
frontend/src/components/CdRipConfigPanel.jsx
Normal file
1279
frontend/src/components/CdRipConfigPanel.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
@@ -20,7 +20,10 @@ const GENERAL_TOOL_KEYS = new Set([
|
||||
'makemkv_min_length_minutes',
|
||||
'mediainfo_command',
|
||||
'handbrake_command',
|
||||
'handbrake_restart_delete_incomplete_output'
|
||||
'ffmpeg_command',
|
||||
'ffprobe_command',
|
||||
'handbrake_restart_delete_incomplete_output',
|
||||
'script_test_timeout_ms'
|
||||
]);
|
||||
|
||||
const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
|
||||
@@ -29,6 +32,78 @@ const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
|
||||
'handbrake_preset_dvd'
|
||||
]);
|
||||
|
||||
const NOTIFICATION_EVENT_TOGGLE_KEYS = new Set([
|
||||
'pushover_notify_metadata_ready',
|
||||
'pushover_notify_rip_started',
|
||||
'pushover_notify_encoding_started',
|
||||
'pushover_notify_job_finished',
|
||||
'pushover_notify_job_error',
|
||||
'pushover_notify_job_cancelled',
|
||||
'pushover_notify_reencode_started',
|
||||
'pushover_notify_reencode_finished'
|
||||
]);
|
||||
|
||||
const PUSHOVER_ENABLED_SETTING_KEY = 'pushover_enabled';
|
||||
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||
const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
|
||||
'drive_device',
|
||||
'makemkv_rip_mode',
|
||||
'makemkv_rip_mode_bluray',
|
||||
'makemkv_rip_mode_dvd',
|
||||
'makemkv_backup_mode'
|
||||
]);
|
||||
const EXPERT_ONLY_SETTING_KEYS = new Set([
|
||||
'pushover_device',
|
||||
'pushover_priority',
|
||||
'pushover_timeout_ms',
|
||||
'makemkv_source_index',
|
||||
'disc_poll_interval_ms',
|
||||
'hardware_monitoring_interval_ms',
|
||||
'makemkv_command',
|
||||
'mediainfo_command',
|
||||
'handbrake_command',
|
||||
'mediainfo_extra_args_bluray',
|
||||
'mediainfo_extra_args_dvd',
|
||||
'makemkv_analyze_extra_args_bluray',
|
||||
'makemkv_analyze_extra_args_dvd',
|
||||
'makemkv_rip_extra_args_bluray',
|
||||
'makemkv_rip_extra_args_dvd',
|
||||
'cdparanoia_command'
|
||||
]);
|
||||
|
||||
function toBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function shouldHideSettingByExpertMode(settingKey, expertModeEnabled) {
|
||||
const key = normalizeSettingKey(settingKey);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
if (ALWAYS_HIDDEN_SETTING_KEYS.has(key)) {
|
||||
return true;
|
||||
}
|
||||
if (key === EXPERT_MODE_SETTING_KEY) {
|
||||
return true;
|
||||
}
|
||||
return !expertModeEnabled && EXPERT_ONLY_SETTING_KEYS.has(key);
|
||||
}
|
||||
|
||||
function filterSettingsByVisibility(settings, expertModeEnabled) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled));
|
||||
}
|
||||
|
||||
function buildToolSections(settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const generalBucket = {
|
||||
@@ -49,6 +124,12 @@ function buildToolSections(settings) {
|
||||
description: 'Profil-spezifische Settings für DVD.',
|
||||
settings: []
|
||||
};
|
||||
const audiobookBucket = {
|
||||
id: 'audiobook',
|
||||
title: 'Audiobook',
|
||||
description: 'Profil-spezifische Settings für Audiobooks.',
|
||||
settings: []
|
||||
};
|
||||
const fallbackBucket = {
|
||||
id: 'other',
|
||||
title: 'Weitere Tool-Settings',
|
||||
@@ -70,13 +151,18 @@ function buildToolSections(settings) {
|
||||
dvdBucket.settings.push(setting);
|
||||
continue;
|
||||
}
|
||||
if (key.endsWith('_audiobook')) {
|
||||
audiobookBucket.settings.push(setting);
|
||||
continue;
|
||||
}
|
||||
fallbackBucket.settings.push(setting);
|
||||
}
|
||||
|
||||
const sections = [
|
||||
generalBucket,
|
||||
blurayBucket,
|
||||
dvdBucket
|
||||
dvdBucket,
|
||||
audiobookBucket
|
||||
].filter((item) => item.settings.length > 0);
|
||||
if (fallbackBucket.settings.length > 0) {
|
||||
sections.push(fallbackBucket);
|
||||
@@ -84,6 +170,14 @@ function buildToolSections(settings) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Path keys per medium — _owner keys are rendered inline
|
||||
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 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', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
|
||||
const DOWNLOAD_PATH_KEYS = ['download_dir'];
|
||||
const LOG_PATH_KEYS = ['log_dir'];
|
||||
|
||||
function buildSectionsForCategory(categoryName, settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const normalizedCategory = normalizeText(categoryName);
|
||||
@@ -108,187 +202,546 @@ function isHandBrakePresetSetting(setting) {
|
||||
return HANDBRAKE_PRESET_SETTING_KEYS.has(key);
|
||||
}
|
||||
|
||||
function isNotificationEventToggleSetting(setting) {
|
||||
return setting?.type === 'boolean' && NOTIFICATION_EVENT_TOGGLE_KEYS.has(normalizeSettingKey(setting?.key));
|
||||
}
|
||||
|
||||
function SettingField({
|
||||
setting,
|
||||
value,
|
||||
error,
|
||||
dirty,
|
||||
ownerSetting,
|
||||
ownerValue,
|
||||
ownerError,
|
||||
ownerDirty,
|
||||
onChange,
|
||||
variant = 'default'
|
||||
}) {
|
||||
const ownerKey = ownerSetting?.key;
|
||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||
const isNotificationToggleBox = variant === 'notification-toggle' && setting?.type === 'boolean';
|
||||
|
||||
return (
|
||||
<div className={`setting-row${isNotificationToggleBox ? ' notification-toggle-box' : ''}`}>
|
||||
{isNotificationToggleBox ? (
|
||||
<div className="notification-toggle-head">
|
||||
<label htmlFor={setting.key}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
</label>
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<label htmlFor={setting.key}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{setting.type === 'string' || setting.type === 'path' ? (
|
||||
<InputText
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange?.(setting.key, event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'number' ? (
|
||||
<InputNumber
|
||||
id={setting.key}
|
||||
value={value ?? 0}
|
||||
onValueChange={(event) => onChange?.(setting.key, event.value)}
|
||||
mode="decimal"
|
||||
useGrouping={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'boolean' && !isNotificationToggleBox ? (
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'select' ? (
|
||||
<Dropdown
|
||||
id={setting.key}
|
||||
value={value}
|
||||
options={setting.options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<small className="setting-description">{setting.description || ''}</small>
|
||||
{isHandBrakePresetSetting(setting) ? (
|
||||
<small>
|
||||
Preset-Erklärung:{' '}
|
||||
<a
|
||||
href="https://handbrake.fr/docs/en/latest/technical/official-presets.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
HandBrake Official Presets
|
||||
</a>
|
||||
</small>
|
||||
) : null}
|
||||
{error ? (
|
||||
<small className="error-text">{error}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={dirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={dirty ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ownerSetting ? (
|
||||
<div className="setting-owner-row">
|
||||
<label htmlFor={ownerKey} className="setting-owner-label">
|
||||
Eigentümer (user:gruppe)
|
||||
</label>
|
||||
<InputText
|
||||
id={ownerKey}
|
||||
value={ownerValue ?? ''}
|
||||
placeholder="z.B. michael:ripster"
|
||||
disabled={!pathHasValue}
|
||||
onChange={(event) => onChange?.(ownerKey, event.target.value)}
|
||||
/>
|
||||
{ownerError ? (
|
||||
<small className="error-text">{ownerError}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={ownerDirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={ownerDirty ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathMediumCard({ title, pathSettings, settingsByKey, values, errors, dirtyKeys, onChange }) {
|
||||
// Filter out _owner keys since they're rendered inline
|
||||
const visibleSettings = pathSettings.filter(
|
||||
(s) => !String(s?.key || '').endsWith('_owner')
|
||||
);
|
||||
|
||||
if (visibleSettings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="path-medium-card">
|
||||
<div className="path-medium-card-header">
|
||||
<h4>{title}</h4>
|
||||
</div>
|
||||
<div className="settings-grid">
|
||||
{visibleSettings.map((setting) => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
const ownerKey = `${setting.key}_owner`;
|
||||
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
||||
const ownerValue = values?.[ownerKey];
|
||||
const ownerError = errors?.[ownerKey] || null;
|
||||
const ownerDirty = Boolean(dirtyKeys?.has?.(ownerKey));
|
||||
|
||||
return (
|
||||
<SettingField
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
value={value}
|
||||
error={error}
|
||||
dirty={dirty}
|
||||
ownerSetting={ownerSetting}
|
||||
ownerValue={ownerValue}
|
||||
ownerError={ownerError}
|
||||
ownerDirty={ownerDirty}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effectivePaths }) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const settingsByKey = new Map(list.map((s) => [s.key, s]));
|
||||
|
||||
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 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 downloadSettings = list.filter((s) => DOWNLOAD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DOWNLOAD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
|
||||
|
||||
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
|
||||
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 defaultDownloads = effectivePaths?.defaults?.downloads || 'data/downloads';
|
||||
|
||||
const ep = effectivePaths || {};
|
||||
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
||||
const dvdRaw = ep.dvd?.raw || defaultRaw;
|
||||
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
||||
const cdRaw = ep.cd?.raw || defaultCd;
|
||||
const cdMovies = ep.cd?.movies || cdRaw;
|
||||
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
||||
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
|
||||
const downloadPath = ep.downloads?.path || defaultDownloads;
|
||||
|
||||
const isDefault = (path, def) => path === def;
|
||||
|
||||
return (
|
||||
<div className="path-category-tab">
|
||||
{/* Effektive Pfade Übersicht */}
|
||||
<div className="path-overview-card">
|
||||
<div className="path-overview-header">
|
||||
<h4>Effektive Pfade</h4>
|
||||
<small>Zeigt die tatsächlich verwendeten Pfade entsprechend der aktuellen Konfiguration.</small>
|
||||
</div>
|
||||
<table className="path-overview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Medium</th>
|
||||
<th>RAW-Ordner</th>
|
||||
<th>Film-Ordner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Blu-ray</strong></td>
|
||||
<td>
|
||||
<code>{blurayRaw}</code>
|
||||
{isDefault(blurayRaw, defaultRaw) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
<td>
|
||||
<code>{blurayMovies}</code>
|
||||
{isDefault(blurayMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>DVD</strong></td>
|
||||
<td>
|
||||
<code>{dvdRaw}</code>
|
||||
{isDefault(dvdRaw, defaultRaw) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
<td>
|
||||
<code>{dvdMovies}</code>
|
||||
{isDefault(dvdMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>CD / Audio</strong></td>
|
||||
<td>
|
||||
<code>{cdRaw}</code>
|
||||
{isDefault(cdRaw, defaultCd) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
<td>
|
||||
<code>{cdMovies}</code>
|
||||
{isDefault(cdMovies, cdRaw) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
<div className="path-overview-extra">
|
||||
<strong>ZIP-Downloads:</strong>
|
||||
<code>{downloadPath}</code>
|
||||
{isDefault(downloadPath, defaultDownloads) && <span className="path-default-badge">Standard</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Medium-Karten */}
|
||||
<div className="path-medium-cards">
|
||||
<PathMediumCard
|
||||
title="Blu-ray"
|
||||
pathSettings={bluraySettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<PathMediumCard
|
||||
title="DVD"
|
||||
pathSettings={dvdSettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<PathMediumCard
|
||||
title="CD / Audio"
|
||||
pathSettings={cdSettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<PathMediumCard
|
||||
title="Audiobook"
|
||||
pathSettings={audiobookSettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<PathMediumCard
|
||||
title="Downloads"
|
||||
pathSettings={downloadSettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Log-Ordner */}
|
||||
{logSettings.length > 0 && (
|
||||
<div className="path-medium-card">
|
||||
<div className="path-medium-card-header">
|
||||
<h4>Logs</h4>
|
||||
</div>
|
||||
<div className="settings-grid">
|
||||
{logSettings.map((setting) => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
return (
|
||||
<SettingField
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
value={value}
|
||||
error={error}
|
||||
dirty={dirty}
|
||||
ownerSetting={null}
|
||||
ownerValue={null}
|
||||
ownerError={null}
|
||||
ownerDirty={false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DynamicSettingsForm({
|
||||
categories,
|
||||
values,
|
||||
errors,
|
||||
dirtyKeys,
|
||||
onChange
|
||||
onChange,
|
||||
effectivePaths
|
||||
}) {
|
||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
||||
const visibleCategories = safeCategories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
settings: filterSettingsByVisibility(category?.settings, expertModeEnabled)
|
||||
}))
|
||||
.filter((category) => Array.isArray(category?.settings) && category.settings.length > 0);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const rootRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (safeCategories.length === 0) {
|
||||
if (visibleCategories.length === 0) {
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
if (activeIndex < 0 || activeIndex >= safeCategories.length) {
|
||||
if (activeIndex < 0 || activeIndex >= visibleCategories.length) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [activeIndex, safeCategories.length]);
|
||||
}, [activeIndex, visibleCategories.length]);
|
||||
|
||||
if (safeCategories.length === 0) {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
const syncToggleHeights = () => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const grids = root.querySelectorAll('.notification-toggle-grid');
|
||||
for (const grid of grids) {
|
||||
const cards = Array.from(grid.querySelectorAll('.notification-toggle-box'));
|
||||
if (cards.length === 0) {
|
||||
continue;
|
||||
}
|
||||
for (const card of cards) {
|
||||
card.style.minHeight = '0px';
|
||||
}
|
||||
const maxHeight = cards.reduce((acc, card) => Math.max(acc, Number(card.offsetHeight || 0)), 0);
|
||||
if (maxHeight <= 0) {
|
||||
continue;
|
||||
}
|
||||
for (const card of cards) {
|
||||
card.style.minHeight = `${maxHeight}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const frameId = window.requestAnimationFrame(syncToggleHeights);
|
||||
window.addEventListener('resize', syncToggleHeights);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
window.removeEventListener('resize', syncToggleHeights);
|
||||
};
|
||||
}, [activeIndex, visibleCategories, values]);
|
||||
|
||||
if (visibleCategories.length === 0) {
|
||||
return <p>Keine Kategorien vorhanden.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TabView
|
||||
className="settings-tabview"
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
|
||||
scrollable
|
||||
>
|
||||
{safeCategories.map((category, categoryIndex) => (
|
||||
<TabPanel
|
||||
key={`${category.category || 'category'}-${categoryIndex}`}
|
||||
header={category.category || `Kategorie ${categoryIndex + 1}`}
|
||||
>
|
||||
{(() => {
|
||||
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
||||
const grouped = sections.length > 1;
|
||||
<div className="dynamic-settings-form" ref={rootRef}>
|
||||
<TabView
|
||||
className="settings-tabview"
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
|
||||
scrollable
|
||||
>
|
||||
{visibleCategories.map((category, categoryIndex) => (
|
||||
<TabPanel
|
||||
key={`${category.category || 'category'}-${categoryIndex}`}
|
||||
header={category.category || `Kategorie ${categoryIndex + 1}`}
|
||||
>
|
||||
{normalizeText(category?.category) === 'pfade' ? (
|
||||
<PathCategoryTab
|
||||
settings={category?.settings || []}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
effectivePaths={effectivePaths}
|
||||
/>
|
||||
) : (() => {
|
||||
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
||||
const grouped = sections.length > 1;
|
||||
const isNotificationCategory = normalizeText(category?.category) === 'benachrichtigungen';
|
||||
const pushoverEnabled = toBoolean(values?.[PUSHOVER_ENABLED_SETTING_KEY]);
|
||||
|
||||
return (
|
||||
<div className="settings-sections">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={`${category?.category || 'category'}-${section.id}`}
|
||||
className={`settings-section${grouped ? ' grouped' : ''}`}
|
||||
>
|
||||
{section.title ? (
|
||||
<div className="settings-section-head">
|
||||
<h4>{section.title}</h4>
|
||||
{section.description ? <small>{section.description}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{(() => {
|
||||
const ownerKeySet = new Set(
|
||||
(section.settings || [])
|
||||
.filter((s) => String(s.key || '').endsWith('_owner'))
|
||||
.map((s) => s.key)
|
||||
);
|
||||
const settingsByKey = new Map(
|
||||
(section.settings || []).map((s) => [s.key, s])
|
||||
);
|
||||
const visibleSettings = (section.settings || []).filter(
|
||||
(s) => !ownerKeySet.has(s.key)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
{visibleSettings.map((setting) => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
|
||||
const ownerKey = `${setting.key}_owner`;
|
||||
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||
|
||||
return (
|
||||
<div key={setting.key} className="setting-row">
|
||||
<label htmlFor={setting.key}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
</label>
|
||||
|
||||
{setting.type === 'string' || setting.type === 'path' ? (
|
||||
<InputText
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange?.(setting.key, event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'number' ? (
|
||||
<InputNumber
|
||||
id={setting.key}
|
||||
value={value ?? 0}
|
||||
onValueChange={(event) => onChange?.(setting.key, event.value)}
|
||||
mode="decimal"
|
||||
useGrouping={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'boolean' ? (
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'select' ? (
|
||||
<Dropdown
|
||||
id={setting.key}
|
||||
value={value}
|
||||
options={setting.options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<small>{setting.description || ''}</small>
|
||||
{isHandBrakePresetSetting(setting) ? (
|
||||
<small>
|
||||
Preset-Erklärung:{' '}
|
||||
<a
|
||||
href="https://handbrake.fr/docs/en/latest/technical/official-presets.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
HandBrake Official Presets
|
||||
</a>
|
||||
</small>
|
||||
) : null}
|
||||
{error ? (
|
||||
<small className="error-text">{error}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={dirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={dirty ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ownerSetting ? (
|
||||
<div className="setting-owner-row">
|
||||
<label htmlFor={ownerKey} className="setting-owner-label">
|
||||
Eigentümer (user:gruppe)
|
||||
</label>
|
||||
<InputText
|
||||
id={ownerKey}
|
||||
value={values?.[ownerKey] ?? ''}
|
||||
placeholder="z.B. michael:ripster"
|
||||
disabled={!pathHasValue}
|
||||
onChange={(event) => onChange?.(ownerKey, event.target.value)}
|
||||
/>
|
||||
{errors?.[ownerKey] ? (
|
||||
<small className="error-text">{errors[ownerKey]}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={dirtyKeys?.has?.(ownerKey) ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={dirtyKeys?.has?.(ownerKey) ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div className="settings-sections">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={`${category?.category || 'category'}-${section.id}`}
|
||||
className={`settings-section${grouped ? ' grouped' : ''}`}
|
||||
>
|
||||
{section.title ? (
|
||||
<div className="settings-section-head">
|
||||
<h4>{section.title}</h4>
|
||||
{section.description ? <small>{section.description}</small> : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabView>
|
||||
) : null}
|
||||
{(() => {
|
||||
const ownerKeySet = new Set(
|
||||
(section.settings || [])
|
||||
.filter((s) => String(s.key || '').endsWith('_owner'))
|
||||
.map((s) => s.key)
|
||||
);
|
||||
const settingsByKey = new Map(
|
||||
(section.settings || []).map((s) => [s.key, s])
|
||||
);
|
||||
const baseSettings = (section.settings || []).filter(
|
||||
(s) => !ownerKeySet.has(s.key)
|
||||
);
|
||||
const notificationToggleSettings = isNotificationCategory
|
||||
? baseSettings.filter((setting) => isNotificationEventToggleSetting(setting))
|
||||
: [];
|
||||
const notificationToggleKeys = new Set(
|
||||
notificationToggleSettings.map((setting) => normalizeSettingKey(setting?.key))
|
||||
);
|
||||
const regularSettings = baseSettings.filter(
|
||||
(setting) => !notificationToggleKeys.has(normalizeSettingKey(setting?.key))
|
||||
);
|
||||
const renderSetting = (setting, variant = 'default') => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
|
||||
const ownerKey = `${setting.key}_owner`;
|
||||
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
||||
const ownerValue = values?.[ownerKey];
|
||||
const ownerError = errors?.[ownerKey] || null;
|
||||
const ownerDirty = Boolean(dirtyKeys?.has?.(ownerKey));
|
||||
|
||||
return (
|
||||
<SettingField
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
value={value}
|
||||
error={error}
|
||||
dirty={dirty}
|
||||
ownerSetting={ownerSetting}
|
||||
ownerValue={ownerValue}
|
||||
ownerError={ownerError}
|
||||
ownerDirty={ownerDirty}
|
||||
onChange={onChange}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{regularSettings.length > 0 ? (
|
||||
<div className="settings-grid">
|
||||
{regularSettings.map((setting) => renderSetting(setting))}
|
||||
</div>
|
||||
) : null}
|
||||
{pushoverEnabled && notificationToggleSettings.length > 0 ? (
|
||||
<div className="notification-toggle-grid">
|
||||
{notificationToggleSettings.map((setting) => renderSetting(setting, 'notification-toggle'))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import { getStatusLabel } from '../utils/statusPresentation';
|
||||
|
||||
const CD_FORMAT_LABELS = {
|
||||
flac: 'FLAC',
|
||||
wav: 'WAV',
|
||||
mp3: 'MP3',
|
||||
opus: 'Opus',
|
||||
ogg: 'Ogg Vorbis'
|
||||
};
|
||||
|
||||
function JsonView({ title, value }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -19,7 +27,6 @@ function ScriptResultRow({ result }) {
|
||||
const status = String(result?.status || '').toUpperCase();
|
||||
const isSuccess = status === 'SUCCESS';
|
||||
const isError = status === 'ERROR';
|
||||
const isSkipped = status.startsWith('SKIPPED');
|
||||
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
|
||||
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
|
||||
return (
|
||||
@@ -74,6 +81,29 @@ function normalizeIdList(values) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function formatDurationSeconds(totalSeconds) {
|
||||
const parsed = Number(totalSeconds);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Math.max(0, Math.trunc(parsed));
|
||||
const hours = Math.floor(rounded / 3600);
|
||||
const minutes = Math.floor((rounded % 3600) / 60);
|
||||
const seconds = rounded % 60;
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
const raw = String(value ?? '');
|
||||
if (raw.length === 0) {
|
||||
@@ -176,12 +206,13 @@ function buildConfiguredScriptAndChainSelection(job) {
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||
const candidates = [
|
||||
job?.mediaType,
|
||||
job?.media_type,
|
||||
job?.mediaProfile,
|
||||
job?.media_profile,
|
||||
job?.encodePlan?.mediaProfile,
|
||||
encodePlan?.mediaProfile,
|
||||
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
job?.makemkvInfo?.mediaProfile,
|
||||
job?.mediainfoInfo?.mediaProfile
|
||||
@@ -197,10 +228,131 @@ function resolveMediaType(job) {
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||
return 'audiobook';
|
||||
}
|
||||
}
|
||||
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
|
||||
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||
return 'cd';
|
||||
}
|
||||
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
||||
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
||||
return 'cd';
|
||||
}
|
||||
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||
return 'cd';
|
||||
}
|
||||
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
|
||||
return 'audiobook';
|
||||
}
|
||||
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||
return 'audiobook';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function resolveCdDetails(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
|
||||
: {};
|
||||
const tracksSource = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
|
||||
? makemkvInfo.tracks
|
||||
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
|
||||
const tracks = tracksSource
|
||||
.map((track) => {
|
||||
const position = normalizePositiveInteger(track?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
return { ...track, position, selected: track?.selected !== false };
|
||||
})
|
||||
.filter(Boolean);
|
||||
const selectedTracksFromPlan = Array.isArray(encodePlan?.selectedTracks)
|
||||
? encodePlan.selectedTracks.map((v) => normalizePositiveInteger(v)).filter(Boolean)
|
||||
: [];
|
||||
const selectedTrackPositions = selectedTracksFromPlan.length > 0
|
||||
? selectedTracksFromPlan
|
||||
: tracks.filter((t) => t.selected !== false).map((t) => t.position);
|
||||
const fallbackArtist = tracks.map((t) => String(t?.artist || '').trim()).find(Boolean) || null;
|
||||
const fallbackAlbum = tracks.map((t) => String(t?.album || '').trim()).find(Boolean) || null;
|
||||
const totalDurationSec = tracks.reduce((sum, t) => {
|
||||
const ms = Number(t?.durationMs);
|
||||
const sec = Number(t?.durationSec);
|
||||
if (Number.isFinite(ms) && ms > 0) {
|
||||
return sum + ms / 1000;
|
||||
}
|
||||
if (Number.isFinite(sec) && sec > 0) {
|
||||
return sum + sec;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const format = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const mbId = String(
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.mbid
|
||||
|| ''
|
||||
).trim() || null;
|
||||
|
||||
return {
|
||||
artist: String(selectedMetadata?.artist || '').trim() || fallbackArtist || null,
|
||||
album: String(selectedMetadata?.album || '').trim() || fallbackAlbum || null,
|
||||
trackCount: tracks.length,
|
||||
selectedTrackCount: selectedTrackPositions.length,
|
||||
format,
|
||||
formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null,
|
||||
totalDurationLabel: formatDurationSeconds(totalDurationSec),
|
||||
mbId
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
const formatOptions = job?.handbrakeInfo?.formatOptions && typeof job.handbrakeInfo.formatOptions === 'object'
|
||||
? job.handbrakeInfo.formatOptions
|
||||
: (encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object' ? encodePlan.formatOptions : {});
|
||||
const qualityLabel = format === 'mp3'
|
||||
? (
|
||||
String(formatOptions?.mp3Mode || '').trim().toLowerCase() === 'vbr'
|
||||
? `VBR V${Number(formatOptions?.mp3Quality ?? 4)}`
|
||||
: `CBR ${Number(formatOptions?.mp3Bitrate ?? 192)} kbps`
|
||||
)
|
||||
: (format === 'flac'
|
||||
? `Kompression ${Number(formatOptions?.flacCompression ?? 5)}`
|
||||
: (format === 'm4b' ? 'Original-Audio' : null));
|
||||
return {
|
||||
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
|
||||
narrator: String(selectedMetadata?.narrator || '').trim() || null,
|
||||
series: String(selectedMetadata?.series || '').trim() || null,
|
||||
part: String(selectedMetadata?.part || '').trim() || null,
|
||||
chapterCount: chapters.length,
|
||||
formatLabel: format ? format.toUpperCase() : null,
|
||||
qualityLabel
|
||||
};
|
||||
}
|
||||
|
||||
function statusBadgeMeta(status, queued = false) {
|
||||
const normalized = String(status || '').trim().toUpperCase();
|
||||
const label = getStatusLabel(normalized, { queued });
|
||||
@@ -264,6 +416,42 @@ function BoolState({ value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PathField({
|
||||
label,
|
||||
value,
|
||||
onDownload = null,
|
||||
downloadDisabled = false,
|
||||
downloadLoading = false
|
||||
}) {
|
||||
const hasValue = Boolean(String(value || '').trim());
|
||||
const canDownload = hasValue && typeof onDownload === 'function' && !downloadDisabled;
|
||||
|
||||
return (
|
||||
<div className="job-path-field">
|
||||
<strong>{label}</strong>
|
||||
<div className="job-path-field-value">
|
||||
<span>{hasValue ? value : '-'}</span>
|
||||
{canDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
icon="pi pi-download"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
className="job-path-download-button"
|
||||
aria-label={`${label} als ZIP vorbereiten`}
|
||||
tooltip={`${label} als ZIP vorbereiten`}
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
onClick={onDownload}
|
||||
disabled={downloadDisabled || downloadLoading}
|
||||
loading={downloadLoading}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobDetailDialog({
|
||||
visible,
|
||||
job,
|
||||
@@ -272,18 +460,23 @@ export default function JobDetailDialog({
|
||||
onLoadLog,
|
||||
logLoadingMode = null,
|
||||
onAssignOmdb,
|
||||
onAssignCdMetadata,
|
||||
onResumeReady,
|
||||
onRestartEncode,
|
||||
onRestartReview,
|
||||
onReencode,
|
||||
onRetry,
|
||||
onDeleteFiles,
|
||||
onDeleteEntry,
|
||||
onDownloadArchive,
|
||||
onRemoveFromQueue,
|
||||
isQueued = false,
|
||||
omdbAssignBusy = false,
|
||||
cdMetadataAssignBusy = false,
|
||||
actionBusy = false,
|
||||
reencodeBusy = false,
|
||||
deleteEntryBusy = false
|
||||
deleteEntryBusy = false,
|
||||
downloadBusyTarget = null
|
||||
}) {
|
||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||
@@ -294,6 +487,9 @@ export default function JobDetailDialog({
|
||||
&& !running
|
||||
&& typeof onResumeReady === 'function'
|
||||
);
|
||||
const mediaType = resolveMediaType(job);
|
||||
const isCd = mediaType === 'cd';
|
||||
const isAudiobook = mediaType === 'audiobook';
|
||||
const hasConfirmedPlan = Boolean(
|
||||
job?.encodePlan
|
||||
&& Array.isArray(job?.encodePlan?.titles)
|
||||
@@ -306,6 +502,7 @@ export default function JobDetailDialog({
|
||||
job?.rawStatus?.exists
|
||||
&& job?.rawStatus?.isEmpty !== true
|
||||
&& !running
|
||||
&& mediaType !== 'audiobook'
|
||||
&& typeof onRestartReview === 'function'
|
||||
);
|
||||
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
|
||||
@@ -314,16 +511,22 @@ export default function JobDetailDialog({
|
||||
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||
const logTruncated = Boolean(logMeta?.truncated);
|
||||
const mediaType = resolveMediaType(job);
|
||||
const cdDetails = isCd ? resolveCdDetails(job) : null;
|
||||
const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null;
|
||||
const canRetry = isCd && !running && typeof onRetry === 'function';
|
||||
const mediaTypeLabel = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||
: mediaType === 'dvd'
|
||||
? 'DVD'
|
||||
: isCd
|
||||
? 'Audio CD'
|
||||
: (isAudiobook ? 'Audiobook' : 'Sonstiges Medium');
|
||||
const mediaTypeIcon = mediaType === 'bluray'
|
||||
? blurayIndicatorIcon
|
||||
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
||||
const mediaTypeAlt = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||
: mediaType === 'dvd'
|
||||
? discIndicatorIcon
|
||||
: otherIndicatorIcon;
|
||||
const mediaTypeAlt = mediaTypeLabel;
|
||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||
@@ -345,6 +548,8 @@ export default function JobDetailDialog({
|
||||
const encodePlanUserPresetId = Number(encodePlanUserPreset?.id);
|
||||
const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : [];
|
||||
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -364,68 +569,156 @@ export default function JobDetailDialog({
|
||||
{job.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
|
||||
) : (
|
||||
<div className="poster-large poster-fallback">Kein Poster</div>
|
||||
<div className="poster-large poster-fallback">{isCd || isAudiobook ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||
)}
|
||||
|
||||
<div className="job-film-info-grid">
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>Film-Infos</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Titel:</strong>
|
||||
<span>{job.title || job.detected_title || '-'}</span>
|
||||
{isCd ? (
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>Musik-Infos</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Album:</strong>
|
||||
<span>{job.title || job.detected_title || cdDetails?.album || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Interpret:</strong>
|
||||
<span>{cdDetails?.artist || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Jahr:</strong>
|
||||
<span>{job.year || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Tracks:</strong>
|
||||
<span>
|
||||
{cdDetails?.trackCount > 0
|
||||
? (cdDetails.selectedTrackCount > 0 && cdDetails.selectedTrackCount !== cdDetails.trackCount
|
||||
? `${cdDetails.selectedTrackCount}/${cdDetails.trackCount}`
|
||||
: String(cdDetails.trackCount))
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Format:</strong>
|
||||
<span>{cdDetails?.formatLabel || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Gesamtdauer:</strong>
|
||||
<span>{cdDetails?.totalDurationLabel || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>MusicBrainz ID:</strong>
|
||||
<span>{cdDetails?.mbId || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Medium:</strong>
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||
<span>{mediaTypeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Jahr:</strong>
|
||||
<span>{job.year || '-'}</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">
|
||||
<strong>Medium:</strong>
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||
<span>{mediaTypeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Titel:</strong>
|
||||
<span>{job.title || job.detected_title || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Jahr:</strong>
|
||||
<span>{job.year || '-'}</span>
|
||||
</div>
|
||||
{isAudiobook ? (
|
||||
<>
|
||||
<div className="job-meta-item">
|
||||
<strong>Autor:</strong>
|
||||
<span>{audiobookDetails?.author || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<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>Qualität:</strong>
|
||||
<span>{audiobookDetails?.qualityLabel || '-'}</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">
|
||||
<strong>Medium:</strong>
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||
<span>{mediaTypeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>OMDb Details</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<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>
|
||||
</section>
|
||||
{!isAudiobook ? (
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>OMDb Details</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<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>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -448,34 +741,52 @@ export default function JobDetailDialog({
|
||||
<div>
|
||||
<strong>Ende:</strong> {job.end_time || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>RAW Pfad:</strong> {job.raw_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Output:</strong> {job.output_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||
</div>
|
||||
<PathField
|
||||
label={isCd ? 'WAV Pfad:' : 'RAW Pfad:'}
|
||||
value={job.raw_path}
|
||||
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||
downloadDisabled={!canDownloadRaw}
|
||||
downloadLoading={downloadBusyTarget === 'raw'}
|
||||
/>
|
||||
<PathField
|
||||
label="Output:"
|
||||
value={job.output_path}
|
||||
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
||||
downloadDisabled={!canDownloadOutput}
|
||||
downloadLoading={downloadBusyTarget === 'output'}
|
||||
/>
|
||||
{!isCd ? (
|
||||
<div>
|
||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Movie Datei vorhanden:</strong> <BoolState value={job.outputStatus?.exists} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
||||
<strong>{isCd ? 'Audio-Dateien vorhanden:' : (isAudiobook ? (job.outputStatus?.isDirectory ? 'Audiobook-Dateien vorhanden:' : 'Audiobook-Datei vorhanden:') : 'Movie Datei vorhanden:')}</strong> <BoolState value={job.outputStatus?.exists} />
|
||||
</div>
|
||||
{isCd ? (
|
||||
<div>
|
||||
<strong>Rip erfolgreich:</strong> <BoolState value={job?.ripSuccessful} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<strong>{isAudiobook ? 'Import erfolgreich:' : 'Backup erfolgreich:'}</strong> <BoolState value={job?.backupSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="job-meta-col-span-2">
|
||||
<strong>Letzter Fehler:</strong> {job.error_message || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{hasConfiguredSelection || encodePlanUserPreset ? (
|
||||
{!isCd && !isAudiobook && (hasConfiguredSelection || encodePlanUserPreset) ? (
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Hinterlegte Encode-Auswahl</h4>
|
||||
<div className="job-configured-selection-grid">
|
||||
@@ -501,17 +812,17 @@ export default function JobDetailDialog({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{executedHandBrakeCommand ? (
|
||||
{!isCd && executedHandBrakeCommand ? (
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Ausgeführter Encode-Befehl</h4>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(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">
|
||||
<h4>Skripte</h4>
|
||||
<div className="script-results-grid">
|
||||
@@ -522,14 +833,14 @@ export default function JobDetailDialog({
|
||||
) : null}
|
||||
|
||||
<div className="job-json-grid">
|
||||
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
||||
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
||||
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
|
||||
<JsonView title="Encode Plan" value={job.encodePlan} />
|
||||
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
||||
{!isCd && !isAudiobook ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
|
||||
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : 'MakeMKV Info')} value={job.makemkvInfo} />
|
||||
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
||||
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
|
||||
{!isCd ? <JsonView title={isAudiobook ? 'FFmpeg Info' : 'HandBrake Info'} value={job.handbrakeInfo} /> : null}
|
||||
</div>
|
||||
|
||||
{job.encodePlan ? (
|
||||
{!isCd && !isAudiobook && job.encodePlan ? (
|
||||
<>
|
||||
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
||||
<MediaInfoReviewPanel
|
||||
@@ -562,16 +873,28 @@ export default function JobDetailDialog({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
label="OMDb neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(job)}
|
||||
loading={omdbAssignBusy}
|
||||
disabled={running || typeof onAssignOmdb !== 'function'}
|
||||
/>
|
||||
{canResumeReady ? (
|
||||
{!isCd && !isAudiobook ? (
|
||||
<Button
|
||||
label="OMDb neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(job)}
|
||||
loading={omdbAssignBusy}
|
||||
disabled={running || typeof onAssignOmdb !== 'function'}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
label="MusicBrainz neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onAssignCdMetadata?.(job)}
|
||||
loading={cdMetadataAssignBusy}
|
||||
disabled={running || typeof onAssignCdMetadata !== 'function'}
|
||||
/>
|
||||
)}
|
||||
{!isCd && canResumeReady ? (
|
||||
<Button
|
||||
label="Im Dashboard öffnen"
|
||||
icon="pi pi-window-maximize"
|
||||
@@ -582,7 +905,7 @@ export default function JobDetailDialog({
|
||||
loading={actionBusy}
|
||||
/>
|
||||
) : null}
|
||||
{typeof onRestartEncode === 'function' ? (
|
||||
{!isCd && typeof onRestartEncode === 'function' ? (
|
||||
<Button
|
||||
label="Encode neu starten"
|
||||
icon="pi pi-play"
|
||||
@@ -593,7 +916,7 @@ export default function JobDetailDialog({
|
||||
disabled={!canRestartEncode}
|
||||
/>
|
||||
) : null}
|
||||
{typeof onRestartReview === 'function' ? (
|
||||
{!isCd && typeof onRestartReview === 'function' ? (
|
||||
<Button
|
||||
label="Review neu starten"
|
||||
icon="pi pi-refresh"
|
||||
@@ -605,15 +928,17 @@ export default function JobDetailDialog({
|
||||
disabled={!canRestartReview}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="RAW neu encodieren"
|
||||
icon="pi pi-cog"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode || typeof onReencode !== 'function'}
|
||||
/>
|
||||
{!isCd ? (
|
||||
<Button
|
||||
label="RAW neu encodieren"
|
||||
icon="pi pi-cog"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode || typeof onReencode !== 'function'}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
@@ -625,7 +950,7 @@ export default function JobDetailDialog({
|
||||
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="Movie löschen"
|
||||
label={isCd ? 'Audio löschen' : 'Movie löschen'}
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
|
||||
@@ -532,6 +532,84 @@ function resolveAudioEncoderPreviewLabel(track, encoderToken, copyMask, fallback
|
||||
return `Transcode (${normalizedToken})`;
|
||||
}
|
||||
|
||||
function parseAudioSelectorFromArgs(extraArgsString, baseSelector) {
|
||||
const base = baseSelector && typeof baseSelector === 'object' ? baseSelector : {};
|
||||
const args = String(extraArgsString || '').trim();
|
||||
if (!args) {
|
||||
return base;
|
||||
}
|
||||
|
||||
// Tokenize: split on whitespace but respect quoted strings
|
||||
const tokens = [];
|
||||
let current = '';
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const ch = args[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === ' ' || ch === '\t') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
const result = { ...base };
|
||||
|
||||
const getNext = (i) => (i + 1 < tokens.length ? tokens[i + 1] : null);
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
|
||||
// Support both --flag=value and --flag value
|
||||
const eqIdx = token.indexOf('=');
|
||||
const flag = eqIdx !== -1 ? token.slice(0, eqIdx) : token;
|
||||
const inlineVal = eqIdx !== -1 ? token.slice(eqIdx + 1) : null;
|
||||
|
||||
const getValue = () => {
|
||||
if (inlineVal !== null) return inlineVal;
|
||||
const next = getNext(i);
|
||||
if (next && !next.startsWith('-')) {
|
||||
i++;
|
||||
return next;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (flag === '--aencoder') {
|
||||
const val = getValue();
|
||||
if (val) {
|
||||
result.encoders = val.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
result.encoderSource = 'args';
|
||||
}
|
||||
} else if (flag === '--audio-copy-mask') {
|
||||
const val = getValue();
|
||||
if (val) {
|
||||
result.copyMask = val.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
} else if (flag === '--audio-fallback') {
|
||||
const val = getValue();
|
||||
if (val) {
|
||||
result.fallbackEncoder = val.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildAudioActionPreviewSummary(track, selectedIndex, audioSelector) {
|
||||
const selector = audioSelector && typeof audioSelector === 'object' ? audioSelector : {};
|
||||
const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : [];
|
||||
@@ -595,6 +673,11 @@ function TrackList({
|
||||
const actionInfo = type === 'audio'
|
||||
? (checked
|
||||
? (() => {
|
||||
// When encoder comes from user preset extraArgs, always recompute
|
||||
// (server-stored summary reflects a different/default preset)
|
||||
if (audioSelector?.encoderSource === 'args') {
|
||||
return buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
|
||||
}
|
||||
const base = String(track.encodePreviewSummary || track.encodeActionSummary || '').trim();
|
||||
const staleUnselectedSummary = /^nicht übernommen$/i.test(base);
|
||||
if (staleUnselectedSummary) {
|
||||
@@ -759,6 +842,9 @@ export default function MediaInfoReviewPanel({
|
||||
const effectivePresetOverride = selectedUserPreset
|
||||
? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' }
|
||||
: null;
|
||||
const effectiveAudioSelector = effectivePresetOverride?.extraArgs
|
||||
? parseAudioSelectorFromArgs(effectivePresetOverride.extraArgs, review?.selectors?.audio)
|
||||
: (review?.selectors?.audio || null);
|
||||
const hasUserPresets = normalizedUserPresets.length > 0;
|
||||
const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection;
|
||||
|
||||
@@ -843,10 +929,10 @@ export default function MediaInfoReviewPanel({
|
||||
<div><strong>Preset-Profil:</strong> {effectivePresetOverride ? 'user-preset' : (review.selectors?.presetProfileSource || '-')}</div>
|
||||
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
|
||||
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
|
||||
<div><strong>Audio Auswahl:</strong> {review.selectors?.audio?.mode || '-'}</div>
|
||||
<div><strong>Audio Encoder:</strong> {(review.selectors?.audio?.encoders || []).join(', ') || 'Preset-Default'}</div>
|
||||
<div><strong>Audio Copy-Mask:</strong> {(review.selectors?.audio?.copyMask || []).join(', ') || '-'}</div>
|
||||
<div><strong>Audio Fallback:</strong> {review.selectors?.audio?.fallbackEncoder || '-'}</div>
|
||||
<div><strong>Audio Auswahl:</strong> {effectiveAudioSelector?.mode || '-'}</div>
|
||||
<div><strong>Audio Encoder:</strong> {(effectiveAudioSelector?.encoders || []).join(', ') || 'Preset-Default'}</div>
|
||||
<div><strong>Audio Copy-Mask:</strong> {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}</div>
|
||||
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
|
||||
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
|
||||
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.forcedOnly ? 'forced-only' : '-'}{review.selectors?.subtitle?.burnBehavior === 'first' ? ' + burned(first)' : ''}</div>
|
||||
</div>
|
||||
@@ -1206,7 +1292,7 @@ export default function MediaInfoReviewPanel({
|
||||
type="audio"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedAudioTrackIds}
|
||||
audioSelector={review?.selectors?.audio || null}
|
||||
audioSelector={effectiveAudioSelector}
|
||||
onToggleTrack={(trackId, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
|
||||
@@ -109,6 +109,9 @@ function normalizeMediaProfile(value) {
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||
return 'audiobook';
|
||||
}
|
||||
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
|
||||
return 'other';
|
||||
}
|
||||
@@ -234,8 +237,8 @@ function sanitizeFileName(input) {
|
||||
}
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
||||
const value = values[key.trim()];
|
||||
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
|
||||
const value = values[(keyA || keyB || '').trim()];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -243,22 +246,56 @@ function renderTemplate(template, values) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
||||
const movieDir = String(settings?.movie_dir || '').trim();
|
||||
function resolveProfiledSetting(settings, key, mediaProfile) {
|
||||
const profileKey = mediaProfile ? `${key}_${mediaProfile}` : null;
|
||||
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
|
||||
return settings[profileKey];
|
||||
}
|
||||
const fallbackProfiles = mediaProfile === 'bluray'
|
||||
? ['dvd']
|
||||
: mediaProfile === 'dvd'
|
||||
? ['bluray']
|
||||
: [];
|
||||
for (const fb of fallbackProfiles) {
|
||||
const fbKey = `${key}_${fb}`;
|
||||
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
|
||||
return settings[fbKey];
|
||||
}
|
||||
}
|
||||
return settings?.[key] ?? null;
|
||||
}
|
||||
|
||||
function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId = null) {
|
||||
const movieDir = String(resolveProfiledSetting(settings, 'movie_dir', mediaProfile) || '').trim();
|
||||
if (!movieDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||
const fileTemplate = settings?.filename_template || '${title} (${year})';
|
||||
const folderTemplate = String(settings?.output_folder_template || '').trim() || fileTemplate;
|
||||
const folderName = sanitizeFileName(renderTemplate(folderTemplate, { title, year, imdbId }));
|
||||
const baseName = sanitizeFileName(renderTemplate(fileTemplate, { title, year, imdbId }));
|
||||
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
|
||||
const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})';
|
||||
const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile);
|
||||
const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE;
|
||||
const rendered = renderTemplate(template, { title, year, imdbId, author, narrator });
|
||||
const segments = rendered
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.map((seg) => sanitizeFileName(seg))
|
||||
.filter(Boolean);
|
||||
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
|
||||
const folderParts = segments.slice(0, -1);
|
||||
const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile);
|
||||
const ext = String(rawExt || 'mkv').trim() || 'mkv';
|
||||
const root = movieDir.replace(/\/+$/g, '');
|
||||
return `${root}/${folderName}/${baseName}.${ext}`;
|
||||
if (folderParts.length > 0) {
|
||||
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
|
||||
}
|
||||
return `${root}/${baseName}.${ext}`;
|
||||
}
|
||||
|
||||
export default function PipelineStatusCard({
|
||||
@@ -266,6 +303,7 @@ export default function PipelineStatusCard({
|
||||
onAnalyze,
|
||||
onReanalyze,
|
||||
onOpenMetadata,
|
||||
onReassignOmdb,
|
||||
onStart,
|
||||
onRemoveFromQueue,
|
||||
onRestartEncode,
|
||||
@@ -515,8 +553,8 @@ export default function PipelineStatusCard({
|
||||
|
||||
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
|
||||
const commandOutputPath = useMemo(
|
||||
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
|
||||
[settingsMap, selectedMetadata, retryJobId]
|
||||
() => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId),
|
||||
[settingsMap, jobMediaProfile, selectedMetadata, retryJobId]
|
||||
);
|
||||
const presetDisplayValue = useMemo(() => {
|
||||
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
||||
@@ -625,6 +663,17 @@ export default function PipelineStatusCard({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!running && state !== 'METADATA_SELECTION' && state !== 'WAITING_FOR_USER_DECISION' && state !== 'IDLE' && state !== 'DISC_DETECTED' && retryJobId && typeof onReassignOmdb === 'function' ? (
|
||||
<Button
|
||||
label="OMDb neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onReassignOmdb?.(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state === 'READY_TO_START' && retryJobId ? (
|
||||
<Button
|
||||
label="Job starten"
|
||||
|
||||
80
frontend/src/config/audiobookFormatSchemas.js
Normal file
80
frontend/src/config/audiobookFormatSchemas.js
Normal file
@@ -0,0 +1,80 @@
|
||||
export const AUDIOBOOK_FORMATS = [
|
||||
{ label: 'M4B (Original-Audio)', value: 'm4b' },
|
||||
{ label: 'MP3', value: 'mp3' },
|
||||
{ label: 'FLAC (verlustlos)', value: 'flac' }
|
||||
];
|
||||
|
||||
export const AUDIOBOOK_FORMAT_SCHEMAS = {
|
||||
m4b: {
|
||||
fields: []
|
||||
},
|
||||
|
||||
flac: {
|
||||
fields: [
|
||||
{
|
||||
key: 'flacCompression',
|
||||
label: 'Kompressionsstufe',
|
||||
description: '0 = schnell / wenig Kompression, 8 = maximale Kompression',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 8,
|
||||
step: 1,
|
||||
default: 5
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
mp3: {
|
||||
fields: [
|
||||
{
|
||||
key: 'mp3Mode',
|
||||
label: 'Modus',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'CBR (Konstante Bitrate)', value: 'cbr' },
|
||||
{ label: 'VBR (Variable Bitrate)', value: 'vbr' }
|
||||
],
|
||||
default: 'cbr'
|
||||
},
|
||||
{
|
||||
key: 'mp3Bitrate',
|
||||
label: 'Bitrate (kbps)',
|
||||
type: 'select',
|
||||
showWhen: { field: 'mp3Mode', value: 'cbr' },
|
||||
options: [
|
||||
{ label: '128 kbps', value: 128 },
|
||||
{ label: '160 kbps', value: 160 },
|
||||
{ label: '192 kbps', value: 192 },
|
||||
{ label: '256 kbps', value: 256 },
|
||||
{ label: '320 kbps', value: 320 }
|
||||
],
|
||||
default: 192
|
||||
},
|
||||
{
|
||||
key: 'mp3Quality',
|
||||
label: 'VBR Qualität (V0-V9)',
|
||||
description: '0 = beste Qualität, 9 = kleinste Datei',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 9,
|
||||
step: 1,
|
||||
showWhen: { field: 'mp3Mode', value: 'vbr' },
|
||||
default: 4
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function getDefaultAudiobookFormatOptions(format) {
|
||||
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format];
|
||||
if (!schema) {
|
||||
return {};
|
||||
}
|
||||
const defaults = {};
|
||||
for (const field of schema.fields) {
|
||||
if (field.default !== undefined) {
|
||||
defaults[field.key] = field.default;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
126
frontend/src/config/cdFormatSchemas.js
Normal file
126
frontend/src/config/cdFormatSchemas.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* CD output format schemas.
|
||||
* Each format defines the fields shown in CdRipConfigPanel.
|
||||
*/
|
||||
export const CD_FORMATS = [
|
||||
{ label: 'FLAC (verlustlos)', value: 'flac' },
|
||||
{ label: 'MP3', value: 'mp3' },
|
||||
{ label: 'Opus', value: 'opus' },
|
||||
{ label: 'OGG Vorbis', value: 'ogg' },
|
||||
{ label: 'WAV (unkomprimiert)', value: 'wav' }
|
||||
];
|
||||
|
||||
export const CD_FORMAT_SCHEMAS = {
|
||||
wav: {
|
||||
fields: []
|
||||
},
|
||||
|
||||
flac: {
|
||||
fields: [
|
||||
{
|
||||
key: 'flacCompression',
|
||||
label: 'Kompressionsstufe',
|
||||
description: '0 = schnell / wenig Kompression, 8 = maximale Kompression',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 8,
|
||||
step: 1,
|
||||
default: 5
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
mp3: {
|
||||
fields: [
|
||||
{
|
||||
key: 'mp3Mode',
|
||||
label: 'Modus',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'CBR (Konstante Bitrate)', value: 'cbr' },
|
||||
{ label: 'VBR (Variable Bitrate)', value: 'vbr' }
|
||||
],
|
||||
default: 'cbr'
|
||||
},
|
||||
{
|
||||
key: 'mp3Bitrate',
|
||||
label: 'Bitrate (kbps)',
|
||||
type: 'select',
|
||||
showWhen: { field: 'mp3Mode', value: 'cbr' },
|
||||
options: [
|
||||
{ label: '128 kbps', value: 128 },
|
||||
{ label: '160 kbps', value: 160 },
|
||||
{ label: '192 kbps', value: 192 },
|
||||
{ label: '256 kbps', value: 256 },
|
||||
{ label: '320 kbps', value: 320 }
|
||||
],
|
||||
default: 192
|
||||
},
|
||||
{
|
||||
key: 'mp3Quality',
|
||||
label: 'VBR Qualität (V0–V9)',
|
||||
description: '0 = beste Qualität, 9 = kleinste Datei',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 9,
|
||||
step: 1,
|
||||
showWhen: { field: 'mp3Mode', value: 'vbr' },
|
||||
default: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
opus: {
|
||||
fields: [
|
||||
{
|
||||
key: 'opusBitrate',
|
||||
label: 'Bitrate (kbps)',
|
||||
description: 'Empfohlen: 96–192 kbps für Musik',
|
||||
type: 'slider',
|
||||
min: 32,
|
||||
max: 512,
|
||||
step: 8,
|
||||
default: 160
|
||||
},
|
||||
{
|
||||
key: 'opusComplexity',
|
||||
label: 'Encoder-Komplexität',
|
||||
description: '0 = schnell, 10 = beste Qualität',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 10
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
ogg: {
|
||||
fields: [
|
||||
{
|
||||
key: 'oggQuality',
|
||||
label: 'Qualität',
|
||||
description: '-1 = kleinste Datei, 10 = beste Qualität. Empfohlen: 5–7.',
|
||||
type: 'slider',
|
||||
min: -1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 6
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function getDefaultFormatOptions(format) {
|
||||
const schema = CD_FORMAT_SCHEMAS[format];
|
||||
if (!schema) {
|
||||
return {};
|
||||
}
|
||||
const defaults = {};
|
||||
for (const field of schema.fields) {
|
||||
if (field.default !== undefined) {
|
||||
defaults[field.key] = field.default;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
@@ -42,6 +43,12 @@ function resolveMediaType(row) {
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (['cd', 'audio_cd'].includes(raw)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||
return 'audiobook';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
@@ -72,6 +79,9 @@ export default function DatabasePage() {
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [metadataDialogBusy, setMetadataDialogBusy] = useState(false);
|
||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||
const [cdMetadataDialogBusy, setCdMetadataDialogBusy] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||
const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null);
|
||||
@@ -467,7 +477,7 @@ export default function DatabasePage() {
|
||||
|
||||
const handleImportOrphanRaw = async (row) => {
|
||||
const target = row?.rawPath || row?.folderName || '-';
|
||||
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
|
||||
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen und direkt scannen?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
@@ -475,12 +485,32 @@ export default function DatabasePage() {
|
||||
setOrphanImportBusyPath(row.rawPath);
|
||||
try {
|
||||
const response = await api.importOrphanRawFolder(row.rawPath);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job angelegt',
|
||||
detail: `Historieneintrag #${response?.job?.id || '-'} wurde erstellt.`,
|
||||
life: 3500
|
||||
});
|
||||
const newJobId = response?.job?.id;
|
||||
if (newJobId) {
|
||||
try {
|
||||
await api.reencodeJob(newJobId);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job angelegt & Scan gestartet',
|
||||
detail: `Historieneintrag #${newJobId} erstellt, Mediainfo-Scan läuft.`,
|
||||
life: 4000
|
||||
});
|
||||
} catch (scanError) {
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Job angelegt',
|
||||
detail: `Historieneintrag #${newJobId} erstellt. Scan konnte nicht automatisch gestartet werden: ${scanError.message}`,
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job angelegt',
|
||||
detail: `Historieneintrag wurde erstellt.`,
|
||||
life: 3500
|
||||
});
|
||||
}
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
@@ -504,6 +534,77 @@ export default function DatabasePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMusicBrainzSearch = async (query) => {
|
||||
try {
|
||||
const response = await api.searchMusicBrainz(query);
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Suche fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleMusicBrainzReleaseFetch = async (mbId) => {
|
||||
try {
|
||||
const response = await api.getMusicBrainzRelease(mbId);
|
||||
return response.release || null;
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Release fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const openCdMetadataAssignDialog = (row) => {
|
||||
if (!row?.id) {
|
||||
return;
|
||||
}
|
||||
const makemkvInfo = row.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const tocTracks = Array.isArray(makemkvInfo.tracks) ? makemkvInfo.tracks : [];
|
||||
const selectedMetadata = makemkvInfo.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
setCdMetadataDialogContext({
|
||||
jobId: row.id,
|
||||
detectedTitle: row.title || row.detected_title || selectedMetadata.title || '',
|
||||
tracks: tocTracks
|
||||
});
|
||||
setCdMetadataDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleCdMetadataAssignSubmit = async (payload) => {
|
||||
const jobId = Number(payload?.jobId || cdMetadataDialogContext?.jobId || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCdMetadataDialogBusy(true);
|
||||
try {
|
||||
const response = await api.assignJobCdMetadata(jobId, payload);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'CD-Metadaten aktualisiert',
|
||||
detail: `Job #${jobId} wurde aktualisiert.`,
|
||||
life: 3500
|
||||
});
|
||||
setCdMetadataDialogVisible(false);
|
||||
await load();
|
||||
if (detailVisible && selectedJob?.id === jobId && response?.job) {
|
||||
setSelectedJob(response.job);
|
||||
} else {
|
||||
await refreshDetailIfOpen(jobId);
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'CD-Metadaten fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
setCdMetadataDialogBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openMetadataAssignDialog = (row) => {
|
||||
if (!row?.id) {
|
||||
return;
|
||||
@@ -600,13 +701,13 @@ export default function DatabasePage() {
|
||||
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
||||
const alt = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
|
||||
const title = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges Medium'));
|
||||
const label = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges');
|
||||
: (mediaType === 'dvd' ? 'DVD' : (mediaType === 'audiobook' ? 'Audiobook' : 'Sonstiges'));
|
||||
return (
|
||||
<span className="job-step-cell">
|
||||
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
||||
@@ -683,7 +784,7 @@ export default function DatabasePage() {
|
||||
|
||||
<Card
|
||||
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">
|
||||
<Button
|
||||
@@ -728,6 +829,7 @@ export default function DatabasePage() {
|
||||
setLogLoadingMode(null);
|
||||
}}
|
||||
onAssignOmdb={openMetadataAssignDialog}
|
||||
onAssignCdMetadata={openCdMetadataAssignDialog}
|
||||
onResumeReady={handleResumeReady}
|
||||
onRestartEncode={handleRestartEncode}
|
||||
onRestartReview={handleRestartReview}
|
||||
@@ -737,6 +839,7 @@ export default function DatabasePage() {
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
omdbAssignBusy={metadataDialogBusy}
|
||||
cdMetadataAssignBusy={cdMetadataDialogBusy}
|
||||
actionBusy={actionBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusyJobId === selectedJob?.id}
|
||||
@@ -750,6 +853,19 @@ export default function DatabasePage() {
|
||||
onSearch={handleOmdbSearch}
|
||||
busy={metadataDialogBusy}
|
||||
/>
|
||||
|
||||
<CdMetadataDialog
|
||||
visible={cdMetadataDialogVisible}
|
||||
context={cdMetadataDialogContext || {}}
|
||||
onHide={() => {
|
||||
setCdMetadataDialogVisible(false);
|
||||
setCdMetadataDialogContext(null);
|
||||
}}
|
||||
onSubmit={handleCdMetadataAssignSubmit}
|
||||
onSearch={handleMusicBrainzSearch}
|
||||
onFetchRelease={handleMusicBrainzReleaseFetch}
|
||||
busy={cdMetadataDialogBusy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
305
frontend/src/pages/DownloadsPage.jsx
Normal file
305
frontend/src/pages/DownloadsPage.jsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: 'Alle Stati', value: '' },
|
||||
{ label: 'Wartend', value: 'queued' },
|
||||
{ label: 'Laufend', value: 'processing' },
|
||||
{ label: 'Bereit', value: 'ready' },
|
||||
{ label: 'Fehlgeschlagen', value: 'failed' }
|
||||
];
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return '-';
|
||||
}
|
||||
if (parsed === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let current = parsed;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const digits = unitIndex === 0 ? 0 : 2;
|
||||
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||
}
|
||||
|
||||
function getStatusMeta(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'queued') {
|
||||
return { label: 'Wartend', severity: 'warning' };
|
||||
}
|
||||
if (normalized === 'processing') {
|
||||
return { label: 'Laeuft', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'ready') {
|
||||
return { label: 'Bereit', severity: 'success' };
|
||||
}
|
||||
return { label: 'Fehlgeschlagen', severity: 'danger' };
|
||||
}
|
||||
|
||||
export default function DownloadsPage({ refreshToken = 0 }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadBusyId, setDownloadBusyId] = useState(null);
|
||||
const [deleteBusyId, setDeleteBusyId] = useState(null);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const hasActiveItems = useMemo(
|
||||
() => items.some((item) => ['queued', 'processing'].includes(String(item?.status || '').trim().toLowerCase())),
|
||||
[items]
|
||||
);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const searchText = normalizeSearchText(search);
|
||||
return items.filter((item) => {
|
||||
const matchesStatus = !statusFilter || String(item?.status || '').trim().toLowerCase() === statusFilter;
|
||||
if (!matchesStatus) {
|
||||
return false;
|
||||
}
|
||||
if (!searchText) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
item?.displayTitle,
|
||||
item?.archiveName,
|
||||
item?.label,
|
||||
item?.sourcePath,
|
||||
item?.jobId ? `job ${item.jobId}` : ''
|
||||
]
|
||||
.map((value) => normalizeSearchText(value))
|
||||
.join(' ');
|
||||
return haystack.includes(searchText);
|
||||
});
|
||||
}, [items, search, statusFilter]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getDownloads();
|
||||
setItems(Array.isArray(response?.items) ? response.items : []);
|
||||
setSummary(response?.summary && typeof response.summary === 'object' ? response.summary : null);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Downloads konnten nicht geladen werden',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveItems) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasActiveItems]);
|
||||
|
||||
const handleDownload = async (row) => {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
setDownloadBusyId(id);
|
||||
try {
|
||||
await api.downloadPreparedArchive(id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'ZIP-Download fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const label = row?.archiveName || `ZIP ${id}`;
|
||||
const confirmed = window.confirm(`"${label}" wirklich loeschen?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteBusyId(id);
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'ZIP geloescht',
|
||||
detail: `"${label}" wurde entfernt.`,
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Loeschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDeleteBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBody = (row) => {
|
||||
const meta = getStatusMeta(row?.status);
|
||||
return <Tag value={meta.label} severity={meta.severity} />;
|
||||
};
|
||||
|
||||
const titleBody = (row) => (
|
||||
<div className="downloads-title-cell">
|
||||
<strong>{row?.displayTitle || '-'}</strong>
|
||||
<small>
|
||||
{row?.jobId ? `Job #${row.jobId}` : 'Ohne Job'} | {row?.label || '-'}
|
||||
</small>
|
||||
{row?.errorMessage ? <small className="downloads-error-text">{row.errorMessage}</small> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const archiveBody = (row) => (
|
||||
<div className="downloads-path-cell">
|
||||
<code>{row?.archiveName || '-'}</code>
|
||||
<small>{row?.downloadDir || '-'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sourceBody = (row) => (
|
||||
<div className="downloads-path-cell">
|
||||
<code>{row?.sourcePath || '-'}</code>
|
||||
<small>{row?.sourceType === 'file' ? 'Datei' : 'Ordner'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const actionBody = (row) => {
|
||||
const normalizedStatus = String(row?.status || '').trim().toLowerCase();
|
||||
const canDownload = normalizedStatus === 'ready';
|
||||
const canDelete = !['queued', 'processing'].includes(normalizedStatus);
|
||||
const id = String(row?.id || '').trim();
|
||||
|
||||
return (
|
||||
<div className="downloads-actions">
|
||||
<Button
|
||||
label="Download"
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
onClick={() => handleDownload(row)}
|
||||
disabled={!canDownload || Boolean(deleteBusyId)}
|
||||
loading={downloadBusyId === id}
|
||||
/>
|
||||
<Button
|
||||
label="Loeschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => handleDelete(row)}
|
||||
disabled={!canDelete || Boolean(downloadBusyId)}
|
||||
loading={deleteBusyId === id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Downloadbare Dateien" subTitle="Vorbereitete ZIP-Dateien aus RAW- und Encode-Inhalten">
|
||||
<div className="table-filters">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel, ZIP-Datei oder Pfad"
|
||||
/>
|
||||
<Dropdown
|
||||
value={statusFilter}
|
||||
options={STATUS_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setStatusFilter(event.value || '')}
|
||||
placeholder="Status"
|
||||
/>
|
||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||
</div>
|
||||
|
||||
<div className="downloads-summary-tags">
|
||||
<Tag value={`${summary?.activeCount || 0} aktiv`} severity={(summary?.activeCount || 0) > 0 ? 'info' : 'secondary'} />
|
||||
<Tag value={`${summary?.readyCount || 0} bereit`} severity={(summary?.readyCount || 0) > 0 ? 'success' : 'secondary'} />
|
||||
<Tag value={`${summary?.failedCount || 0} Fehler`} severity={(summary?.failedCount || 0) > 0 ? 'danger' : 'secondary'} />
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-wide">
|
||||
<DataTable
|
||||
value={visibleItems}
|
||||
dataKey="id"
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[10, 20, 50]}
|
||||
loading={loading}
|
||||
responsiveLayout="scroll"
|
||||
emptyMessage="Keine ZIP-Dateien vorhanden"
|
||||
>
|
||||
<Column header="Status" body={statusBody} style={{ width: '10rem' }} />
|
||||
<Column header="Inhalt" body={titleBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="ZIP-Datei" body={archiveBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="Quelle" body={sourceBody} style={{ minWidth: '22rem' }} />
|
||||
<Column header="Erstellt" body={(row) => formatDateTime(row?.createdAt)} style={{ width: '11rem' }} />
|
||||
<Column header="Fertig" body={(row) => formatDateTime(row?.finishedAt)} style={{ width: '11rem' }} />
|
||||
<Column header="Groesse" body={(row) => formatBytes(row?.sizeBytes)} style={{ width: '9rem' }} />
|
||||
<Column header="Aktion" body={actionBody} style={{ width: '14rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataView, DataViewLayoutOptions } from 'primereact/dataview';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
@@ -6,6 +7,7 @@ import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { api } from '../api/client';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
@@ -22,6 +24,8 @@ const MEDIA_FILTER_OPTIONS = [
|
||||
{ label: 'Alle Medien', value: '' },
|
||||
{ label: 'Blu-ray', value: 'bluray' },
|
||||
{ label: 'DVD', value: 'dvd' },
|
||||
{ label: 'Audio CD', value: 'cd' },
|
||||
{ label: 'Audiobook', value: 'audiobook' },
|
||||
{ label: 'Sonstiges', value: 'other' }
|
||||
];
|
||||
|
||||
@@ -36,13 +40,30 @@ const SORT_OPTIONS = [
|
||||
{ label: 'Medium: Z -> A', value: '!sortMediaType' }
|
||||
];
|
||||
|
||||
const CD_FORMAT_LABELS = {
|
||||
flac: 'FLAC',
|
||||
wav: 'WAV',
|
||||
mp3: 'MP3',
|
||||
opus: 'Opus',
|
||||
ogg: 'Ogg Vorbis'
|
||||
};
|
||||
|
||||
function normalizePositiveInteger(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
|
||||
const candidates = [
|
||||
row?.mediaType,
|
||||
row?.media_type,
|
||||
row?.mediaProfile,
|
||||
row?.media_profile,
|
||||
row?.encodePlan?.mediaProfile,
|
||||
encodePlan?.mediaProfile,
|
||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
row?.makemkvInfo?.mediaProfile,
|
||||
row?.mediainfoInfo?.mediaProfile
|
||||
@@ -58,6 +79,37 @@ function resolveMediaType(row) {
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||
return 'audiobook';
|
||||
}
|
||||
}
|
||||
const statusCandidates = [
|
||||
row?.status,
|
||||
row?.last_state,
|
||||
row?.makemkvInfo?.lastState
|
||||
];
|
||||
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||
return 'cd';
|
||||
}
|
||||
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
||||
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (String(row?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
||||
return 'cd';
|
||||
}
|
||||
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
|
||||
return 'cd';
|
||||
}
|
||||
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(row?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
|
||||
return 'audiobook';
|
||||
}
|
||||
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
||||
return 'audiobook';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
@@ -80,6 +132,22 @@ function resolveMediaTypeMeta(row) {
|
||||
alt: 'DVD'
|
||||
};
|
||||
}
|
||||
if (mediaType === 'cd') {
|
||||
return {
|
||||
mediaType,
|
||||
icon: otherIndicatorIcon,
|
||||
label: 'Audio CD',
|
||||
alt: 'Audio CD'
|
||||
};
|
||||
}
|
||||
if (mediaType === 'audiobook') {
|
||||
return {
|
||||
mediaType,
|
||||
icon: otherIndicatorIcon,
|
||||
label: 'Audiobook',
|
||||
alt: 'Audiobook'
|
||||
};
|
||||
}
|
||||
return {
|
||||
mediaType,
|
||||
icon: otherIndicatorIcon,
|
||||
@@ -88,6 +156,128 @@ function resolveMediaTypeMeta(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDurationSeconds(totalSeconds) {
|
||||
const parsed = Number(totalSeconds);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Math.max(0, Math.trunc(parsed));
|
||||
const hours = Math.floor(rounded / 3600);
|
||||
const minutes = Math.floor((rounded % 3600) / 60);
|
||||
const seconds = rounded % 60;
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function resolveCdDetails(row) {
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
const tracksSource = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
|
||||
? makemkvInfo.tracks
|
||||
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
|
||||
const tracks = tracksSource
|
||||
.map((track) => {
|
||||
const position = normalizePositiveInteger(track?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...track,
|
||||
position,
|
||||
selected: track?.selected !== false
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const selectedTracksFromPlan = Array.isArray(encodePlan?.selectedTracks)
|
||||
? encodePlan.selectedTracks
|
||||
.map((value) => normalizePositiveInteger(value))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const selectedTrackPositions = selectedTracksFromPlan.length > 0
|
||||
? selectedTracksFromPlan
|
||||
: tracks.filter((track) => track.selected !== false).map((track) => track.position);
|
||||
const fallbackArtist = tracks
|
||||
.map((track) => String(track?.artist || '').trim())
|
||||
.find(Boolean) || null;
|
||||
const totalDurationSec = tracks.reduce((sum, track) => {
|
||||
const durationMs = Number(track?.durationMs);
|
||||
const durationSec = Number(track?.durationSec);
|
||||
if (Number.isFinite(durationMs) && durationMs > 0) {
|
||||
return sum + (durationMs / 1000);
|
||||
}
|
||||
if (Number.isFinite(durationSec) && durationSec > 0) {
|
||||
return sum + durationSec;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const format = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const mbId = String(
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.mbid
|
||||
|| ''
|
||||
).trim() || null;
|
||||
|
||||
return {
|
||||
artist: String(selectedMetadata?.artist || '').trim() || fallbackArtist || null,
|
||||
trackCount: tracks.length,
|
||||
selectedTrackCount: selectedTrackPositions.length,
|
||||
format,
|
||||
formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null,
|
||||
totalDurationLabel: formatDurationSeconds(totalDurationSec),
|
||||
mbId
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const mediaType = resolveMediaType(row);
|
||||
if (mediaType === 'cd') {
|
||||
return 'Audio-Dateien';
|
||||
}
|
||||
if (mediaType === 'audiobook') {
|
||||
return 'Audiobook-Datei(en)';
|
||||
}
|
||||
return 'Movie-Datei(en)';
|
||||
}
|
||||
|
||||
function getOutputShortLabelForRow(row) {
|
||||
const mediaType = resolveMediaType(row);
|
||||
if (mediaType === 'cd') {
|
||||
return 'Audio';
|
||||
}
|
||||
if (mediaType === 'audiobook') {
|
||||
return 'Audiobook';
|
||||
}
|
||||
return 'Movie';
|
||||
}
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
@@ -157,7 +347,9 @@ function formatDateTime(value) {
|
||||
});
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
@@ -173,6 +365,12 @@ export default function HistoryPage() {
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
|
||||
const [deleteEntryDialogVisible, setDeleteEntryDialogVisible] = useState(false);
|
||||
const [deleteEntryDialogRow, setDeleteEntryDialogRow] = useState(null);
|
||||
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
|
||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
const toastRef = useRef(null);
|
||||
@@ -240,7 +438,18 @@ export default function HistoryPage() {
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, status]);
|
||||
}, [search, status, refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const openJobId = Number(params.get('open') || 0);
|
||||
if (!openJobId) {
|
||||
return;
|
||||
}
|
||||
// URL-Parameter entfernen, dann Job-Modal öffnen
|
||||
navigate('/history', { replace: true });
|
||||
openDetail({ id: openJobId });
|
||||
}, [location.search]);
|
||||
|
||||
const onSortChange = (event) => {
|
||||
const value = String(event.value || '').trim();
|
||||
@@ -321,7 +530,9 @@ export default function HistoryPage() {
|
||||
};
|
||||
|
||||
const handleDeleteFiles = async (row, target) => {
|
||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie';
|
||||
const outputLabel = getOutputLabelForRow(row);
|
||||
const outputShortLabel = getOutputShortLabelForRow(row);
|
||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
|
||||
if (!confirmed) {
|
||||
@@ -335,7 +546,7 @@ export default function HistoryPage() {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Dateien gelöscht',
|
||||
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`,
|
||||
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, ${outputShortLabel}: ${summary.movie?.filesDeleted ?? 0}`,
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
@@ -347,6 +558,40 @@ export default function HistoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadArchive = async (row, target) => {
|
||||
const jobId = Number(row?.id || selectedJob?.id || 0);
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!jobId || !['raw', 'output'].includes(normalizedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadBusyTarget(normalizedTarget);
|
||||
try {
|
||||
const response = await api.requestJobArchive(jobId, normalizedTarget);
|
||||
const item = response?.item && typeof response.item === 'object' ? response.item : null;
|
||||
const label = normalizedTarget === 'raw' ? 'RAW' : 'Encode';
|
||||
const isReady = String(item?.status || '').trim().toLowerCase() === 'ready';
|
||||
const detail = isReady
|
||||
? `${label}-ZIP ist bereits auf der Downloads-Seite verfuegbar.`
|
||||
: `${label}-ZIP wird im Hintergrund erstellt und erscheint danach auf der Downloads-Seite.`;
|
||||
toastRef.current?.show({
|
||||
severity: isReady ? 'success' : 'info',
|
||||
summary: isReady ? 'ZIP bereit' : 'ZIP wird erstellt',
|
||||
detail,
|
||||
life: 4000
|
||||
});
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Download fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusyTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReencode = async (row) => {
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`);
|
||||
@@ -440,28 +685,132 @@ export default function HistoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (row) => {
|
||||
const handleRetry = async (row) => {
|
||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||
const confirmed = window.confirm(`Historieneintrag für "${title}" wirklich löschen?\nDateien werden NICHT gelöscht.`);
|
||||
const mediaType = resolveMediaType(row);
|
||||
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
|
||||
const confirmed = window.confirm(`${actionLabel} für "${title}" neu starten?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const response = await api.retryJob(row.id);
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId);
|
||||
toastRef.current?.show({
|
||||
severity: result.queued ? 'info' : 'success',
|
||||
summary: mediaType === 'cd' ? 'CD-Rip neu gestartet' : 'Retry gestartet',
|
||||
detail: result.queued
|
||||
? 'Job wurde in die Warteschlange eingeplant.'
|
||||
: (replacementJobId ? `Neuer Job #${replacementJobId} wurde erstellt.` : 'Job wurde neu gestartet.'),
|
||||
life: 4000
|
||||
});
|
||||
await load();
|
||||
if (replacementJobId) {
|
||||
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false });
|
||||
setSelectedJob(detailResponse.job);
|
||||
setDetailVisible(true);
|
||||
} else {
|
||||
await refreshDetailIfOpen(row.id);
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: mediaType === 'cd' ? 'CD-Rip Neustart fehlgeschlagen' : 'Retry fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteEntryDialog = () => {
|
||||
if (deleteEntryTargetBusy) {
|
||||
return;
|
||||
}
|
||||
setDeleteEntryDialogVisible(false);
|
||||
setDeleteEntryDialogRow(null);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
setDeleteEntryTargetBusy(null);
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
setDeleteEntryDialogRow(row);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryDialogVisible(true);
|
||||
setDeleteEntryPreviewLoading(true);
|
||||
setDeleteEntryBusy(true);
|
||||
try {
|
||||
await api.deleteJobEntry(row.id, 'none');
|
||||
const response = await api.getJobDeletePreview(jobId, { includeRelated: true });
|
||||
setDeleteEntryPreview(response?.preview || null);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschvorschau fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
setDeleteEntryDialogVisible(false);
|
||||
setDeleteEntryDialogRow(null);
|
||||
setDeleteEntryPreview(null);
|
||||
} finally {
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
setDeleteEntryBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteEntry = async (target) => {
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
|
||||
return;
|
||||
}
|
||||
const jobId = Number(deleteEntryDialogRow?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteEntryBusy(true);
|
||||
setDeleteEntryTargetBusy(normalizedTarget);
|
||||
try {
|
||||
const response = await api.deleteJobEntry(jobId, normalizedTarget, { includeRelated: true });
|
||||
const deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : [];
|
||||
const fileSummary = response?.fileSummary || {};
|
||||
const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0);
|
||||
const movieFiles = Number(fileSummary?.movie?.filesDeleted || 0);
|
||||
const rawDirs = Number(fileSummary?.raw?.dirsRemoved || 0);
|
||||
const movieDirs = Number(fileSummary?.movie?.dirsRemoved || 0);
|
||||
|
||||
const detail = normalizedTarget === 'none'
|
||||
? `${deletedJobIds.length || 1} Eintrag/Einträge entfernt (Dateien bleiben erhalten)`
|
||||
: `${deletedJobIds.length || 1} Eintrag/Einträge entfernt | RAW: ${rawFiles} Dateien, ${rawDirs} Ordner | ${deleteEntryOutputShortLabel}: ${movieFiles} Dateien, ${movieDirs} Ordner`;
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Eintrag gelöscht',
|
||||
detail: `"${title}" wurde aus der Historie entfernt.`,
|
||||
life: 3500
|
||||
summary: 'Historie gelöscht',
|
||||
detail,
|
||||
life: 5000
|
||||
});
|
||||
|
||||
closeDeleteEntryDialog();
|
||||
setDetailVisible(false);
|
||||
setSelectedJob(null);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
setDeleteEntryTargetBusy(null);
|
||||
setDeleteEntryBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -508,11 +857,12 @@ export default function HistoryPage() {
|
||||
};
|
||||
|
||||
const renderPoster = (row, className = 'history-dv-poster') => {
|
||||
const mediaMeta = resolveMediaTypeMeta(row);
|
||||
const title = row?.title || row?.detected_title || 'Poster';
|
||||
if (row?.poster_url && row.poster_url !== 'N/A') {
|
||||
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
|
||||
}
|
||||
return <div className="history-dv-poster-fallback">Kein Poster</div>;
|
||||
return <div className="history-dv-poster-fallback">{['cd', 'audiobook'].includes(mediaMeta.mediaType) ? 'Kein Cover' : 'Kein Poster'}</div>;
|
||||
};
|
||||
|
||||
const renderPresenceChip = (label, available) => (
|
||||
@@ -522,7 +872,65 @@ export default function HistoryPage() {
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderRatings = (row) => {
|
||||
const renderSupplementalInfo = (row) => {
|
||||
if (resolveMediaType(row) === 'cd') {
|
||||
const cdDetails = resolveCdDetails(row);
|
||||
const infoItems = [];
|
||||
if (cdDetails.trackCount > 0) {
|
||||
infoItems.push({
|
||||
key: 'tracks',
|
||||
label: 'Tracks',
|
||||
value: cdDetails.selectedTrackCount > 0 && cdDetails.selectedTrackCount !== cdDetails.trackCount
|
||||
? `${cdDetails.selectedTrackCount}/${cdDetails.trackCount}`
|
||||
: String(cdDetails.trackCount)
|
||||
});
|
||||
}
|
||||
if (cdDetails.formatLabel) {
|
||||
infoItems.push({ key: 'format', label: 'Format', value: cdDetails.formatLabel });
|
||||
}
|
||||
if (cdDetails.totalDurationLabel) {
|
||||
infoItems.push({ key: 'duration', label: 'Dauer', value: cdDetails.totalDurationLabel });
|
||||
}
|
||||
if (cdDetails.mbId) {
|
||||
infoItems.push({ key: 'mb', label: 'MusicBrainz', value: 'gesetzt' });
|
||||
}
|
||||
if (infoItems.length === 0) {
|
||||
return <span className="history-dv-subtle">Keine CD-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>
|
||||
));
|
||||
}
|
||||
|
||||
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);
|
||||
if (ratings.length === 0) {
|
||||
return <span className="history-dv-subtle">Keine Ratings</span>;
|
||||
@@ -545,6 +953,16 @@ export default function HistoryPage() {
|
||||
|
||||
const listItem = (row) => {
|
||||
const mediaMeta = resolveMediaTypeMeta(row);
|
||||
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||
const subtitle = isCdJob
|
||||
? [
|
||||
`#${row?.id || '-'}`,
|
||||
cdDetails?.artist || '-',
|
||||
row?.year || null,
|
||||
cdDetails?.mbId ? 'MusicBrainz' : null
|
||||
].filter(Boolean).join(' | ')
|
||||
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
|
||||
|
||||
return (
|
||||
<div className="col-12" key={row.id}>
|
||||
@@ -565,9 +983,7 @@ export default function HistoryPage() {
|
||||
<div className="history-dv-head">
|
||||
<div className="history-dv-title-block">
|
||||
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||
<small className="history-dv-subtle">
|
||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||
</small>
|
||||
<small className="history-dv-subtle">{subtitle}</small>
|
||||
</div>
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
@@ -582,12 +998,22 @@ export default function HistoryPage() {
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
{isCdJob ? (
|
||||
<>
|
||||
{renderPresenceChip('Audio', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Rip', Boolean(row?.ripSuccessful))}
|
||||
{renderPresenceChip('Metadaten', Boolean(cdDetails?.artist || cdDetails?.mbId))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-actions">
|
||||
@@ -608,6 +1034,16 @@ export default function HistoryPage() {
|
||||
|
||||
const gridItem = (row) => {
|
||||
const mediaMeta = resolveMediaTypeMeta(row);
|
||||
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||
const subtitle = isCdJob
|
||||
? [
|
||||
`#${row?.id || '-'}`,
|
||||
cdDetails?.artist || '-',
|
||||
row?.year || null,
|
||||
cdDetails?.mbId ? 'MusicBrainz' : null
|
||||
].filter(Boolean).join(' | ')
|
||||
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
|
||||
|
||||
return (
|
||||
<div className="col-12 md-col-6 xl-col-4" key={row.id}>
|
||||
@@ -630,9 +1066,7 @@ export default function HistoryPage() {
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
|
||||
<small className="history-dv-subtle">
|
||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||
</small>
|
||||
<small className="history-dv-subtle">{subtitle}</small>
|
||||
|
||||
<div className="history-dv-meta-row">
|
||||
<span className="job-step-cell">
|
||||
@@ -644,12 +1078,22 @@ export default function HistoryPage() {
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
{isCdJob ? (
|
||||
<>
|
||||
{renderPresenceChip('Audio', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Rip', Boolean(row?.ripSuccessful))}
|
||||
{renderPresenceChip('Metadaten', Boolean(cdDetails?.artist || cdDetails?.mbId))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-actions history-dv-actions-grid">
|
||||
@@ -675,12 +1119,21 @@ export default function HistoryPage() {
|
||||
return currentLayout === 'list' ? listItem(row) : gridItem(row);
|
||||
};
|
||||
|
||||
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
|
||||
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
|
||||
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
||||
const deleteEntryOutputShortLabel = getOutputShortLabelForRow(deleteEntryDialogRow);
|
||||
|
||||
const header = (
|
||||
<div className="history-dv-toolbar">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel oder IMDb"
|
||||
placeholder="Suche nach Titel, Interpret oder IMDb"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
@@ -748,19 +1201,154 @@ export default function HistoryPage() {
|
||||
onRestartEncode={handleRestartEncode}
|
||||
onRestartReview={handleRestartReview}
|
||||
onReencode={handleReencode}
|
||||
onRetry={handleRetry}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onDownloadArchive={handleDownloadArchive}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
actionBusy={actionBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusy}
|
||||
downloadBusyTarget={downloadBusyTarget}
|
||||
onHide={() => {
|
||||
setDetailVisible(false);
|
||||
setDetailLoading(false);
|
||||
setLogLoadingMode(null);
|
||||
setDownloadBusyTarget(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
header="Historien-Eintrag löschen"
|
||||
visible={deleteEntryDialogVisible}
|
||||
onHide={closeDeleteEntryDialog}
|
||||
style={{ width: '56rem', maxWidth: '96vw' }}
|
||||
className="history-delete-dialog"
|
||||
modal
|
||||
>
|
||||
<p>
|
||||
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
|
||||
</p>
|
||||
|
||||
{deleteEntryDialogRow ? (
|
||||
<small className="muted-inline">
|
||||
Job: {deleteEntryDialogRow?.title || deleteEntryDialogRow?.detected_title || `Job #${deleteEntryDialogRow?.id || '-'}`}
|
||||
</small>
|
||||
) : null}
|
||||
|
||||
{deleteEntryPreviewLoading ? (
|
||||
<p>Löschvorschau wird geladen ...</p>
|
||||
) : (
|
||||
<div className="history-delete-preview-grid">
|
||||
<div>
|
||||
<h4>Rip/Encode Historie</h4>
|
||||
{previewRelatedJobs.length > 0 ? (
|
||||
<ul className="history-delete-preview-list">
|
||||
{previewRelatedJobs.map((item) => (
|
||||
<li key={`delete-related-${item.id}`}>
|
||||
<strong>#{item.id}</strong> | {item.title || '-'} | {item.status || '-'} {item.isPrimary ? '(aktuell)' : '(Alt-Eintrag)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>RAW</h4>
|
||||
{previewRawPaths.length > 0 ? (() => {
|
||||
const display = previewRawPaths.filter(p => p.exists).length > 0
|
||||
? previewRawPaths.filter(p => p.exists)
|
||||
: previewRawPaths.slice(0, 1);
|
||||
return (
|
||||
<ul className="history-delete-preview-list">
|
||||
{display.map((item) => (
|
||||
<li key={`delete-raw-${item.path}`}>
|
||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||
</span>
|
||||
{' '}| {item.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})() : (
|
||||
<small className="history-dv-subtle">Keine RAW-Pfade.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{deleteEntryOutputShortLabel}</h4>
|
||||
{previewMoviePaths.length > 0 ? (() => {
|
||||
const display = previewMoviePaths.filter(p => p.exists).length > 0
|
||||
? previewMoviePaths.filter(p => p.exists)
|
||||
: previewMoviePaths.slice(0, 1);
|
||||
return (
|
||||
<ul className="history-delete-preview-list">
|
||||
{display.map((item) => (
|
||||
<li key={`delete-movie-${item.path}`}>
|
||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||
</span>
|
||||
{' '}| {item.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})() : (
|
||||
<small className="history-dv-subtle">Keine Movie-Pfade.</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<Button
|
||||
label="Nur RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('raw')}
|
||||
loading={deleteEntryTargetBusy === 'raw'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label={`Nur ${deleteEntryOutputShortLabel} löschen`}
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label="Beides löschen"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
onClick={() => confirmDeleteEntry('both')}
|
||||
loading={deleteEntryTargetBusy === 'both'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label="Nur Eintrag löschen"
|
||||
icon="pi pi-database"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('none')}
|
||||
loading={deleteEntryTargetBusy === 'none'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={closeDeleteEntryDialog}
|
||||
disabled={Boolean(deleteEntryTargetBusy)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { api } from '../api/client';
|
||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||
import CronJobsTab from '../components/CronJobsTab';
|
||||
|
||||
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||
|
||||
function buildValuesMap(categories) {
|
||||
const next = {};
|
||||
for (const category of categories || []) {
|
||||
@@ -28,6 +31,17 @@ function isSameValue(a, b) {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
function toBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function reorderListById(items, sourceId, targetIndex) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const normalizedSourceId = Number(sourceId);
|
||||
@@ -138,6 +152,7 @@ export default function SettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingPushover, setTestingPushover] = useState(false);
|
||||
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [draftValues, setDraftValues] = useState({});
|
||||
@@ -169,6 +184,9 @@ export default function SettingsPage() {
|
||||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||
const [chainDragSource, setChainDragSource] = useState(null);
|
||||
|
||||
// Activation Bytes state
|
||||
const [activationBytes, setActivationBytes] = useState([]);
|
||||
|
||||
// User presets state
|
||||
const [userPresets, setUserPresets] = useState([]);
|
||||
const [userPresetsLoading, setUserPresetsLoading] = useState(false);
|
||||
@@ -184,6 +202,7 @@ export default function SettingsPage() {
|
||||
});
|
||||
const [userPresetErrors, setUserPresetErrors] = useState({});
|
||||
const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]);
|
||||
const [effectivePaths, setEffectivePaths] = useState(null);
|
||||
|
||||
const toastRef = useRef(null);
|
||||
|
||||
@@ -317,6 +336,17 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadEffectivePaths = async ({ silent = false } = {}) => {
|
||||
try {
|
||||
const paths = await api.getEffectivePaths({ forceRefresh: true });
|
||||
setEffectivePaths(paths || null);
|
||||
} catch (_error) {
|
||||
if (!silent) {
|
||||
setEffectivePaths(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -327,6 +357,9 @@ export default function SettingsPage() {
|
||||
setInitialValues(values);
|
||||
setDraftValues(values);
|
||||
setErrors({});
|
||||
loadEffectivePaths({ silent: true });
|
||||
|
||||
api.getActivationBytes().then(r => setActivationBytes(Array.isArray(r?.entries) ? r.entries : [])).catch(() => {});
|
||||
|
||||
const presetsPromise = api.getHandBrakePresets();
|
||||
const scriptsPromise = api.getScripts();
|
||||
@@ -389,12 +422,41 @@ export default function SettingsPage() {
|
||||
}, [initialValues, draftValues]);
|
||||
|
||||
const hasUnsavedChanges = dirtyKeys.size > 0;
|
||||
const expertModeEnabled = toBoolean(draftValues?.[EXPERT_MODE_SETTING_KEY]);
|
||||
|
||||
const handleFieldChange = (key, value) => {
|
||||
setDraftValues((prev) => ({ ...prev, [key]: value }));
|
||||
setErrors((prev) => ({ ...prev, [key]: null }));
|
||||
};
|
||||
|
||||
const handleExpertModeToggle = async (checked) => {
|
||||
const previousDraftValue = draftValues?.[EXPERT_MODE_SETTING_KEY];
|
||||
const previousInitialValue = initialValues?.[EXPERT_MODE_SETTING_KEY];
|
||||
const nextValue = Boolean(checked);
|
||||
const currentValue = toBoolean(previousDraftValue);
|
||||
if (nextValue === currentValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingExpertMode(true);
|
||||
setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue }));
|
||||
setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue }));
|
||||
setErrors((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: null }));
|
||||
try {
|
||||
await api.updateSetting(EXPERT_MODE_SETTING_KEY, nextValue);
|
||||
} catch (error) {
|
||||
setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousDraftValue }));
|
||||
setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousInitialValue }));
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Expertenmodus',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setUpdatingExpertMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasUnsavedChanges) {
|
||||
toastRef.current?.show({
|
||||
@@ -415,6 +477,7 @@ export default function SettingsPage() {
|
||||
const response = await api.updateSettingsBulk(patch);
|
||||
setInitialValues((prev) => ({ ...prev, ...patch }));
|
||||
setErrors({});
|
||||
loadEffectivePaths({ silent: true });
|
||||
const reviewRefresh = response?.reviewRefresh || null;
|
||||
const reviewRefreshHint = reviewRefresh?.triggered
|
||||
? ' Mediainfo-Prüfung wird mit den neuen Settings automatisch neu berechnet.'
|
||||
@@ -946,7 +1009,7 @@ export default function SettingsPage() {
|
||||
icon="pi pi-save"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!hasUnsavedChanges}
|
||||
disabled={!hasUnsavedChanges || updatingExpertMode}
|
||||
/>
|
||||
<Button
|
||||
label="Änderungen verwerfen"
|
||||
@@ -954,7 +1017,7 @@ export default function SettingsPage() {
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleDiscard}
|
||||
disabled={!hasUnsavedChanges || saving}
|
||||
disabled={!hasUnsavedChanges || saving || updatingExpertMode}
|
||||
/>
|
||||
<Button
|
||||
label="Neu laden"
|
||||
@@ -962,7 +1025,7 @@ export default function SettingsPage() {
|
||||
severity="secondary"
|
||||
onClick={load}
|
||||
loading={loading}
|
||||
disabled={saving}
|
||||
disabled={saving || updatingExpertMode}
|
||||
/>
|
||||
<Button
|
||||
label="PushOver Test"
|
||||
@@ -970,8 +1033,16 @@ export default function SettingsPage() {
|
||||
severity="info"
|
||||
onClick={handlePushoverTest}
|
||||
loading={testingPushover}
|
||||
disabled={saving}
|
||||
disabled={saving || updatingExpertMode}
|
||||
/>
|
||||
<div className="settings-expert-toggle">
|
||||
<span>Expertenmodus</span>
|
||||
<InputSwitch
|
||||
checked={expertModeEnabled}
|
||||
onChange={(event) => handleExpertModeToggle(event.value)}
|
||||
disabled={loading || saving || updatingExpertMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -983,6 +1054,7 @@ export default function SettingsPage() {
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={handleFieldChange}
|
||||
effectivePaths={effectivePaths}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
@@ -1526,7 +1598,7 @@ export default function SettingsPage() {
|
||||
|
||||
<small>
|
||||
Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
|
||||
Sie sind medienbezogen (Blu-ray, DVD, Sonstiges oder Universell) und können vor dem Encode
|
||||
Sie sind medienbezogen (Blu-ray, DVD oder Universell) und können vor dem Encode
|
||||
in der Mediainfo-Prüfung ausgewählt werden. Kein Preset gewählt = Fallback aus Einstellungen.
|
||||
</small>
|
||||
|
||||
@@ -1544,7 +1616,6 @@ export default function SettingsPage() {
|
||||
<span className="preset-media-type-tag">
|
||||
{preset.mediaType === 'bluray' ? 'Blu-ray'
|
||||
: preset.mediaType === 'dvd' ? 'DVD'
|
||||
: preset.mediaType === 'other' ? 'Sonstiges'
|
||||
: 'Universell'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1604,7 +1675,6 @@ export default function SettingsPage() {
|
||||
<option value="all">Universell (alle Medien)</option>
|
||||
<option value="bluray">Blu-ray</option>
|
||||
<option value="dvd">DVD</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1673,6 +1743,33 @@ export default function SettingsPage() {
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</Card>
|
||||
|
||||
{activationBytes.length > 0 && (
|
||||
<Card
|
||||
title="Activation Bytes Cache"
|
||||
subTitle="Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt."
|
||||
style={{ marginTop: '1.5rem' }}
|
||||
>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--surface-border)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Checksum</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Activation Bytes</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Gespeichert am</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activationBytes.map((entry) => (
|
||||
<tr key={entry.checksum} style={{ borderBottom: '1px solid var(--surface-border)' }}>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{entry.checksum}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 'bold' }}>{entry.activation_bytes}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{new Date(entry.created_at).toLocaleString('de-DE')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,12 @@ const STATUS_LABELS = {
|
||||
POST_ENCODE_SCRIPTS: 'Nachbearbeitung',
|
||||
FINISHED: 'Fertig',
|
||||
CANCELLED: 'Abgebrochen',
|
||||
ERROR: 'Fehler'
|
||||
ERROR: 'Fehler',
|
||||
CD_ANALYZING: 'CD-Analyse',
|
||||
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
|
||||
CD_READY_TO_RIP: 'CD bereit zum Rippen',
|
||||
CD_RIPPING: 'CD rippen',
|
||||
CD_ENCODING: 'CD encodieren'
|
||||
};
|
||||
|
||||
const PROCESS_STATUS_LABELS = {
|
||||
@@ -46,6 +51,8 @@ export function getStatusSeverity(status, options = {}) {
|
||||
if (normalized === 'ERROR') return 'danger';
|
||||
if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info';
|
||||
if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning';
|
||||
if (normalized === 'CD_READY_TO_RIP') return 'info';
|
||||
if (normalized === 'CD_METADATA_SELECTION') return 'warning';
|
||||
if (
|
||||
normalized === 'RIPPING'
|
||||
|| normalized === 'ENCODING'
|
||||
@@ -53,6 +60,9 @@ export function getStatusSeverity(status, options = {}) {
|
||||
|| normalized === 'MEDIAINFO_CHECK'
|
||||
|| normalized === 'METADATA_SELECTION'
|
||||
|| normalized === 'POST_ENCODE_SCRIPTS'
|
||||
|| normalized === 'CD_ANALYZING'
|
||||
|| normalized === 'CD_RIPPING'
|
||||
|| normalized === 'CD_ENCODING'
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
@@ -69,6 +79,11 @@ export const STATUS_FILTER_OPTIONS = [
|
||||
{ label: getStatusLabel('FINISHED'), value: 'FINISHED' },
|
||||
{ label: getStatusLabel('CANCELLED'), value: 'CANCELLED' },
|
||||
{ label: getStatusLabel('ERROR'), value: 'ERROR' },
|
||||
{ label: getStatusLabel('CD_METADATA_SELECTION'), value: 'CD_METADATA_SELECTION' },
|
||||
{ label: getStatusLabel('CD_READY_TO_RIP'), value: 'CD_READY_TO_RIP' },
|
||||
{ label: getStatusLabel('CD_ANALYZING'), value: 'CD_ANALYZING' },
|
||||
{ label: getStatusLabel('CD_RIPPING'), value: 'CD_RIPPING' },
|
||||
{ label: getStatusLabel('CD_ENCODING'), value: 'CD_ENCODING' },
|
||||
{ label: getStatusLabel('WAITING_FOR_USER_DECISION'), value: 'WAITING_FOR_USER_DECISION' },
|
||||
{ label: getStatusLabel('READY_TO_START'), value: 'READY_TO_START' },
|
||||
{ label: getStatusLabel('READY_TO_ENCODE'), value: 'READY_TO_ENCODE' },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const appPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
const publicOrigin = (process.env.VITE_PUBLIC_ORIGIN || '').trim();
|
||||
const parsedAllowedHosts = (process.env.VITE_ALLOWED_HOSTS || '')
|
||||
.split(',')
|
||||
@@ -24,6 +26,9 @@ if (publicOrigin) {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appPackage.version)
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
|
||||
1035
install-dev.sh
Normal file
1035
install-dev.sh
Normal file
File diff suppressed because it is too large
Load Diff
831
install.sh
Normal file
831
install.sh
Normal file
@@ -0,0 +1,831 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
nginx_replace_or_insert_directive() {
|
||||
local file="$1"
|
||||
local directive_regex="$2"
|
||||
local desired_line="$3"
|
||||
local anchor_regex="$4"
|
||||
local directive_sed_regex="${directive_regex//\//\\/}"
|
||||
local anchor_sed_regex="${anchor_regex//\//\\/}"
|
||||
local desired_sed_line="${desired_line//\//\\/}"
|
||||
|
||||
if grep -Eq "$directive_regex" "$file"; then
|
||||
sed -i -E "0,/$directive_sed_regex/s//${desired_sed_line}/" "$file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sed -i "/$anchor_sed_regex/a\\$desired_line" "$file"
|
||||
}
|
||||
|
||||
patch_existing_ripster_nginx_site() {
|
||||
local file="$1"
|
||||
local backup_file="${file}.bak-$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
[[ -f "$file" ]] || return 1
|
||||
|
||||
cp -a "$file" "$backup_file"
|
||||
info "Bestehende nginx-Konfiguration erkannt - ergänze Upload-/Proxy-Settings"
|
||||
info "Backup erstellt: $backup_file"
|
||||
|
||||
nginx_replace_or_insert_directive \
|
||||
"$file" \
|
||||
'^[[:space:]]*client_max_body_size[[:space:]]+[^;]+;' \
|
||||
' client_max_body_size 8G;' \
|
||||
'server_name .*;'
|
||||
|
||||
nginx_replace_or_insert_directive \
|
||||
"$file" \
|
||||
'^[[:space:]]*proxy_connect_timeout[[:space:]]+[^;]+;' \
|
||||
' proxy_connect_timeout 60s;' \
|
||||
'location /api/ {'
|
||||
|
||||
nginx_replace_or_insert_directive \
|
||||
"$file" \
|
||||
'^[[:space:]]*proxy_send_timeout[[:space:]]+[^;]+;' \
|
||||
' proxy_send_timeout 3600s;' \
|
||||
'location /api/ {'
|
||||
|
||||
nginx_replace_or_insert_directive \
|
||||
"$file" \
|
||||
'^[[:space:]]*proxy_read_timeout[[:space:]]+[^;]+;' \
|
||||
' proxy_read_timeout 3600s;' \
|
||||
'location /api/ {'
|
||||
|
||||
nginx_replace_or_insert_directive \
|
||||
"$file" \
|
||||
'^[[:space:]]*proxy_request_buffering[[:space:]]+[^;]+;' \
|
||||
' proxy_request_buffering off;' \
|
||||
'location /api/ {'
|
||||
}
|
||||
|
||||
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 \
|
||||
ffmpeg \
|
||||
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/downloads"
|
||||
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
|
||||
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
||||
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/downloads" \
|
||||
"$INSTALL_DIR/backend/data/logs"
|
||||
chmod -R 775 \
|
||||
"$INSTALL_DIR/backend/data/output" \
|
||||
"$INSTALL_DIR/backend/data/downloads" \
|
||||
"$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"
|
||||
|
||||
if [[ -f /etc/nginx/sites-available/ripster ]]; then
|
||||
patch_existing_ripster_nginx_site /etc/nginx/sites-available/ripster
|
||||
else
|
||||
cat > /etc/nginx/sites-available/ripster <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${FRONTEND_HOST} _;
|
||||
client_max_body_size 8G;
|
||||
|
||||
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 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
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 ""
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "1.0.0",
|
||||
"version": "0.10.2-5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "1.0.0",
|
||||
"version": "0.10.2-5",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "0.10.2-5",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
"dev:frontend": "npm run dev --prefix frontend",
|
||||
"start": "npm run start --prefix backend",
|
||||
"build:frontend": "npm run build --prefix frontend"
|
||||
"build:frontend": "npm run build --prefix frontend",
|
||||
"release:interactive": "bash ./scripts/release.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
|
||||
154
setup.sh
Normal file
154
setup.sh
Normal file
@@ -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