1.0.0 release snapshot
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
name: Deploy Docs to GitHub Pages
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Documentation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Für git-dates Plugin
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Cache pip packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: mkdocs-material-${{ runner.os }}-${{ hashFiles('requirements-docs.txt') }}
|
||||
path: ~/.cache/pip
|
||||
restore-keys: |
|
||||
mkdocs-material-${{ runner.os }}-
|
||||
|
||||
- name: Install MkDocs and dependencies
|
||||
run: pip install -r requirements-docs.txt
|
||||
|
||||
- name: Build MkDocs site (main -> /, dev -> /dev)
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_ROOT="${GITHUB_WORKSPACE:-$PWD}/site"
|
||||
WORKTREE_ROOT="${RUNNER_TEMP:-$PWD}/mkdocs-worktrees"
|
||||
|
||||
has_ref() {
|
||||
git rev-parse --verify --quiet "$1" >/dev/null
|
||||
}
|
||||
|
||||
# Fetch refs separately (robust when one branch is not yet mirrored to GitHub).
|
||||
git fetch origin main || true
|
||||
git fetch origin dev || true
|
||||
|
||||
MAIN_REF="origin/main"
|
||||
DEV_REF="origin/dev"
|
||||
if [ "${REF_NAME}" = "main" ]; then
|
||||
MAIN_REF="${SHA}"
|
||||
fi
|
||||
if [ "${REF_NAME}" = "dev" ]; then
|
||||
DEV_REF="${SHA}"
|
||||
fi
|
||||
|
||||
if ! has_ref "${MAIN_REF}"; then
|
||||
echo "::error::Konnte Main-Ref '${MAIN_REF}' nicht auflösen. Main-Doku kann nicht gebaut werden."
|
||||
exit 1
|
||||
fi
|
||||
if ! has_ref "${DEV_REF}"; then
|
||||
echo "::warning::Dev-Ref '${DEV_REF}' nicht gefunden. Es wird ein Platzhalter unter /dev erzeugt."
|
||||
DEV_REF=""
|
||||
fi
|
||||
|
||||
# Build each branch in its own worktree and merge into one Pages artifact:
|
||||
# - main docs at site/
|
||||
# - dev docs at site/dev/
|
||||
rm -rf "${WORKTREE_ROOT}" "${SITE_ROOT}"
|
||||
mkdir -p "${WORKTREE_ROOT}"
|
||||
mkdir -p "${SITE_ROOT}"
|
||||
git worktree add --detach "${WORKTREE_ROOT}/main" "${MAIN_REF}"
|
||||
|
||||
if ! mkdocs build --strict --verbose \
|
||||
--config-file "${WORKTREE_ROOT}/main/mkdocs.yml" \
|
||||
--site-dir "${SITE_ROOT}"; then
|
||||
echo "::error::Main-Dokumentation konnte nicht gebaut werden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${DEV_REF}" ]; then
|
||||
git worktree add --detach "${WORKTREE_ROOT}/dev" "${DEV_REF}"
|
||||
if ! mkdocs build --strict --verbose \
|
||||
--config-file "${WORKTREE_ROOT}/dev/mkdocs.yml" \
|
||||
--site-dir "${SITE_ROOT}/dev"; then
|
||||
echo "::warning::Dev-Dokumentation konnte nicht gebaut werden. Es wird ein Platzhalter unter /dev erzeugt."
|
||||
rm -rf "${SITE_ROOT}/dev"
|
||||
mkdir -p "${SITE_ROOT}/dev"
|
||||
cat > "${SITE_ROOT}/dev/index.html" <<'EOF'
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>Dev Doku</title></head>
|
||||
<body><p>Dev-Dokumentation ist aktuell auf GitHub noch nicht verfügbar.</p></body>
|
||||
</html>
|
||||
EOF
|
||||
fi
|
||||
else
|
||||
mkdir -p "${SITE_ROOT}/dev"
|
||||
cat > "${SITE_ROOT}/dev/index.html" <<'EOF'
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>Dev Doku</title></head>
|
||||
<body><p>Dev-Dokumentation ist aktuell auf GitHub noch nicht verfügbar.</p></body>
|
||||
</html>
|
||||
EOF
|
||||
fi
|
||||
|
||||
- name: Verify Pages artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_ROOT="${GITHUB_WORKSPACE:-$PWD}/site"
|
||||
if [ ! -d "${SITE_ROOT}" ]; then
|
||||
echo "::error::site/ wurde nicht erzeugt."
|
||||
ls -la
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "${SITE_ROOT}/index.html" ]; then
|
||||
echo "::error::site/index.html fehlt."
|
||||
find "${SITE_ROOT}" -maxdepth 3 -type f | head -n 50 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ${{ github.workspace }}/site
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# ----------------------------
|
||||
# Dependencies
|
||||
# ----------------------------
|
||||
node_modules/
|
||||
backend/node_modules/
|
||||
frontend/node_modules/
|
||||
|
||||
# ----------------------------
|
||||
# Build artifacts / caches
|
||||
# ----------------------------
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
backend/dist/
|
||||
dist/
|
||||
build/
|
||||
.cache/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# ----------------------------
|
||||
# Runtime state / PIDs / temp
|
||||
# ----------------------------
|
||||
start.pid
|
||||
*.pid
|
||||
*.pid.lock
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# ----------------------------
|
||||
# Databases and generated data
|
||||
# ----------------------------
|
||||
backend/data/
|
||||
!backend/data/.gitkeep
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# ----------------------------
|
||||
# Logs/Debug
|
||||
# ----------------------------
|
||||
backend/logs/
|
||||
logs/
|
||||
*.log
|
||||
job*-hbscan.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
debug/
|
||||
DATABASE_BLA
|
||||
# ----------------------------
|
||||
# Env / secrets (keep examples)
|
||||
# ----------------------------
|
||||
.env
|
||||
.env.*
|
||||
backend/.env
|
||||
backend/.env.*
|
||||
frontend/.env
|
||||
frontend/.env.*
|
||||
!.env.example
|
||||
!.env.sample
|
||||
!backend/.env.example
|
||||
!backend/.env.sample
|
||||
!frontend/.env.example
|
||||
!frontend/.env.sample
|
||||
|
||||
# ----------------------------
|
||||
# IDE / OS files
|
||||
# ----------------------------
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# ----------------------------
|
||||
# Scripts
|
||||
# ----------------------------
|
||||
/scripts/
|
||||
/release.sh
|
||||
/Audible_Tool
|
||||
/AddOns
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
python3
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
python3
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
lib
|
||||
@@ -0,0 +1,5 @@
|
||||
home = /usr/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.12.3
|
||||
executable = /usr/bin/python3.12
|
||||
command = /usr/bin/python3 -m venv /home/michael/ripster/.venv-docs
|
||||
Submodule
+1
Submodule .worktrees/main added at 190f6fe1a5
@@ -1 +1,209 @@
|
||||
# ripster
|
||||
# Ripster
|
||||
|
||||

|
||||
|
||||
Ripster ist eine lokale Web-Anwendung für Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Das System kombiniert MakeMKV, HandBrake, FFmpeg, `cdparanoia`, `mkvmerge`, SQLite und eine React-Oberfläche zu einer durchgehenden Pipeline mit Queue, Historie, Downloads, Skript-Automation und Echtzeitstatus per WebSocket.
|
||||
|
||||
## ⚠️ Rechtlicher Hinweis
|
||||
|
||||
Ripster ist ausschließlich für die Sicherung von Titeln gedacht, die dir selbst gehören oder für die du die erforderlichen Nutzungsrechte besitzt. Bitte prüfe vor der Nutzung die in deinem Land geltenden Urheberrechts-, Privatkopie- und Kopierschutzregelungen. Die Software ist nicht für Piraterie oder die Verarbeitung unberechtigt beschaffter Inhalte gedacht.
|
||||
|
||||
## 🎬 Funktionsumfang
|
||||
|
||||
### 🎞️ Medien-Workflows
|
||||
- Blu-ray-Ripping und -Encoding
|
||||
- DVD-Ripping und -Encoding
|
||||
- Audio-CD-Ripping und -Encoding
|
||||
- Audiobook-Verarbeitung aus `.aax`
|
||||
- Datei-Converter für Audio-, Video- und ISO-Dateien
|
||||
|
||||
### 📀 Video (Blu-ray / DVD)
|
||||
- automatische Disc-Erkennung, Rescan einzelner Laufwerke und Live-Status im Ripper
|
||||
- Metadaten-Suche über TMDB für Filme und Serien
|
||||
- HandBrake-Review mit Playlist-/Titel-Auswahl, Audio-/Untertitel-Track-Auswahl und Encode-Vorschau
|
||||
- MakeMKV-Rip mit profilspezifischen Modi und Zusatzargumenten
|
||||
- HandBrake-Encoding mit User-Presets, offiziellen HandBrake-Presets und Extra-Args
|
||||
- Serien-Workflows für DVD- und Blu-ray-Discs inkl. Episoden-Zuordnung und Batch-Encoding
|
||||
- Multipart-Movie-Workflows über mehrere Discs inkl. Merge-Job via `mkvmerge`
|
||||
- Re-Encode aus RAW, Review-Neustart, Encode-Neustart, Retry und Resume laufender/unterbrochener Jobs
|
||||
|
||||
### 🎵 Audio-CD
|
||||
- TOC-Analyse und Rip mit `cdparanoia`
|
||||
- MusicBrainz-Suche und Übernahme von Album-/Track-Metadaten
|
||||
- Track-Auswahl und Ausgabe als FLAC, WAV, MP3, Opus oder Ogg Vorbis
|
||||
- RAW-Wiederverwendung und CD-Review-/Encode-Neustart aus vorhandenen Daten
|
||||
|
||||
### 🎧 Audiobooks
|
||||
|
||||
Diese Funktion ist für eine Archivierung von gekauften Titeln. Das System ermittelt keine Activation-Bytes und verweist dafür auf eine externe Seite
|
||||
|
||||
- Upload und Verarbeitung von Audible-/AAX-Dateien
|
||||
- Metadaten- und Kapitelanreicherung über Audnex plus lokale Probe-Daten
|
||||
- Ausgabe als M4B, MP3 oder FLAC
|
||||
- Einzeldatei oder kapitelweises Splitten
|
||||
|
||||
### 🔄 Converter (Beta)
|
||||
- Dateibaum und Datei-Explorer für das Converter-RAW-Verzeichnis
|
||||
- Upload, Ordner anlegen, Umbenennen, Verschieben und Löschen
|
||||
- Jobs aus Dateiauswahl erzeugen, Dateien bestehenden Jobs zuweisen oder daraus entfernen
|
||||
- Audio-/Video-Erkennung und automatische RAW-Scans per Polling
|
||||
- TMDB-Metadatenzuordnung für passende Video-Jobs
|
||||
|
||||
Der Converter ist noch nicht vollständig entwickelt und auch noch nicht vollständig geprüft. Er kann verwendet werden, es funktionieren aber ggf. nicht alle Funktionen vollständig!
|
||||
|
||||
### 🛠️ Automation, Betrieb und Verwaltung
|
||||
- Queue mit normalen Jobs sowie zusätzlichen `script`-, `chain`- und `wait`-Einträgen
|
||||
- Pre-/Post-Encode-Skripte und Skript-Ketten
|
||||
- Cron-Jobs für Skripte und Ketten inkl. Validierung, Logs und manueller Auslösung
|
||||
- Download-Queue für ZIP-Archive aus Historienjobs
|
||||
- Historie mit Detailansicht, Re-Encode, Review-/Encode-Neustart, Retry und Löschfunktionen
|
||||
- `/database`-Ansicht für Orphan-RAW-Ordner (Import oder Löschen vorhandener RAW-Daten)
|
||||
- Hardware-Monitoring mit Live-Werten und Verlaufshistorie
|
||||
- Pushover-Benachrichtigungen für zentrale Pipeline-Ereignisse
|
||||
- MakeMKV-Betakey-Prüfung/-Übernahme und Cover-Art-Recovery
|
||||
|
||||
## 🖥️ Oberfläche
|
||||
|
||||
- `Ripper`: Disc-Erkennung, Pipeline-Status, Queue, Review-Dialoge und aktive Jobs
|
||||
- `Converter`: Dateibaum, Uploads und Converter-Jobs
|
||||
- `Audiobooks`: AAX-Uploads und Audiobook-Jobs
|
||||
- `Settings`: Pfade, Tools, Templates, Queue, Monitoring, Notifications, Scripts, Chains, Presets
|
||||
- `Historie`: abgeschlossene und fehlerhafte Jobs mit Folgeaktionen
|
||||
- `Downloads`: vorbereitete ZIP-Downloads
|
||||
- `Database`: Orphan-RAW-Verwaltung (im Expertenmodus)
|
||||
- Zusatzansichten: `Hardware` und `TMDB Migration`
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
Der korrekte Bootstrap läuft über `setup.sh`. `setup.sh` lädt das passende `install.sh` aus dem gewünschten Branch und startet es mit denselben Parametern.
|
||||
|
||||
Unterstützte Systeme laut Installer:
|
||||
- Debian
|
||||
- Ubuntu
|
||||
|
||||
Standardinstallation:
|
||||
|
||||
```bash
|
||||
wget -qO setup.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
Es gibt 2 Branches: main und dev
|
||||
|
||||
main enthält das aktuelle stbale release
|
||||
dev enthält den aktuellen Stand der Weiterentwicklung
|
||||
|
||||
Hinweise:
|
||||
- `setup.sh` nutzt bei Bedarf `sudo`; root-Rechte und Internetzugang sind erforderlich.
|
||||
- Ohne `--branch` bietet `setup.sh` lokal eine Branch-Auswahl an.
|
||||
- Der eigentliche Installer ist `install.sh`; `setup.sh` ist der empfohlene Einstiegspunkt.
|
||||
|
||||
Wichtige Standardparameter für den Installationslauf:
|
||||
|
||||
Verfügbare Optionen:
|
||||
- `--branch <branch>`
|
||||
- `--dir <pfad>`
|
||||
- `--user <benutzer>`
|
||||
- `--port <port>`
|
||||
- `--host <hostname|ip>`
|
||||
- `--no-makemkv`
|
||||
- `--no-handbrake`
|
||||
- `--no-nginx`
|
||||
- `--no-system-deps`
|
||||
- `--reinstall`
|
||||
- `--help`
|
||||
|
||||
Was der Installer typischerweise einrichtet:
|
||||
- Node.js 20
|
||||
- `ffmpeg`, `ffprobe`, `mediainfo`, `mkvtoolnix`
|
||||
- CD-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
|
||||
- optional MakeMKV
|
||||
- optional HandBrakeCLI
|
||||
- optional nginx
|
||||
- Repository-Checkout bzw. Update
|
||||
- npm-Abhängigkeiten, Frontend-Build und `ripster-backend`-systemd-Service
|
||||
|
||||
## ♻️ Update
|
||||
|
||||
Standard-Update einer bestehenden Installation:
|
||||
|
||||
```bash
|
||||
sudo bash Pfad_zur_Installation/install.sh --reinstall
|
||||
```
|
||||
|
||||
Wenn die Installation mit abweichenden Kernparametern eingerichtet wurde, diese beim Update wieder mitgeben:
|
||||
|
||||
```bash
|
||||
sudo bash Pfad_zur_Installation/install.sh --reinstall --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10
|
||||
```
|
||||
|
||||
Alternativ kann auch erneut über `setup.sh` gebootstrapped werden:
|
||||
|
||||
```bash
|
||||
sudo bash Pfad_zur_Installation/setup.sh --reinstall
|
||||
```
|
||||
|
||||
`--reinstall` aktualisiert die Installation und behält die persistenten Daten der bestehenden Instanz bei.
|
||||
|
||||
## 🧪 Entwicklung
|
||||
|
||||
Schnellstart für lokale Entwicklung:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
`start.sh` prüft Node.js, installiert fehlende Abhängigkeiten und startet Backend und Frontend im Dev-Modus.
|
||||
|
||||
Wichtige npm-Skripte:
|
||||
- `npm run dev`
|
||||
- `npm run dev:backend`
|
||||
- `npm run dev:frontend`
|
||||
- `npm run build:frontend`
|
||||
- `npm run start`
|
||||
|
||||
## ⚙️ Konfiguration
|
||||
|
||||
Die meisten Einstellungen werden in der Weboberfläche unter `Settings` gepflegt und in SQLite gespeichert. Dazu gehören insbesondere:
|
||||
- Pfade und Output-Templates für Blu-ray, DVD, Serie, CD, Audiobook, Converter, Downloads und Logs
|
||||
- Tool-Kommandos für MakeMKV, HandBrake, MediaInfo, FFmpeg, FFprobe, `cdparanoia` und `mkvmerge`
|
||||
- Laufwerksmodus, Disc-Erkennung und Queue-/Parallelisierungsregeln
|
||||
- Hardware-Monitoring, Logging, Expert Mode und Cover-Art-Recovery
|
||||
- TMDB-Zugangsdaten, Pushover, Skripte, Skript-Ketten, User-Presets und Preset-Defaults
|
||||
|
||||
Zusätzliche Bootstrap-/Override-Variablen:
|
||||
|
||||
Backend:
|
||||
- `PORT`
|
||||
- `DB_PATH`
|
||||
- `LOG_DIR`
|
||||
- `LOG_LEVEL`
|
||||
- `CORS_ORIGIN`
|
||||
- `DEFAULT_TEMP_DIR`
|
||||
- `DEFAULT_RAW_DIR`
|
||||
- `DEFAULT_MOVIE_DIR`
|
||||
- `DEFAULT_SERIES_DIR`
|
||||
- `DEFAULT_CD_DIR`
|
||||
- `DEFAULT_AUDIOBOOK_RAW_DIR`
|
||||
- `DEFAULT_AUDIOBOOK_DIR`
|
||||
- `DEFAULT_DOWNLOAD_DIR`
|
||||
- `DEFAULT_CONVERTER_RAW_DIR`
|
||||
- `DEFAULT_CONVERTER_MOVIE_DIR`
|
||||
- `DEFAULT_CONVERTER_AUDIO_DIR`
|
||||
|
||||
Frontend:
|
||||
- `VITE_API_BASE`
|
||||
- `VITE_WS_URL`
|
||||
|
||||
## 🗂️ Daten, Logs und API
|
||||
|
||||
- Standard-DB: `backend/data/ripster.db`
|
||||
- Standard-Logs: `backend/logs`
|
||||
- Standard-Outputs liegen relativ zum Datenverzeichnis, sofern in den Settings nichts anderes gesetzt ist.
|
||||
- Das Schema wird beim Start geprüft und fehlende DB-Elemente werden migriert.
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
Ausführlichere Dokumentation liegt in `docs/` und veröffentlicht unter:
|
||||
|
||||
https://mboehmlaender.github.io/ripster/
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
PORT=3001
|
||||
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_SERIES_DIR=
|
||||
DEFAULT_CD_DIR=
|
||||
DEFAULT_DOWNLOAD_DIR=
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"watch": [
|
||||
"src",
|
||||
".env"
|
||||
],
|
||||
"ext": "js,json,env",
|
||||
"ignore": [
|
||||
"data/**",
|
||||
"logs/**"
|
||||
]
|
||||
}
|
||||
Generated
+3526
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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: resolvedDbPath,
|
||||
dataDir,
|
||||
tempDir: resolveOutputPath(process.env.DEFAULT_TEMP_DIR, 'temp'),
|
||||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||
defaultSeriesDir: resolveOutputPath(process.env.DEFAULT_SERIES_DIR, 'output', 'series'),
|
||||
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'),
|
||||
defaultConverterRawDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_RAW_DIR, 'output', 'converter-raw'),
|
||||
defaultConverterMovieDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_MOVIE_DIR, 'output', 'converted-movies'),
|
||||
defaultConverterAudioDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_AUDIO_DIR, 'output', 'converted-audio')
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { port, corsOrigin } = require('./config');
|
||||
const { initDatabase } = require('./db/database');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
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 converterRoutes = require('./routes/converterRoutes');
|
||||
const wsService = require('./services/websocketService');
|
||||
const pipelineService = require('./services/pipelineService');
|
||||
const settingsService = require('./services/settingsService');
|
||||
const converterScanService = require('./services/converterScanService');
|
||||
const cronService = require('./services/cronService');
|
||||
const downloadService = require('./services/downloadService');
|
||||
const diskDetectionService = require('./services/diskDetectionService');
|
||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
||||
const tempCleanupService = require('./services/tempCleanupService');
|
||||
const { fetchCurrentBetaKey } = require('./services/makemkvKeyService');
|
||||
const logger = require('./services/logger').child('BOOT');
|
||||
const { errorToMeta } = require('./utils/errorMeta');
|
||||
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||
|
||||
function getDelayUntilNextBetaKeyCheck(now = new Date()) {
|
||||
const next = new Date(now);
|
||||
next.setHours(0, 1, 0, 0);
|
||||
if (next.getTime() <= now.getTime()) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
return Math.max(1000, next.getTime() - now.getTime());
|
||||
}
|
||||
|
||||
async function start() {
|
||||
logger.info('backend:start:init');
|
||||
await initDatabase();
|
||||
try {
|
||||
const runtimeSettings = await settingsService.applyRuntimeSettings();
|
||||
logger.info('backend:runtime-settings:applied', runtimeSettings);
|
||||
} catch (error) {
|
||||
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) });
|
||||
}
|
||||
let betaKeyRefreshTimer = null;
|
||||
const scheduleBetaKeyRefresh = () => {
|
||||
clearTimeout(betaKeyRefreshTimer);
|
||||
const delayMs = getDelayUntilNextBetaKeyCheck();
|
||||
betaKeyRefreshTimer = setTimeout(runBetaKeyRefresh, delayMs);
|
||||
betaKeyRefreshTimer.unref?.();
|
||||
logger.info('beta-key:daily-check:scheduled', {
|
||||
delayMs,
|
||||
nextRunAt: new Date(Date.now() + delayMs).toISOString()
|
||||
});
|
||||
};
|
||||
const runBetaKeyRefresh = async () => {
|
||||
try {
|
||||
const result = await fetchCurrentBetaKey({ forceRefresh: true });
|
||||
logger.info('beta-key:daily-check:done', {
|
||||
sourceUrl: result?.sourceUrl || null,
|
||||
validUntil: result?.validUntil || null,
|
||||
fetchedAt: result?.fetchedAt || null
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('beta-key:daily-check:failed', { error: errorToMeta(error) });
|
||||
} finally {
|
||||
scheduleBetaKeyRefresh();
|
||||
}
|
||||
};
|
||||
scheduleBetaKeyRefresh();
|
||||
await tempCleanupService.init();
|
||||
await pipelineService.init();
|
||||
await converterScanService.startPolling();
|
||||
await cronService.init();
|
||||
await downloadService.init();
|
||||
await coverArtRecoveryService.init();
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: corsOrigin }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(requestLogger);
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ ok: true, now: new Date().toISOString() });
|
||||
});
|
||||
|
||||
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/converter', converterRoutes);
|
||||
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
const server = http.createServer(app);
|
||||
wsService.init(server);
|
||||
await hardwareMonitorService.init();
|
||||
|
||||
diskDetectionService.on('discInserted', (device) => {
|
||||
logger.info('disk:inserted:event', { device });
|
||||
pipelineService.onDiscInserted(device).catch((error) => {
|
||||
logger.error('pipeline:onDiscInserted:failed', { error: errorToMeta(error), device });
|
||||
wsService.broadcast('PIPELINE_ERROR', { message: error.message });
|
||||
});
|
||||
});
|
||||
|
||||
diskDetectionService.on('discRemoved', (device) => {
|
||||
logger.info('disk:removed:event', { device });
|
||||
pipelineService.onDiscRemoved(device).catch((error) => {
|
||||
logger.error('pipeline:onDiscRemoved:failed', { error: errorToMeta(error), device });
|
||||
wsService.broadcast('PIPELINE_ERROR', { message: error.message });
|
||||
});
|
||||
});
|
||||
|
||||
diskDetectionService.on('error', (error) => {
|
||||
logger.error('diskDetection:error:event', { error: errorToMeta(error) });
|
||||
wsService.broadcast('DISK_DETECTION_ERROR', { message: error.message });
|
||||
});
|
||||
|
||||
diskDetectionService.start();
|
||||
|
||||
server.listen(port, () => {
|
||||
logger.info('backend:listening', { port });
|
||||
// Bestehende Job-Bilder im Hintergrund migrieren (blockiert nicht den Start)
|
||||
migrateExistingThumbnails().catch(() => {});
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
logger.warn('backend:shutdown:received');
|
||||
converterScanService.stopPolling();
|
||||
diskDetectionService.stop();
|
||||
coverArtRecoveryService.stop();
|
||||
hardwareMonitorService.stop();
|
||||
cronService.stop();
|
||||
tempCleanupService.stop();
|
||||
clearTimeout(betaKeyRefreshTimer);
|
||||
server.close(() => {
|
||||
logger.warn('backend:shutdown:completed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('process:uncaughtException', { error: errorToMeta(error) });
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('process:unhandledRejection', {
|
||||
reason: reason instanceof Error ? errorToMeta(reason) : String(reason)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
logger.error('backend:start:failed', { error: errorToMeta(error) });
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = function asyncHandler(fn) {
|
||||
return function wrapped(req, res, next) {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
const logger = require('../services/logger').child('ERROR_HANDLER');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
module.exports = function errorHandler(error, req, res, next) {
|
||||
const statusCode = error.statusCode || 500;
|
||||
|
||||
logger.error('request:error', {
|
||||
reqId: req?.reqId,
|
||||
method: req?.method,
|
||||
url: req?.originalUrl,
|
||||
statusCode,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
message: error.message || 'Interner Fehler',
|
||||
statusCode,
|
||||
reqId: req?.reqId,
|
||||
details: Array.isArray(error.details) ? error.details : undefined
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
const { randomUUID } = require('crypto');
|
||||
const logger = require('../services/logger').child('HTTP');
|
||||
|
||||
function truncate(value, maxLen = 1500) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let str;
|
||||
if (typeof value === 'string') {
|
||||
str = value;
|
||||
} else {
|
||||
try {
|
||||
str = JSON.stringify(value);
|
||||
} catch (error) {
|
||||
str = '[unserializable-body]';
|
||||
}
|
||||
}
|
||||
if (str.length <= maxLen) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`;
|
||||
}
|
||||
|
||||
module.exports = function requestLogger(req, res, next) {
|
||||
const reqId = randomUUID();
|
||||
const startedAt = Date.now();
|
||||
let completionLogged = false;
|
||||
|
||||
req.reqId = reqId;
|
||||
|
||||
logger.info('request:start', {
|
||||
reqId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
query: req.query,
|
||||
body: truncate(req.body)
|
||||
});
|
||||
|
||||
const logCompletion = (kind) => {
|
||||
if (completionLogged) {
|
||||
return;
|
||||
}
|
||||
completionLogged = true;
|
||||
logger.info('request:end', {
|
||||
reqId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
completion: kind
|
||||
});
|
||||
};
|
||||
|
||||
res.on('finish', () => {
|
||||
logCompletion('finish');
|
||||
});
|
||||
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
logger.warn('request:aborted', {
|
||||
reqId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
return;
|
||||
}
|
||||
logCompletion('close');
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,460 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const audiobookService = require('../services/audiobookService');
|
||||
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||
const { ensureDir } = require('../utils/files');
|
||||
|
||||
/**
|
||||
* Source-Plugin für Audiobooks (AAX-Dateien).
|
||||
*
|
||||
* Audiobooks unterscheiden sich von Disc-Medien grundlegend:
|
||||
* - Kein Laufwerk — der Nutzer lädt eine .aax-Datei hoch
|
||||
* - Kein Rip-Schritt — die AAX-Datei ist der Input
|
||||
* - Encode mit ffmpeg (Gesamt oder kapitelweise)
|
||||
*
|
||||
* Deshalb:
|
||||
* rip() → No-Op (Datei liegt bereits im rawDir)
|
||||
* review() → null (kein HandBrake-Preview)
|
||||
* encode() → ffmpeg Single-File oder kapitelweise
|
||||
*
|
||||
* detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für
|
||||
* Datei-Uploads) oder eine Dateiendung im discInfo.
|
||||
*
|
||||
* Pflicht-Felder in ctx.extra für analyze():
|
||||
* filePath - Absoluter Pfad zur .aax-Datei
|
||||
*
|
||||
* Pflicht-Felder in ctx.extra für encode():
|
||||
* inputPath - Absoluter Pfad zur .aax-Input-Datei
|
||||
* outputPath - Absoluter Pfad für die Ausgabe (Datei oder Verzeichnis)
|
||||
* outputFormat - 'm4b' | 'mp3' | 'flac'
|
||||
* formatOptions - Encoder-Optionen { flacCompression, mp3Mode, mp3Bitrate, ... }
|
||||
* metadata - Metadata-Objekt aus analyze() oder encode_plan_json
|
||||
* activationBytes - AAX-Aktivierungsbytes (String, kann null sein bei nicht-DRM)
|
||||
* isSplitOutput - boolean: true = kapitelweise, false = Einzel-Datei
|
||||
* chapterPlan - { outputFiles, chapters } — nur benötigt wenn isSplitOutput = true
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*/
|
||||
class AudiobookPlugin extends SourcePlugin {
|
||||
get id() {
|
||||
return 'audiobook';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'Audiobook (AAX)';
|
||||
}
|
||||
|
||||
get priority() {
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Audiobooks.
|
||||
* Wird entweder via mediaProfile ('audiobook') oder Dateiendung erkannt.
|
||||
*/
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
if (profile === 'audiobook' || profile === 'audio_book') {
|
||||
return true;
|
||||
}
|
||||
const ext = path.extname(String(discInfo?.filePath || discInfo?.fileName || '')).trim().toLowerCase();
|
||||
return audiobookService.SUPPORTED_INPUT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert eine .aax-Datei mit ffprobe.
|
||||
* Gibt die extrahierten Metadaten zurück (Titel, Autor, Kapitel, Cover, etc.).
|
||||
*
|
||||
* @param {string} filePath - Pfad zur .aax-Datei (in ctx.extra.filePath ODER als devicePath)
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object>} Metadata-Objekt aus audiobookService.buildMetadataFromProbe()
|
||||
*/
|
||||
async analyze(filePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'AudiobookPlugin.js'
|
||||
});
|
||||
|
||||
const inputPath = ctx.extra?.filePath || filePath;
|
||||
|
||||
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||
const error = new Error(`AudiobookPlugin.analyze(): Datei nicht gefunden: ${inputPath}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const ffprobeCommand = String(settings.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||
const originalName = path.basename(inputPath);
|
||||
|
||||
ctx.logger.info('audiobook:analyze:start', { jobId: job?.id, inputPath, ffprobeCommand });
|
||||
|
||||
const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, inputPath);
|
||||
const captured = await _runCaptured(probeConfig.cmd, probeConfig.args, { jobId: job?.id });
|
||||
|
||||
const probe = audiobookService.parseProbeOutput(captured.stdout);
|
||||
if (!probe) {
|
||||
const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden (kein gültiges JSON).');
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const metadata = audiobookService.buildMetadataFromProbe(probe, originalName);
|
||||
|
||||
ctx.logger.info('audiobook:analyze:done', {
|
||||
jobId: job?.id,
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
chapterCount: metadata.chapters?.length || 0,
|
||||
durationMs: metadata.durationMs
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* No-Op: Audiobooks haben keinen Rip-Schritt.
|
||||
* Die .aax-Datei wurde bereits beim Upload ins rawDir verschoben.
|
||||
*/
|
||||
async rip(_job, _ctx) {
|
||||
_ctx.markExecution('rip', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'AudiobookPlugin.js'
|
||||
});
|
||||
|
||||
// Für Audiobooks gibt es keinen separaten Rip-Schritt
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodiert die .aax-Datei mit ffmpeg — als Einzel-Datei oder kapitelweise.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{outputPath: string, format: string, chapterCount: number}>}
|
||||
*/
|
||||
async encode(job, ctx) {
|
||||
ctx.markExecution('encode', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'AudiobookPlugin.js'
|
||||
});
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
if (!settings.audiobook_encoding_enabled) {
|
||||
ctx.logger.info('audiobook:encode:disabled', { jobId: job?.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputFormat = 'mp3',
|
||||
formatOptions = {},
|
||||
metadata = {},
|
||||
activationBytes = null,
|
||||
isSplitOutput = false,
|
||||
chapterPlan = null,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error('AudiobookPlugin.encode(): ctx.extra.inputPath fehlt');
|
||||
}
|
||||
if (!outputPath) {
|
||||
throw new Error('AudiobookPlugin.encode(): ctx.extra.outputPath fehlt');
|
||||
}
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
const error = new Error(`AudiobookPlugin.encode(): Input-Datei nicht gefunden: ${inputPath}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat);
|
||||
const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions);
|
||||
|
||||
ctx.logger.info('audiobook:encode:start', {
|
||||
jobId: job?.id,
|
||||
format: normalizedFormat,
|
||||
isSplitOutput,
|
||||
inputPath
|
||||
});
|
||||
|
||||
if (isSplitOutput) {
|
||||
await this._encodeChapters({
|
||||
job, ctx, ffmpegCommand, inputPath, chapterPlan,
|
||||
normalizedFormat, normalizedOptions, metadata, activationBytes,
|
||||
isCancelled, onProcessHandle
|
||||
});
|
||||
} else {
|
||||
await this._encodeSingleFile({
|
||||
job, ctx, ffmpegCommand, inputPath, outputPath,
|
||||
normalizedFormat, normalizedOptions, metadata, activationBytes,
|
||||
isCancelled, onProcessHandle
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info('audiobook:encode:done', {
|
||||
jobId: job?.id,
|
||||
format: normalizedFormat,
|
||||
outputPath
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = {
|
||||
outputPath,
|
||||
format: normalizedFormat,
|
||||
chapterCount: isSplitOutput
|
||||
? (chapterPlan?.outputFiles?.length || 0)
|
||||
: 1
|
||||
};
|
||||
|
||||
return ctx.extra.encodeResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audiobooks brauchen keine Encode-Review (kein HandBrake).
|
||||
*/
|
||||
async review(_job, _ctx) {
|
||||
_ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'AudiobookPlugin.js'
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-spezifische Settings für Audiobooks.
|
||||
*/
|
||||
getSettingsSchema() {
|
||||
return [
|
||||
{
|
||||
key: 'audiobook_encoding_enabled',
|
||||
category: 'Features',
|
||||
label: 'Audiobook Encoding',
|
||||
type: 'boolean',
|
||||
required: 1,
|
||||
description: 'Audiobook-Encoding über das Plugin aktivieren.',
|
||||
default_value: 'true',
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: 100
|
||||
},
|
||||
{
|
||||
key: 'ffmpeg_command',
|
||||
category: 'Tools',
|
||||
label: 'FFmpeg Kommando',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.',
|
||||
default_value: 'ffmpeg',
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 232
|
||||
},
|
||||
{
|
||||
key: 'ffprobe_command',
|
||||
category: 'Tools',
|
||||
label: 'FFprobe Kommando',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.',
|
||||
default_value: 'ffprobe',
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 233
|
||||
},
|
||||
{
|
||||
key: 'output_template_audiobook',
|
||||
category: 'Pfade',
|
||||
label: 'Output Template (Audiobook)',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner über "/".',
|
||||
default_value: audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 735
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private Encode-Methoden ──────────────────────────────────────────────────
|
||||
|
||||
async _encodeSingleFile({ job, ctx, ffmpegCommand, inputPath, outputPath, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
|
||||
ensureDir(path.dirname(outputPath));
|
||||
|
||||
const ffmpegConfig = audiobookService.buildEncodeCommand(
|
||||
ffmpegCommand,
|
||||
inputPath,
|
||||
outputPath,
|
||||
normalizedFormat,
|
||||
normalizedOptions,
|
||||
{ activationBytes, metadata }
|
||||
);
|
||||
|
||||
ctx.logger.info('audiobook:encode:single-file', {
|
||||
jobId: job?.id,
|
||||
cmd: ffmpegConfig.cmd,
|
||||
outputPath
|
||||
});
|
||||
|
||||
const progressParser = audiobookService.buildProgressParser(metadata?.durationMs || 0);
|
||||
await _spawnAndWait({
|
||||
cmd: ffmpegConfig.cmd,
|
||||
args: ffmpegConfig.args,
|
||||
jobId: job?.id,
|
||||
progressParser,
|
||||
onProgress: (pct) => ctx.emitProgress(Math.round(pct), 'Audiobook wird encodiert …'),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'AudiobookPlugin' }
|
||||
});
|
||||
}
|
||||
|
||||
async _encodeChapters({ job, ctx, ffmpegCommand, inputPath, chapterPlan, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
|
||||
const outputFiles = Array.isArray(chapterPlan?.outputFiles) ? chapterPlan.outputFiles : [];
|
||||
if (outputFiles.length === 0) {
|
||||
throw new Error('AudiobookPlugin: Keine Kapitel im chapterPlan für kapitelweise Ausgabe.');
|
||||
}
|
||||
|
||||
for (let index = 0; index < outputFiles.length; index++) {
|
||||
_assertNotCancelled(isCancelled);
|
||||
|
||||
const entry = outputFiles[index];
|
||||
const chapter = entry?.chapter || {};
|
||||
const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim();
|
||||
const startPct = (index / outputFiles.length) * 100;
|
||||
const endPct = ((index + 1) / outputFiles.length) * 100;
|
||||
|
||||
ensureDir(path.dirname(entry.outputPath));
|
||||
|
||||
const ffmpegConfig = audiobookService.buildChapterEncodeCommand(
|
||||
ffmpegCommand,
|
||||
inputPath,
|
||||
entry.outputPath,
|
||||
normalizedFormat,
|
||||
normalizedOptions,
|
||||
metadata,
|
||||
chapter,
|
||||
outputFiles.length,
|
||||
{ activationBytes }
|
||||
);
|
||||
|
||||
ctx.logger.info('audiobook:encode:chapter', {
|
||||
jobId: job?.id,
|
||||
chapterIndex: index + 1,
|
||||
chapterTotal: outputFiles.length,
|
||||
chapterTitle,
|
||||
outputPath: entry.outputPath
|
||||
});
|
||||
|
||||
const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
|
||||
const scaledParser = baseParser
|
||||
? (line) => {
|
||||
const progress = baseParser(line);
|
||||
if (!progress || progress.percent == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
percent: startPct + (endPct - startPct) * (progress.percent / 100),
|
||||
eta: null
|
||||
};
|
||||
}
|
||||
: null;
|
||||
|
||||
ctx.emitProgress(
|
||||
Math.round(startPct),
|
||||
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: ffmpegConfig.cmd,
|
||||
args: ffmpegConfig.args,
|
||||
jobId: job?.id,
|
||||
progressParser: scaledParser,
|
||||
onProgress: (pct) => ctx.emitProgress(
|
||||
Math.round(pct),
|
||||
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
|
||||
),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'AudiobookPlugin', chapterIndex: index + 1 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
|
||||
|
||||
function _assertNotCancelled(isCancelled) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function _runCaptured(cmd, args, _context = {}) {
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
return await execFileAsync(cmd, args, { timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
|
||||
} catch (error) {
|
||||
// execFile wirft bei non-zero exit; stdout/stderr können trotzdem valide sein
|
||||
return {
|
||||
stdout: String(error?.stdout || ''),
|
||||
stderr: String(error?.stderr || ''),
|
||||
exitCode: error?.code ?? 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
|
||||
_assertNotCancelled(isCancelled);
|
||||
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
onStdoutLine: () => {},
|
||||
onStderrLine: (line) => {
|
||||
if (progressParser && typeof onProgress === 'function') {
|
||||
const progress = progressParser(line);
|
||||
if (progress?.percent != null) {
|
||||
onProgress(progress.percent);
|
||||
}
|
||||
}
|
||||
},
|
||||
context: context || { jobId }
|
||||
});
|
||||
|
||||
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()) {
|
||||
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
cancelError.statusCode = 409;
|
||||
throw cancelError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AudiobookPlugin };
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
|
||||
|
||||
/**
|
||||
* Source-Plugin für Blu-ray Discs.
|
||||
*
|
||||
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
|
||||
* Überschreibt nur die Identifikations-Properties und detect().
|
||||
*
|
||||
* Erkennungsmerkmale:
|
||||
* - mediaProfile === 'bluray'
|
||||
* - Dateisystem: UDF (Versionen 2.5 / 2.6)
|
||||
* - Laufwerksmodell enthält 'blu-ray', 'bdrom', 'bd-r', 'bd-re'
|
||||
*/
|
||||
class BluRayPlugin extends VideoDiscPlugin {
|
||||
get id() {
|
||||
return 'bluray';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'Blu-ray';
|
||||
}
|
||||
|
||||
get mediaProfile() {
|
||||
return 'bluray';
|
||||
}
|
||||
|
||||
/**
|
||||
* Höhere Priorität als DVD (10 > 5) damit ein UDF-Laufwerk mit
|
||||
* blu-ray-Modell-Marker korrekt erkannt wird, auch wenn beide Plugins
|
||||
* theoretisch auf UDF-Discs matchen könnten.
|
||||
*/
|
||||
get priority() {
|
||||
return 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Blu-ray Discs.
|
||||
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
|
||||
*
|
||||
* @param {object} discInfo
|
||||
* @param {string} [discInfo.mediaProfile] - 'bluray'
|
||||
* @param {string} [discInfo.fstype] - 'udf'
|
||||
* @param {string} [discInfo.driveModel]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
if (profile === 'bluray' || profile === 'blu-ray' || profile === 'blu_ray') {
|
||||
return true;
|
||||
}
|
||||
// Wenn mediaProfile explizit auf einen anderen Medientyp gesetzt ist
|
||||
// (z.B. 'dvd' für eine DVD im Blu-ray-Laufwerk), dem DiskDetectionService
|
||||
// vertrauen und nicht via Modell-Marker überschreiben.
|
||||
if (profile) {
|
||||
return false;
|
||||
}
|
||||
// Fallback: nur wenn kein mediaProfile bekannt ist.
|
||||
// UDF-Dateisystem + Laufwerks-Modell enthält Blu-ray-Marker.
|
||||
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
|
||||
if (fstype === 'udf' && /(blu[\s-]?ray|bd[\s_-]?rom|\bbd-?r\b|\bbd-?re\b|\bbdr\b)/i.test(model)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { BluRayPlugin };
|
||||
@@ -0,0 +1,346 @@
|
||||
'use strict';
|
||||
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const cdRipService = require('../services/cdRipService');
|
||||
|
||||
/**
|
||||
* Source-Plugin für Audio-CDs.
|
||||
*
|
||||
* Delegiert die gesamte Arbeit an den bereits fertig ausgelagerten
|
||||
* cdRipService — dieser Plugin ist ein dünner Adapter-Wrapper.
|
||||
*
|
||||
* CD-Besonderheit: Rip und Encode sind bei CDs Track-für-Track
|
||||
* verzahnt (rip track → encode track → nächster Track).
|
||||
* Deshalb ist encode() ein No-Op; die gesamte Arbeit liegt in rip().
|
||||
*
|
||||
* Erwartete ctx.extra-Felder für rip():
|
||||
* ripConfig - Das ripConfig-Objekt aus dem API-Request (format, formatOptions,
|
||||
* selectedTracks, tracks, metadata, ...)
|
||||
* rawWavDir - Absoluter Pfad zum WAV-Temp-/RAW-Ordner
|
||||
* outputDir - Absoluter Pfad zum finalen Ausgabeordner
|
||||
* outputTemplate - CD-Output-Template String
|
||||
* isCancelled - () => boolean — Abbruchsignal vom Orchestrator
|
||||
* onProcessHandle - Callback mit dem Prozess-Handle (für Abbruch)
|
||||
*/
|
||||
class CdPlugin extends SourcePlugin {
|
||||
get id() {
|
||||
return 'cd';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'Audio CD';
|
||||
}
|
||||
|
||||
get priority() {
|
||||
return 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt Audio-CDs anhand des mediaProfile-Feldes, das vom
|
||||
* DiskDetectionService / diskDetectionService.inferMediaProfile() gesetzt wird.
|
||||
*
|
||||
* Akzeptierte Werte: 'cd', 'audio_cd'
|
||||
*/
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||
return profile === 'cd' || profile === 'audio_cd' || fstype === 'audio_cd';
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest das TOC der CD mit cdparanoia -Q und gibt die Trackliste zurück.
|
||||
*
|
||||
* @param {string} devicePath - z.B. '/dev/sr0'
|
||||
* @param {object} job - Job-Record (id wird für Logging genutzt)
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{tracks: Array, cdparanoiaCmd: string, detectedTitle: string}>}
|
||||
*/
|
||||
async analyze(devicePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'CdPlugin.js'
|
||||
});
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||
const detectedTitle = String(
|
||||
ctx.extra?.discInfo?.discLabel || ctx.extra?.discInfo?.label || ctx.extra?.discInfo?.model || 'Audio CD'
|
||||
).trim();
|
||||
|
||||
ctx.logger.info('cd:analyze:start', { devicePath, jobId: job?.id, detectedTitle });
|
||||
|
||||
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
|
||||
|
||||
if (!tracks.length) {
|
||||
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.logger.info('cd:analyze:done', { devicePath, jobId: job?.id, trackCount: tracks.length });
|
||||
|
||||
return {
|
||||
tracks,
|
||||
cdparanoiaCmd,
|
||||
detectedTitle
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rippt und encodiert alle ausgewählten Tracks der CD.
|
||||
*
|
||||
* Bei CDs sind Rip und Encode Track-für-Track verzahnt — alles
|
||||
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
|
||||
*
|
||||
* Pflicht-Felder in ctx.extra:
|
||||
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata, skipRip, skipEncode }
|
||||
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
|
||||
* outputDir - Finaler Ausgabeordner (optional bei skipEncode=true)
|
||||
* outputTemplate - Template-String für Dateinamen
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{outputDir, format, trackCount, encodeResults}>}
|
||||
*/
|
||||
async rip(job, ctx) {
|
||||
ctx.markExecution('rip', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'CdPlugin.js'
|
||||
});
|
||||
|
||||
const {
|
||||
ripConfig = {},
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
outputTemplate,
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
onProgress: onRawProgress,
|
||||
onLog: onExternalLog
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!rawWavDir) {
|
||||
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
|
||||
}
|
||||
const skipRip = Boolean(ripConfig?.skipRip);
|
||||
const skipEncode = Boolean(ripConfig?.skipEncode);
|
||||
|
||||
if (!skipEncode && !outputDir) {
|
||||
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
|
||||
}
|
||||
|
||||
const cdInfo = _safeParseJson(job?.makemkv_info_json) || {};
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||
const devicePath = String(job?.disc_device || '').trim();
|
||||
if (!devicePath && !skipRip) {
|
||||
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
|
||||
}
|
||||
|
||||
const format = String(ripConfig.format || 'flac').trim().toLowerCase();
|
||||
const formatOptions = ripConfig.formatOptions || {};
|
||||
const effectiveTemplate = outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
|
||||
|
||||
// Trackliste: TOC-Tracks aus DB, optional mit Metadaten aus ripConfig überschrieben
|
||||
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
|
||||
const incomingTracks = Array.isArray(ripConfig.tracks) ? ripConfig.tracks : [];
|
||||
const incomingByPos = new Map(
|
||||
incomingTracks
|
||||
.filter((t) => Number.isFinite(Number(t?.position)) && Number(t.position) > 0)
|
||||
.map((t) => [Math.trunc(Number(t.position)), t])
|
||||
);
|
||||
|
||||
const mergedTracks = tocTracks
|
||||
.map((track) => {
|
||||
const pos = Math.trunc(Number(track?.position));
|
||||
if (!Number.isFinite(pos) || pos <= 0) {
|
||||
return null;
|
||||
}
|
||||
const incoming = incomingByPos.get(pos) || null;
|
||||
return {
|
||||
...track,
|
||||
position: pos,
|
||||
title: incoming?.title || track.title || `Track ${pos}`,
|
||||
artist: incoming?.artist || track.artist || null,
|
||||
selected: incoming ? Boolean(incoming.selected) : (track.selected !== false)
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const selectedMeta = cdInfo.selectedMetadata || {};
|
||||
const incomingMeta = (ripConfig.metadata && typeof ripConfig.metadata === 'object')
|
||||
? ripConfig.metadata
|
||||
: {};
|
||||
const meta = {
|
||||
title: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD',
|
||||
artist: _normText(incomingMeta.artist) || _normText(selectedMeta.artist) || null,
|
||||
year: _normYear(incomingMeta.year) ?? _normYear(selectedMeta.year) ?? _normYear(job?.year) ?? null,
|
||||
album: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD'
|
||||
};
|
||||
|
||||
const rawSelectedPositions = Array.isArray(ripConfig.selectedTracks)
|
||||
? ripConfig.selectedTracks
|
||||
.map((v) => Math.trunc(Number(v)))
|
||||
.filter((v) => Number.isFinite(v) && v > 0)
|
||||
: [];
|
||||
const selectedTrackPositions = rawSelectedPositions.length > 0
|
||||
? rawSelectedPositions
|
||||
: mergedTracks.filter((t) => t.selected !== false).map((t) => t.position);
|
||||
|
||||
ctx.logger.info('cd:rip:start', {
|
||||
jobId: job?.id,
|
||||
devicePath: devicePath || null,
|
||||
skipRip,
|
||||
skipEncode,
|
||||
format,
|
||||
trackCount: selectedTrackPositions.length
|
||||
});
|
||||
|
||||
const result = await cdRipService.ripAndEncode({
|
||||
jobId: job?.id,
|
||||
devicePath: devicePath || null,
|
||||
cdparanoiaCmd,
|
||||
rawWavDir,
|
||||
outputDir,
|
||||
format,
|
||||
formatOptions,
|
||||
selectedTracks: selectedTrackPositions,
|
||||
tracks: mergedTracks,
|
||||
meta,
|
||||
outputTemplate: effectiveTemplate,
|
||||
skipRip,
|
||||
skipEncode,
|
||||
onProgress: (progressEvent) => {
|
||||
if (typeof onRawProgress === 'function') {
|
||||
onRawProgress(progressEvent);
|
||||
}
|
||||
const pct = Number(progressEvent?.percent ?? 0);
|
||||
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
|
||||
const trackIdx = progressEvent?.trackIndex || 1;
|
||||
const trackTotal = progressEvent?.trackTotal || 1;
|
||||
ctx.emitProgress(
|
||||
Math.round(pct),
|
||||
`${phase} Track ${trackIdx}/${trackTotal} …`
|
||||
);
|
||||
},
|
||||
onLog: (level, msg) => {
|
||||
if (ctx.logger[level]) {
|
||||
ctx.logger[level](msg, { jobId: job?.id });
|
||||
}
|
||||
if (typeof onExternalLog === 'function') {
|
||||
onExternalLog(level, msg);
|
||||
}
|
||||
},
|
||||
onProcessHandle,
|
||||
isCancelled,
|
||||
context: { jobId: job?.id, source: 'CdPlugin' }
|
||||
});
|
||||
|
||||
ctx.logger.info('cd:rip:done', {
|
||||
jobId: job?.id,
|
||||
format,
|
||||
trackCount: result.trackCount,
|
||||
outputDir: result.outputDir
|
||||
});
|
||||
|
||||
// Ergebnis in ctx.extra ablegen, damit der Orchestrator darauf zugreifen kann
|
||||
ctx.extra.ripResult = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* No-Op: Bei CDs ist der Encode-Schritt bereits in rip() integriert.
|
||||
*/
|
||||
async encode(_job, _ctx) {
|
||||
_ctx.markExecution('encode', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'CdPlugin.js'
|
||||
});
|
||||
|
||||
// CD Rip und Encode sind in rip() verzahnt — nichts zu tun
|
||||
}
|
||||
|
||||
/**
|
||||
* CDs brauchen keine Encode-Review (kein HandBrake).
|
||||
*/
|
||||
async review(_job, _ctx) {
|
||||
_ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'CdPlugin.js'
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-spezifische Settings für Audio CDs.
|
||||
*/
|
||||
getSettingsSchema() {
|
||||
return [
|
||||
{
|
||||
key: 'cdparanoia_command',
|
||||
category: 'Tools',
|
||||
label: 'cdparanoia Kommando',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: 'Pfad oder Befehl für cdparanoia. Wird für Audio-CD-Ripping genutzt.',
|
||||
default_value: 'cdparanoia',
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 240
|
||||
},
|
||||
{
|
||||
key: 'cd_output_template',
|
||||
category: 'Pfade',
|
||||
label: 'CD Output Template',
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: `Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {trackNr}, {title}. Unterordner über "/".`,
|
||||
default_value: cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 730
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen (privat, nicht exportiert) ────────────────────────────────
|
||||
|
||||
function _safeParseJson(value) {
|
||||
try {
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _normText(value) {
|
||||
const s = String(value == null ? '' : value)
|
||||
.normalize('NFC')
|
||||
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
|
||||
.replace(/\p{C}+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return s || null;
|
||||
}
|
||||
|
||||
function _normYear(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
module.exports = { CdPlugin };
|
||||
@@ -0,0 +1,660 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||
const { syncRegistrationKeyToConfig } = require('../services/makemkvKeyService');
|
||||
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files');
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
|
||||
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
|
||||
const ISO_EXTENSIONS = new Set(['iso']);
|
||||
|
||||
const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
|
||||
const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']);
|
||||
|
||||
/**
|
||||
* Erkennt den Medientyp anhand der Dateiendung.
|
||||
* @returns {'video'|'audio'|'iso'|null}
|
||||
*/
|
||||
function detectMediaTypeFromPath(filePath) {
|
||||
const ext = path.extname(String(filePath || '')).slice(1).toLowerCase();
|
||||
if (ISO_EXTENSIONS.has(ext)) return 'iso';
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
||||
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAudioExt(ext) {
|
||||
return AUDIO_EXTENSIONS.has(String(ext).toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Audio-Dateien in einem Verzeichnis auflisten (nicht rekursiv).
|
||||
*/
|
||||
function listAudioFilesInDir(dirPath) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isFile() && isAudioExt(path.extname(e.name).slice(1)))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg-Args zum Konvertieren einer einzelnen Audio-Datei.
|
||||
*/
|
||||
function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {}) {
|
||||
const args = ['-y', '-i', inputFile];
|
||||
|
||||
if (format === 'flac') {
|
||||
const level = Math.max(0, Math.min(12, Number(opts.flacCompression ?? 5)));
|
||||
args.push('-codec:a', 'flac', '-compression_level', String(level));
|
||||
} else if (format === 'mp3') {
|
||||
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
|
||||
args.push('-codec:a', 'libmp3lame');
|
||||
if (mode === 'vbr') {
|
||||
args.push('-q:a', String(Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)))));
|
||||
} else {
|
||||
args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`);
|
||||
}
|
||||
} else if (format === 'opus') {
|
||||
args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`);
|
||||
} else if (format === 'ogg') {
|
||||
args.push('-codec:a', 'libvorbis', '-q:a', String(Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)))));
|
||||
} else if (format === 'wav') {
|
||||
args.push('-codec:a', 'pcm_s16le');
|
||||
} else {
|
||||
args.push('-codec:a', 'copy');
|
||||
}
|
||||
|
||||
// Metadata tags
|
||||
const trackTitle = opts.trackTitle ? String(opts.trackTitle).trim() : null;
|
||||
const trackArtist = (opts.trackArtist || opts.albumArtist)
|
||||
? String(opts.trackArtist || opts.albumArtist).trim() : null;
|
||||
const albumTitle = opts.albumTitle ? String(opts.albumTitle).trim() : null;
|
||||
const albumYear = opts.albumYear ? String(opts.albumYear).trim() : null;
|
||||
const trackNumber = opts.trackNumber != null ? String(opts.trackNumber) : null;
|
||||
|
||||
if (trackTitle) args.push('-metadata', `title=${trackTitle}`);
|
||||
if (trackArtist) args.push('-metadata', `artist=${trackArtist}`);
|
||||
if (albumTitle) args.push('-metadata', `album=${albumTitle}`);
|
||||
if (albumYear) args.push('-metadata', `date=${albumYear}`);
|
||||
if (trackNumber) args.push('-metadata', `track=${trackNumber}`);
|
||||
|
||||
args.push(outputFile);
|
||||
return { cmd: ffmpegCmd, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-Plugin für den Converter.
|
||||
*
|
||||
* Unterstützte Eingaben:
|
||||
* - Video-Dateien (mkv, mp4, avi, mov, ...): HandBrake-Encode
|
||||
* - Audio-Dateien (flac, mp3, wav, ...): ffmpeg-Encode
|
||||
* - Audio-Ordner: alle enthaltenen Audio-Dateien mit ffmpeg
|
||||
* - ISO: MakeMKV-Rip → HandBrake-Encode
|
||||
*
|
||||
* Erkennung via mediaProfile === 'converter'.
|
||||
*
|
||||
* Erforderliche Felder in ctx.extra für encode():
|
||||
* inputPath - Absoluter Pfad zur Eingabedatei/-ordner (nach Rip: raw_path)
|
||||
* outputDir - Ausgabeverzeichnis (für Audio/ISO-Ordner) oder
|
||||
* outputPath - Ausgabedatei (für einzelne Video/Audio-Dateien)
|
||||
* encodePlan - aus encode_plan_json
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*/
|
||||
class ConverterPlugin extends SourcePlugin {
|
||||
get id() { return 'converter'; }
|
||||
get name() { return 'Converter'; }
|
||||
get priority() { return 3; }
|
||||
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
return profile === 'converter';
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert die Eingabedatei/-ordner.
|
||||
* - Video/ISO: HandBrake --scan für Track-Info
|
||||
* - Audio: ffprobe für Metadaten
|
||||
* - Audio-Ordner: Dateiliste + ffprobe auf erste Datei
|
||||
*/
|
||||
async analyze(inputPath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
|
||||
});
|
||||
|
||||
const actualInput = ctx.extra?.filePath || inputPath;
|
||||
const encodePlan = ctx.extra?.encodePlan || {};
|
||||
const converterMediaType = encodePlan.converterMediaType
|
||||
|| detectMediaTypeFromPath(actualInput);
|
||||
|
||||
ctx.logger.info('converter:analyze:start', {
|
||||
jobId: job?.id, inputPath: actualInput, converterMediaType
|
||||
});
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const ffprobeCmd = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||
const handbrakeCmd = String(settings?.handbrake_command || 'HandBrakeCLI').trim() || 'HandBrakeCLI';
|
||||
|
||||
let analysisResult = { converterMediaType, inputPath: actualInput };
|
||||
|
||||
if (converterMediaType === 'video' || converterMediaType === 'iso') {
|
||||
// HandBrake --scan für Track-Informationen
|
||||
try {
|
||||
const scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(actualInput);
|
||||
const captured = await this._runCaptured(scanConfig.cmd, scanConfig.args, { jobId: job?.id });
|
||||
const hbInfo = this._parseHandBrakeScan(captured.stdout);
|
||||
analysisResult.handbrakeInfo = hbInfo;
|
||||
ctx.logger.info('converter:analyze:handbrake-scan', {
|
||||
jobId: job?.id,
|
||||
titleCount: hbInfo?.TitleList?.length || 0
|
||||
});
|
||||
} catch (scanError) {
|
||||
ctx.logger.warn('converter:analyze:handbrake-scan-failed', {
|
||||
jobId: job?.id, error: scanError?.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (converterMediaType === 'audio') {
|
||||
const stat = fs.statSync(actualInput);
|
||||
if (stat.isDirectory()) {
|
||||
// Ordner mit Audio-Dateien
|
||||
const audioFiles = listAudioFilesInDir(actualInput);
|
||||
analysisResult.isFolder = true;
|
||||
analysisResult.audioFiles = audioFiles;
|
||||
// Erster Track: ffprobe für Metadaten
|
||||
if (audioFiles.length > 0) {
|
||||
const firstFile = path.join(actualInput, audioFiles[0]);
|
||||
try {
|
||||
const meta = await this._ffprobeMetadata(ffprobeCmd, firstFile, { jobId: job?.id });
|
||||
analysisResult.folderMeta = meta;
|
||||
} catch (_err) {
|
||||
// Metadaten optional
|
||||
}
|
||||
}
|
||||
ctx.logger.info('converter:analyze:audio-folder', {
|
||||
jobId: job?.id, fileCount: audioFiles.length
|
||||
});
|
||||
} else {
|
||||
// Einzelne Audio-Datei
|
||||
analysisResult.isFolder = false;
|
||||
try {
|
||||
const meta = await this._ffprobeMetadata(ffprobeCmd, actualInput, { jobId: job?.id });
|
||||
analysisResult.fileMeta = meta;
|
||||
} catch (_err) {
|
||||
// Metadaten optional
|
||||
}
|
||||
ctx.logger.info('converter:analyze:audio-file', { jobId: job?.id });
|
||||
}
|
||||
}
|
||||
|
||||
return analysisResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rip-Schritt:
|
||||
* - Video/Audio-Dateien: No-Op (Datei liegt bereits vor)
|
||||
* - ISO: MakeMKV backup → raw_path
|
||||
*/
|
||||
async rip(job, ctx) {
|
||||
ctx.markExecution('rip', {
|
||||
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
|
||||
});
|
||||
|
||||
const encodePlan = ctx.extra?.encodePlan || {};
|
||||
const converterMediaType = encodePlan.converterMediaType;
|
||||
|
||||
if (converterMediaType !== 'iso') {
|
||||
ctx.logger.info('converter:rip:no-op', { jobId: job?.id, converterMediaType });
|
||||
return;
|
||||
}
|
||||
|
||||
// ISO: MakeMKV backup
|
||||
const {
|
||||
inputPath,
|
||||
rawJobDir,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error('ConverterPlugin.rip(): ctx.extra.inputPath fehlt');
|
||||
}
|
||||
if (!rawJobDir) {
|
||||
throw new Error('ConverterPlugin.rip(): ctx.extra.rawJobDir fehlt');
|
||||
}
|
||||
|
||||
ensureDir(rawJobDir);
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
const makemkvCmd = String(settings?.makemkv_command || 'makemkvcon').trim() || 'makemkvcon';
|
||||
await syncRegistrationKeyToConfig(settings?.makemkv_registration_key || '');
|
||||
|
||||
const args = ['--noscan', '--decrypt', '-r', 'backup', `iso:${inputPath}`, rawJobDir];
|
||||
|
||||
ctx.logger.info('converter:rip:iso:start', {
|
||||
jobId: job?.id, inputPath, rawJobDir, cmd: makemkvCmd
|
||||
});
|
||||
ctx.emitProgress(0, 'ISO: MakeMKV Backup läuft …');
|
||||
|
||||
const runInfo = await _spawnAndWait({
|
||||
cmd: makemkvCmd, args,
|
||||
jobId: job?.id,
|
||||
progressParser: parseMakeMkvProgress,
|
||||
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.rip', stage: 'RIPPING' }
|
||||
});
|
||||
|
||||
ctx.logger.info('converter:rip:iso:done', {
|
||||
jobId: job?.id, exitCode: runInfo?.exitCode ?? null
|
||||
});
|
||||
|
||||
ctx.extra.ripRunInfo = runInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode-Schritt:
|
||||
* - Video: HandBrakeCLI
|
||||
* - Audio Einzeldatei: ffmpeg
|
||||
* - Audio Ordner: ffmpeg je Datei
|
||||
*/
|
||||
async encode(job, ctx) {
|
||||
ctx.markExecution('encode', {
|
||||
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
|
||||
});
|
||||
|
||||
const {
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputDir,
|
||||
encodePlan = {},
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
const converterMediaType = encodePlan.converterMediaType;
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error('ConverterPlugin.encode(): ctx.extra.inputPath fehlt');
|
||||
}
|
||||
|
||||
const settings = await ctx.settings.getSettingsMap();
|
||||
|
||||
if (converterMediaType === 'video' || converterMediaType === 'iso') {
|
||||
await this._encodeVideo({
|
||||
job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle
|
||||
});
|
||||
} else if (converterMediaType === 'audio') {
|
||||
await this._encodeAudio({
|
||||
job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle
|
||||
});
|
||||
} else {
|
||||
throw new Error(`ConverterPlugin.encode(): Unbekannter converterMediaType: ${converterMediaType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Video-Encode (HandBrake) ─────────────────────────────────────────────
|
||||
|
||||
async _encodeVideo({ job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle }) {
|
||||
if (!outputPath) {
|
||||
throw new Error('ConverterPlugin._encodeVideo(): outputPath fehlt');
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(outputPath));
|
||||
|
||||
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
|
||||
trackSelection: encodePlan.trackSelection || null,
|
||||
titleId: encodePlan.handBrakeTitleId || null,
|
||||
mediaProfile: null,
|
||||
settingsMap: settings,
|
||||
userPreset: encodePlan.userPreset || null
|
||||
});
|
||||
|
||||
ctx.logger.info('converter:encode:video:start', {
|
||||
jobId: job?.id, cmd: handBrakeConfig.cmd, titleId: encodePlan.handBrakeTitleId || null
|
||||
});
|
||||
ctx.emitProgress(0, 'Converter: Video Encoding läuft …');
|
||||
|
||||
const runInfo = await _spawnAndWait({
|
||||
cmd: handBrakeConfig.cmd, args: handBrakeConfig.args,
|
||||
jobId: job?.id,
|
||||
progressParser: parseHandBrakeProgress,
|
||||
onProgress: (pct, statusText, eta) => ctx.emitProgress(
|
||||
Math.round(pct),
|
||||
statusText || `Encoding ${pct}%`,
|
||||
eta ?? null
|
||||
),
|
||||
appendLogLine: ctx?.extra?.appendLogLine,
|
||||
logSource: 'HANDBRAKE',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
|
||||
ctx.logger.info('converter:encode:video:done', {
|
||||
jobId: job?.id, outputPath, exitCode: runInfo.exitCode
|
||||
});
|
||||
|
||||
ctx.extra.encodeRunInfo = runInfo;
|
||||
}
|
||||
|
||||
// ── Audio-Encode (ffmpeg) ────────────────────────────────────────────────
|
||||
|
||||
async _encodeAudio({ job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle }) {
|
||||
const ffmpegCmd = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
|
||||
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
|
||||
const formatOptions = encodePlan.audioFormatOptions || {};
|
||||
const isFolder = Boolean(encodePlan.isFolder);
|
||||
const isSharedAudio = Boolean(encodePlan.isSharedAudio)
|
||||
|| (Array.isArray(encodePlan.inputPaths) && encodePlan.inputPaths.length > 0 && !isFolder);
|
||||
|
||||
// Metadata from user config
|
||||
const planMetadata = encodePlan.metadata || {};
|
||||
const planTracks = Array.isArray(encodePlan.tracks) ? encodePlan.tracks : [];
|
||||
|
||||
if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) {
|
||||
throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`);
|
||||
}
|
||||
|
||||
/** Build per-track metadata opts merged with album-level metadata */
|
||||
function trackMetaOpts(position, defaultBaseName) {
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
return {
|
||||
...formatOptions,
|
||||
trackTitle: trackMeta.title || defaultBaseName || null,
|
||||
trackArtist: trackMeta.artist || planMetadata.albumArtist || null,
|
||||
albumTitle: planMetadata.albumTitle || null,
|
||||
albumArtist: planMetadata.albumArtist || null,
|
||||
albumYear: planMetadata.albumYear ? String(planMetadata.albumYear) : null,
|
||||
trackNumber: position
|
||||
};
|
||||
}
|
||||
|
||||
/** Build output filename with optional track number + title */
|
||||
function buildOutputFileName(position, trackMeta, defaultBaseName) {
|
||||
const numStr = String(position).padStart(2, '0');
|
||||
const title = trackMeta?.title ? sanitizeFileName(trackMeta.title) : null;
|
||||
const templateRaw = String(encodePlan.audioTrackTemplate || '').trim();
|
||||
if (templateRaw) {
|
||||
const rendered = renderTemplate(templateRaw, {
|
||||
trackNr: numStr,
|
||||
trackNumber: String(position),
|
||||
title: title || sanitizeFileName(defaultBaseName) || `Track ${numStr}`,
|
||||
artist: sanitizeFileName(trackMeta?.artist || planMetadata.albumArtist || '') || 'unknown',
|
||||
album: sanitizeFileName(planMetadata.albumTitle || '') || 'unknown',
|
||||
year: planMetadata.albumYear || 'unknown'
|
||||
});
|
||||
const sanitized = sanitizeFileName(rendered);
|
||||
if (sanitized) {
|
||||
return `${sanitized}.${outputFormat}`;
|
||||
}
|
||||
}
|
||||
if (title) return `${numStr} - ${title}.${outputFormat}`;
|
||||
return `${sanitizeFileName(defaultBaseName) || numStr}.${outputFormat}`;
|
||||
}
|
||||
|
||||
if (isSharedAudio) {
|
||||
// Freigegebene Audio-Dateien (mehrere Einzeldateien in einem Job)
|
||||
const inputPaths = encodePlan.inputPaths || [];
|
||||
if (inputPaths.length === 0) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): inputPaths leer für shared-audio-Job');
|
||||
}
|
||||
if (!outputDir) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für shared-audio-Job');
|
||||
}
|
||||
ensureDir(outputDir);
|
||||
|
||||
ctx.logger.info('converter:encode:audio:shared:start', {
|
||||
jobId: job?.id, fileCount: inputPaths.length, outputDir, format: outputFormat
|
||||
});
|
||||
|
||||
for (let i = 0; i < inputPaths.length; i++) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const inputFile = inputPaths[i];
|
||||
const fileName = path.basename(inputFile);
|
||||
const baseName = path.basename(fileName, path.extname(fileName));
|
||||
const position = i + 1;
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
const outputFileName = buildOutputFileName(position, trackMeta, baseName);
|
||||
const outputFile = path.join(outputDir, outputFileName);
|
||||
|
||||
ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
|
||||
ctx.emitProgress(
|
||||
Math.round((i / inputPaths.length) * 100),
|
||||
`Audio: ${position}/${inputPaths.length} — ${fileName}`
|
||||
);
|
||||
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputFile, outputFile, outputFormat,
|
||||
trackMetaOpts(position, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
jobId: job?.id,
|
||||
appendLogLine: ctx?.extra?.appendLogLine,
|
||||
logSource: 'FFMPEG',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info('converter:encode:audio:shared:done', {
|
||||
jobId: job?.id, fileCount: inputPaths.length, outputDir
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: inputPaths.length };
|
||||
} else if (isFolder) {
|
||||
// Ordner-Modus: alle Audio-Dateien im Ordner konvertieren
|
||||
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
|
||||
|
||||
if (!outputDir) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für Ordner-Job');
|
||||
}
|
||||
ensureDir(outputDir);
|
||||
|
||||
ctx.logger.info('converter:encode:audio:folder:start', {
|
||||
jobId: job?.id, inputPath, outputDir, format: outputFormat, fileCount: audioFiles.length
|
||||
});
|
||||
|
||||
for (let i = 0; i < audioFiles.length; i++) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const fileName = audioFiles[i];
|
||||
const inputFile = path.join(inputPath, fileName);
|
||||
const baseName = path.basename(fileName, path.extname(fileName));
|
||||
const position = i + 1;
|
||||
const trackMeta = planTracks.find((t) => t.position === position) || {};
|
||||
const outputFileName = buildOutputFileName(position, trackMeta, baseName);
|
||||
const outputFile = path.join(outputDir, outputFileName);
|
||||
|
||||
ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
|
||||
ctx.emitProgress(
|
||||
Math.round((i / audioFiles.length) * 100),
|
||||
`Audio: ${position}/${audioFiles.length} — ${fileName}`
|
||||
);
|
||||
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputFile, outputFile, outputFormat,
|
||||
trackMetaOpts(position, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
jobId: job?.id,
|
||||
appendLogLine: ctx?.extra?.appendLogLine,
|
||||
logSource: 'FFMPEG',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info('converter:encode:audio:folder:done', {
|
||||
jobId: job?.id, fileCount: audioFiles.length, outputDir
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: audioFiles.length };
|
||||
} else {
|
||||
// Einzelne Datei
|
||||
if (!outputPath) {
|
||||
throw new Error('ConverterPlugin._encodeAudio(): outputPath fehlt');
|
||||
}
|
||||
ensureDir(path.dirname(outputPath));
|
||||
|
||||
ctx.logger.info('converter:encode:audio:file:start', {
|
||||
jobId: job?.id, inputPath, outputPath, format: outputFormat
|
||||
});
|
||||
ctx.emitProgress(0, `Audio: Encoding läuft …`);
|
||||
|
||||
const baseName = path.basename(inputPath, path.extname(inputPath));
|
||||
const encodeConfig = buildAudioFfmpegArgs(
|
||||
ffmpegCmd, inputPath, outputPath, outputFormat,
|
||||
trackMetaOpts(1, baseName)
|
||||
);
|
||||
|
||||
await _spawnAndWait({
|
||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||
jobId: job?.id,
|
||||
appendLogLine: ctx?.extra?.appendLogLine,
|
||||
logSource: 'FFMPEG',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||
});
|
||||
|
||||
ctx.logger.info('converter:encode:audio:file:done', {
|
||||
jobId: job?.id, outputPath
|
||||
});
|
||||
|
||||
ctx.extra.encodeResult = { outputPath, format: outputFormat };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ───────────────────────────────────────────────────────
|
||||
|
||||
async _runCaptured(cmd, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { spawn } = require('child_process');
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
||||
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ exitCode: code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
_parseHandBrakeScan(rawOutput) {
|
||||
try {
|
||||
const jsonStart = rawOutput.indexOf('{');
|
||||
if (jsonStart < 0) return null;
|
||||
return JSON.parse(rawOutput.slice(jsonStart));
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _ffprobeMetadata(ffprobeCmd, filePath, options = {}) {
|
||||
const args = [
|
||||
'-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath
|
||||
];
|
||||
const result = await this._runCaptured(ffprobeCmd, args, options);
|
||||
try {
|
||||
return JSON.parse(result.stdout);
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getSettingsSchema() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function _spawnAndWait({
|
||||
cmd,
|
||||
args,
|
||||
jobId,
|
||||
progressParser,
|
||||
onProgress,
|
||||
appendLogLine,
|
||||
logSource = 'PROCESS',
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context
|
||||
}) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const handleLine = (stream, line) => {
|
||||
if (progressParser && typeof onProgress === 'function') {
|
||||
const progress = progressParser(line);
|
||||
if (progress?.percent != null) {
|
||||
onProgress(progress.percent, progress.detail || null, progress.eta ?? null);
|
||||
}
|
||||
}
|
||||
if (typeof appendLogLine === 'function') {
|
||||
try {
|
||||
appendLogLine(logSource, line, stream);
|
||||
} catch (_error) {
|
||||
// ignore log append errors in process stream
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
onStdoutLine: (line) => handleLine('stdout', line),
|
||||
onStderrLine: (line) => handleLine('stderr', line),
|
||||
context: context || { jobId }
|
||||
});
|
||||
|
||||
if (typeof onProcessHandle === 'function') {
|
||||
onProcessHandle(handle);
|
||||
}
|
||||
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
handle.cancel();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handle.promise;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
cancelError.statusCode = 409;
|
||||
throw cancelError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConverterPlugin };
|
||||
@@ -0,0 +1,66 @@
|
||||
'use strict';
|
||||
|
||||
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
|
||||
|
||||
/**
|
||||
* Source-Plugin für DVD-Discs.
|
||||
*
|
||||
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
|
||||
* Überschreibt nur die Identifikations-Properties und detect().
|
||||
*
|
||||
* Erkennungsmerkmale:
|
||||
* - mediaProfile === 'dvd'
|
||||
* - Dateisystem: UDF (1.02) oder ISO9660
|
||||
* - Laufwerksmodell enthält 'dvd', aber KEIN Blu-ray-Marker
|
||||
*/
|
||||
class DVDPlugin extends VideoDiscPlugin {
|
||||
get id() {
|
||||
return 'dvd';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'DVD';
|
||||
}
|
||||
|
||||
get mediaProfile() {
|
||||
return 'dvd';
|
||||
}
|
||||
|
||||
/**
|
||||
* Niedrigere Priorität als Blu-ray (5 < 10), da Blu-ray-Laufwerke
|
||||
* auch DVDs lesen können — BluRayPlugin soll für BD-Discs bevorzugt werden.
|
||||
*/
|
||||
get priority() {
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt DVD-Discs.
|
||||
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
|
||||
*
|
||||
* @param {object} discInfo
|
||||
* @param {string} [discInfo.mediaProfile] - 'dvd'
|
||||
* @param {string} [discInfo.fstype] - 'udf' | 'iso9660'
|
||||
* @param {string} [discInfo.driveModel]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
detect(discInfo) {
|
||||
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||
if (profile === 'dvd') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: ISO9660 oder UDF ohne Blu-ray-Modell-Marker
|
||||
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
|
||||
const hasBlurayMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r\b|bd-re\b)/i.test(model);
|
||||
if (hasBlurayMarker) {
|
||||
return false; // Blu-ray-Laufwerk → BluRayPlugin übernimmt
|
||||
}
|
||||
if (fstype === 'iso9660' || (fstype === 'udf' && /dvd/i.test(model))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DVDPlugin };
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const dvdSeriesScanService = require('../services/dvdSeriesScanService');
|
||||
const tmdbService = require('../services/tmdbService');
|
||||
|
||||
/**
|
||||
* Companion-Plugin für Serien-DVDs.
|
||||
*
|
||||
* Dieses Plugin ersetzt den bestehenden DVDPlugin-Flow nicht. Es wird in einem
|
||||
* späteren Schritt explizit aus dem DVD-Flow aufgerufen, sobald ein Prescan eine
|
||||
* serientypische Titelstruktur vermuten lässt.
|
||||
*/
|
||||
class DVDSeriesPlugin extends SourcePlugin {
|
||||
get id() {
|
||||
return 'dvd_series';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'DVD Series';
|
||||
}
|
||||
|
||||
detect() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async analyze(_devicePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'DVDSeriesPlugin.js'
|
||||
});
|
||||
|
||||
const fallbackSeriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
|
||||
{
|
||||
value: ctx.extra?.detectedTitle || '',
|
||||
source: 'detected_title'
|
||||
}
|
||||
]);
|
||||
|
||||
const scanText = String(ctx.extra?.scanText || '').trim();
|
||||
if (!scanText) {
|
||||
return {
|
||||
parsedScan: null,
|
||||
seriesAnalysis: null,
|
||||
seriesLookupHint: fallbackSeriesLookupHint,
|
||||
providerConfigured: await tmdbService.isConfigured()
|
||||
};
|
||||
}
|
||||
|
||||
const result = dvdSeriesScanService.analyzeHandBrakeScan(scanText, ctx.extra?.seriesOptions || {});
|
||||
const seriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
|
||||
{
|
||||
value: result?.parsed?.discTitle || '',
|
||||
source: 'handbrake_disc_title'
|
||||
},
|
||||
{
|
||||
value: ctx.extra?.detectedTitle || '',
|
||||
source: 'detected_title'
|
||||
}
|
||||
]) || fallbackSeriesLookupHint;
|
||||
return {
|
||||
parsedScan: result.parsed,
|
||||
seriesAnalysis: result.analysis,
|
||||
seriesLookupHint,
|
||||
providerConfigured: await tmdbService.isConfigured()
|
||||
};
|
||||
}
|
||||
|
||||
async review(_job, ctx) {
|
||||
ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'DVDSeriesPlugin.js'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async rip() {
|
||||
throw new Error('DVDSeriesPlugin.rip() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
|
||||
}
|
||||
|
||||
async encode() {
|
||||
throw new Error('DVDSeriesPlugin.encode() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
return tmdbService.searchSeries(query, options);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
return tmdbService.searchSeriesWithSeason(query, seasonNumber, options);
|
||||
}
|
||||
|
||||
getSettingsSchema() {
|
||||
return [
|
||||
{
|
||||
key: 'tmdb_api_read_access_token',
|
||||
category: 'Metadaten',
|
||||
label: 'TMDb Read Access Token',
|
||||
type: 'string',
|
||||
required: 0,
|
||||
description: 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).',
|
||||
default_value: null,
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: 401
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DVDSeriesPlugin };
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Abstrakte Basisklasse für Source-Plugins.
|
||||
* Jedes Plugin implementiert die Pipeline-Phasen für einen Medientyp.
|
||||
*
|
||||
* Lifecycle: detect() → analyze() → rip() → review() → encode() → finalize()
|
||||
*
|
||||
* Alle async-Methoden können einen Fehler werfen — der Orchestrator fängt
|
||||
* diese ab und schreibt sie als Job-Fehler in die Datenbank.
|
||||
*/
|
||||
class SourcePlugin {
|
||||
/**
|
||||
* Eindeutiger Bezeichner des Plugins.
|
||||
* Erlaubte Werte: 'bluray' | 'dvd' | 'cd' | 'audiobook' | eigener String
|
||||
* @returns {string}
|
||||
*/
|
||||
get id() {
|
||||
throw new Error(`${this.constructor.name}: id nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeigename des Plugins (für Logs und UI).
|
||||
* @returns {string}
|
||||
*/
|
||||
get name() {
|
||||
throw new Error(`${this.constructor.name}: name nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Priorität bei detect()-Konflikten. Höhere Zahl = höhere Priorität.
|
||||
* Standard: 0
|
||||
* @returns {number}
|
||||
*/
|
||||
get priority() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob dieses Plugin für die erkannte Disc/Datei zuständig ist.
|
||||
* Wird vom PluginRegistry.findPlugin() aufgerufen.
|
||||
*
|
||||
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
|
||||
* @param {string} [discInfo.devicePath]
|
||||
* @param {string} [discInfo.discType] - 'bluray' | 'dvd' | 'cd' | 'data'
|
||||
* @param {string} [discInfo.filesystem] - z.B. 'UDF', 'ISO9660', 'CDFS'
|
||||
* @param {string} [discInfo.driveModel]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
detect(discInfo) { // eslint-disable-line no-unused-vars
|
||||
throw new Error(`${this.constructor.name}: detect() nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert das Medium (z.B. makemkvcon info, cdparanoia -Q, ffprobe).
|
||||
* Gibt die Rohdaten zurück, die der Orchestrator im Job speichert.
|
||||
*
|
||||
* @param {string} devicePath - Gerätepfad, z.B. '/dev/sr0'
|
||||
* @param {object} job - Bestehender Job-Record aus der DB
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object>} Analyse-Ergebnis
|
||||
* Empfohlene Felder: { encodePlan, handbrakeInfo, rawDiscInfo, ... }
|
||||
*/
|
||||
async analyze(devicePath, job, ctx) { // eslint-disable-line no-unused-vars
|
||||
throw new Error(`${this.constructor.name}: analyze() nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rippt das Medium in den RAW-Ordner.
|
||||
* Fortschritt wird über ctx.emitProgress() gemeldet.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async rip(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
throw new Error(`${this.constructor.name}: rip() nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereitet die Encode-Review vor (MediaInfo-Analyse, Einstellungsvorschau).
|
||||
* Optional — Plugins, die keine Review benötigen, können die Basis-Implementierung
|
||||
* behalten (gibt null zurück, Orchestrator überspringt dann den Review-Schritt).
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object|null>} Review-Daten oder null
|
||||
*/
|
||||
async review(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodiert die gerippten Dateien (HandBrake, ffmpeg, etc.).
|
||||
* Fortschritt wird über ctx.emitProgress() gemeldet.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async encode(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
throw new Error(`${this.constructor.name}: encode() nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschlussarbeiten: Umbenennen, Aufräumen, Notifications, Post-Encode-Scripts usw.
|
||||
* Optional — Default-Implementierung tut nichts.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async finalize(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
// Default: kein Finalize-Schritt notwendig
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbruch-Handler: Laufende Prozesse beenden, temporäre Dateien aufräumen.
|
||||
* Wird vom Orchestrator bei cancel() aufgerufen.
|
||||
* Optional — Default-Implementierung tut nichts.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onCancel(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
// Default: nichts zu tun
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry-Handler: Zustand zurücksetzen vor einem erneuten Versuch.
|
||||
* Wird vom Orchestrator vor retry() aufgerufen.
|
||||
* Optional — Default-Implementierung tut nichts.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onRetry(job, ctx) { // eslint-disable-line no-unused-vars
|
||||
// Default: nichts zu tun
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-spezifische Settings-Schema-Einträge.
|
||||
* Diese werden vom PluginRegistry.getSettingsSchemas() aggregiert
|
||||
* und können zur Laufzeit in die DB eingetragen werden.
|
||||
*
|
||||
* Format je Eintrag:
|
||||
* { key, category, label, type, required, description,
|
||||
* default_value, options_json, validation_json, order_index }
|
||||
*
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getSettingsSchema() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SourcePlugin };
|
||||
@@ -0,0 +1,194 @@
|
||||
'use strict';
|
||||
|
||||
function normalizeExecutionStage(value) {
|
||||
const stage = String(value || '').trim().toLowerCase();
|
||||
return stage || 'unknown';
|
||||
}
|
||||
|
||||
function normalizePluginExecutionState(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const pluginId = String(value.pluginId || '').trim().toLowerCase() || 'unknown';
|
||||
const pluginName = String(value.pluginName || '').trim() || pluginId;
|
||||
const pluginFile = String(value.pluginFile || '').trim() || null;
|
||||
const markerSource = String(value.markerSource || '').trim().toLowerCase() || 'plugin-file';
|
||||
const firstMarkedAt = String(value.firstMarkedAt || '').trim() || null;
|
||||
const lastMarkedAt = String(value.lastMarkedAt || '').trim() || null;
|
||||
const rawLastStage = String(value.lastStage || '').trim();
|
||||
const lastStage = rawLastStage ? normalizeExecutionStage(rawLastStage) : null;
|
||||
const jobIdRaw = Number(value.jobId);
|
||||
const jobId = Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null;
|
||||
const byStageRaw = value.byStage && typeof value.byStage === 'object' && !Array.isArray(value.byStage)
|
||||
? value.byStage
|
||||
: {};
|
||||
const byStage = {};
|
||||
for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) {
|
||||
const normalizedStage = normalizeExecutionStage(stageKey);
|
||||
const count = Number(stageMeta?.count);
|
||||
byStage[normalizedStage] = {
|
||||
count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1,
|
||||
lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null
|
||||
};
|
||||
}
|
||||
const explicitStages = Array.isArray(value.stages)
|
||||
? value.stages.map((stage) => normalizeExecutionStage(stage)).filter(Boolean)
|
||||
: [];
|
||||
const stages = Array.from(new Set([
|
||||
...explicitStages,
|
||||
...Object.keys(byStage),
|
||||
...(lastStage ? [lastStage] : [])
|
||||
]));
|
||||
|
||||
return {
|
||||
markerSource,
|
||||
pluginId,
|
||||
pluginName,
|
||||
pluginFile,
|
||||
jobId,
|
||||
firstMarkedAt,
|
||||
lastMarkedAt,
|
||||
lastStage,
|
||||
stages,
|
||||
byStage
|
||||
};
|
||||
}
|
||||
|
||||
function mergePluginExecutionState(existingState, marker) {
|
||||
const existing = normalizePluginExecutionState(existingState);
|
||||
const normalizedMarker = marker && typeof marker === 'object' ? marker : null;
|
||||
if (!normalizedMarker) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const stage = normalizeExecutionStage(normalizedMarker.stage);
|
||||
const markedAt = String(normalizedMarker.markedAt || '').trim() || new Date().toISOString();
|
||||
const previousStageMeta = existing?.byStage?.[stage] || null;
|
||||
const nextStageCount = Number(previousStageMeta?.count || 0) + 1;
|
||||
const nextByStage = {
|
||||
...(existing?.byStage || {}),
|
||||
[stage]: {
|
||||
count: nextStageCount,
|
||||
lastMarkedAt: markedAt
|
||||
}
|
||||
};
|
||||
const nextStages = Array.from(new Set([
|
||||
...(Array.isArray(existing?.stages) ? existing.stages : []),
|
||||
stage
|
||||
]));
|
||||
|
||||
return {
|
||||
markerSource: 'plugin-file',
|
||||
pluginId: String(normalizedMarker.pluginId || existing?.pluginId || '').trim().toLowerCase() || 'unknown',
|
||||
pluginName: String(normalizedMarker.pluginName || existing?.pluginName || '').trim()
|
||||
|| String(normalizedMarker.pluginId || existing?.pluginId || '').trim()
|
||||
|| 'unknown',
|
||||
pluginFile: String(normalizedMarker.pluginFile || existing?.pluginFile || '').trim() || null,
|
||||
jobId: Number.isFinite(Number(normalizedMarker.jobId)) && Number(normalizedMarker.jobId) > 0
|
||||
? Math.trunc(Number(normalizedMarker.jobId))
|
||||
: (existing?.jobId || null),
|
||||
firstMarkedAt: existing?.firstMarkedAt || markedAt,
|
||||
lastMarkedAt: markedAt,
|
||||
lastStage: stage,
|
||||
stages: nextStages,
|
||||
byStage: nextByStage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontext-Objekt, das an alle Plugin-Methoden weitergegeben wird.
|
||||
* Kapselt den Zugriff auf alle Services, die ein Plugin benötigt,
|
||||
* ohne direkte Abhängigkeiten auf Singletons zu erzwingen.
|
||||
*
|
||||
* Wird vom PluginOrchestrator befüllt und ist read-only für Plugins.
|
||||
*/
|
||||
class PluginContext {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.settings - settingsService (mit get(), getAll() etc.)
|
||||
* @param {object} options.db - SQLite-Datenbankinstanz (getDb())
|
||||
* @param {object} options.logger - Logger-Instanz (child-Logger empfohlen)
|
||||
* @param {object} options.websocket - websocketService (broadcast() etc.)
|
||||
* @param {object} options.processRunner - processRunner (spawnTrackedProcess etc.)
|
||||
* @param {Function} options.emitProgress - (progress: number, statusText: string, eta?: number) => void
|
||||
* @param {Function} options.emitState - (newState: string, context?: object) => void
|
||||
* @param {Function} options.onPluginExecution - Callback für echte Plugin-Datei-Ausführung
|
||||
* @param {object} [options.extra] - Beliebige plugin-spezifische Extras
|
||||
*/
|
||||
constructor({
|
||||
settings,
|
||||
db,
|
||||
logger,
|
||||
websocket,
|
||||
processRunner,
|
||||
emitProgress,
|
||||
emitState,
|
||||
onPluginExecution,
|
||||
extra = {}
|
||||
} = {}) {
|
||||
this.settings = settings;
|
||||
this.db = db;
|
||||
this.logger = logger;
|
||||
this.websocket = websocket;
|
||||
this.processRunner = processRunner;
|
||||
this.emitProgress = typeof emitProgress === 'function' ? emitProgress : () => {};
|
||||
this.emitState = typeof emitState === 'function' ? emitState : () => {};
|
||||
this.onPluginExecution = typeof onPluginExecution === 'function' ? onPluginExecution : () => {};
|
||||
this.extra = extra;
|
||||
this.pluginExecution = null;
|
||||
}
|
||||
|
||||
markExecution(stage, payload = {}) {
|
||||
const jobIdRaw = Number(payload?.jobId ?? this.extra?.jobId ?? this.extra?.job?.id ?? 0);
|
||||
const marker = {
|
||||
markerSource: 'plugin-file',
|
||||
pluginId: String(payload?.pluginId || this.extra?.pluginId || '').trim().toLowerCase() || 'unknown',
|
||||
pluginName: String(payload?.pluginName || '').trim()
|
||||
|| String(payload?.pluginId || this.extra?.pluginId || '').trim()
|
||||
|| 'unknown',
|
||||
pluginFile: String(payload?.pluginFile || '').trim() || null,
|
||||
jobId: Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null,
|
||||
stage: normalizeExecutionStage(stage),
|
||||
markedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.pluginExecution = mergePluginExecutionState(this.pluginExecution, marker);
|
||||
|
||||
try {
|
||||
this.onPluginExecution(marker, this.getPluginExecution());
|
||||
} catch (error) {
|
||||
if (this.logger && typeof this.logger.warn === 'function') {
|
||||
this.logger.warn('plugin:execution:callback-failed', {
|
||||
pluginId: marker.pluginId,
|
||||
pluginFile: marker.pluginFile,
|
||||
stage: marker.stage,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getPluginExecution();
|
||||
}
|
||||
|
||||
getPluginExecution() {
|
||||
const normalized = normalizePluginExecutionState(this.pluginExecution);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
stages: [...normalized.stages],
|
||||
byStage: Object.fromEntries(
|
||||
Object.entries(normalized.byStage || {}).map(([stage, meta]) => [
|
||||
stage,
|
||||
{
|
||||
count: Number(meta?.count || 1),
|
||||
lastMarkedAt: meta?.lastMarkedAt || null
|
||||
}
|
||||
])
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginContext };
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const logger = require('../services/logger').child('PLUGIN-REGISTRY');
|
||||
|
||||
/**
|
||||
* Registry für alle Source-Plugins.
|
||||
* Plugins werden beim Start registriert und anhand von detect() ausgewählt.
|
||||
*
|
||||
* Verwendung:
|
||||
* const { registry } = require('./PluginRegistry');
|
||||
* registry.register(new BluRayPlugin());
|
||||
* const plugin = registry.findPlugin(discInfo); // → BluRayPlugin | null
|
||||
*/
|
||||
class PluginRegistry {
|
||||
constructor() {
|
||||
/** @type {Map<string, SourcePlugin>} */
|
||||
this._plugins = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert ein Plugin.
|
||||
* Wirft einen Fehler wenn das übergebene Objekt keine SourcePlugin-Instanz ist.
|
||||
* Überschreibt ein bereits vorhandenes Plugin mit gleicher ID (mit Warning).
|
||||
*
|
||||
* @param {SourcePlugin} plugin
|
||||
*/
|
||||
register(plugin) {
|
||||
if (!(plugin instanceof SourcePlugin)) {
|
||||
throw new Error(
|
||||
`Plugin muss eine Instanz von SourcePlugin sein: ${plugin?.constructor?.name || typeof plugin}`
|
||||
);
|
||||
}
|
||||
const id = plugin.id;
|
||||
if (!id || typeof id !== 'string') {
|
||||
throw new Error(`Plugin muss eine nicht-leere String-ID haben (got: ${JSON.stringify(id)})`);
|
||||
}
|
||||
if (this._plugins.has(id)) {
|
||||
logger.warn('registry:plugin-overwrite', { id, existingName: this._plugins.get(id).name });
|
||||
}
|
||||
this._plugins.set(id, plugin);
|
||||
logger.info('registry:plugin-registered', { id, name: plugin.name, priority: plugin.priority });
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet das passende Plugin für eine erkannte Disc.
|
||||
*
|
||||
* Alle Plugins werden nach priority (absteigend) sortiert. Das erste,
|
||||
* dessen detect() true zurückgibt, wird verwendet.
|
||||
* Fehler in detect() werden geloggt und ignoriert (Plugin übersprungen).
|
||||
*
|
||||
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
|
||||
* @returns {SourcePlugin|null} Passendes Plugin oder null wenn keines matched
|
||||
*/
|
||||
findPlugin(discInfo) {
|
||||
const sorted = [...this._plugins.values()]
|
||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||
|
||||
for (const plugin of sorted) {
|
||||
try {
|
||||
if (plugin.detect(discInfo)) {
|
||||
logger.debug('registry:plugin-matched', { id: plugin.id, discType: discInfo?.discType });
|
||||
return plugin;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('registry:detect-error', {
|
||||
id: plugin.id,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('registry:no-plugin-matched', { discType: discInfo?.discType, filesystem: discInfo?.filesystem });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt ein Plugin anhand seiner ID zurück.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {SourcePlugin|null}
|
||||
*/
|
||||
getPlugin(id) {
|
||||
return this._plugins.get(id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle registrierten Plugins zurück (Reihenfolge: Registrierungsreihenfolge).
|
||||
*
|
||||
* @returns {SourcePlugin[]}
|
||||
*/
|
||||
getAllPlugins() {
|
||||
return [...this._plugins.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregiert die Settings-Schemata aller registrierten Plugins.
|
||||
* Kann genutzt werden, um Plugin-Settings dynamisch in die DB einzutragen.
|
||||
*
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getSettingsSchemas() {
|
||||
const schemas = [];
|
||||
for (const plugin of this._plugins.values()) {
|
||||
try {
|
||||
const pluginSchemas = plugin.getSettingsSchema();
|
||||
if (Array.isArray(pluginSchemas) && pluginSchemas.length > 0) {
|
||||
schemas.push(...pluginSchemas);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('registry:schema-error', {
|
||||
id: plugin.id,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
return schemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl der registrierten Plugins zurück.
|
||||
* @returns {number}
|
||||
*/
|
||||
get size() {
|
||||
return this._plugins.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Modul-Level-Singleton — wird beim Start einmalig befüllt.
|
||||
const registry = new PluginRegistry();
|
||||
|
||||
module.exports = { PluginRegistry, registry };
|
||||
@@ -0,0 +1,544 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const tmdbService = require('../services/tmdbService');
|
||||
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir } = require('../utils/files');
|
||||
|
||||
/**
|
||||
* Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
|
||||
*
|
||||
* Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
|
||||
* analyze() → TMDb-Suche + Disc-Metadaten vorbereiten
|
||||
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
|
||||
* rip() → makemkvcon backup/mkv
|
||||
* encode() → HandBrakeCLI
|
||||
*
|
||||
* Subklassen müssen implementieren:
|
||||
* get id() → 'bluray' | 'dvd'
|
||||
* get name() → Anzeigename
|
||||
* get mediaProfile() → 'bluray' | 'dvd'
|
||||
* detect(discInfo) → boolean
|
||||
*
|
||||
* ctx.extra-Felder für rip():
|
||||
* rawJobDir - Absoluter Pfad des RAW-Ausgabeordners
|
||||
* deviceInfo - { path, index, mediaProfile } vom DiskDetectionService
|
||||
* selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus)
|
||||
* ripMode - 'backup' | 'mkv' (Default: aus Settings)
|
||||
* backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD)
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*
|
||||
* ctx.extra-Felder für encode():
|
||||
* inputPath - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis
|
||||
* outputPath - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix)
|
||||
* encodePlan - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review
|
||||
* isCancelled - () => boolean
|
||||
* onProcessHandle - (handle) => void [optional]
|
||||
*
|
||||
* ctx.extra-Felder für review():
|
||||
* deviceInfo - Disc-Device (für Pre-Rip-Scan) ODER
|
||||
* rawJobDir - RAW-Ordner (für Post-Rip-Scan)
|
||||
* reviewMode - 'pre_rip' | 'rip' (Default: 'pre_rip')
|
||||
*/
|
||||
class VideoDiscPlugin extends SourcePlugin {
|
||||
/**
|
||||
* Gibt das Media-Profil zurück, das settingsService-Methoden verwenden.
|
||||
* Muss von Subklassen implementiert werden.
|
||||
* @returns {'bluray'|'dvd'}
|
||||
*/
|
||||
get mediaProfile() {
|
||||
throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt eine TMDb-Suche für den erkannten Disc-Titel durch.
|
||||
* Gibt { detectedTitle, metadataCandidates } zurück.
|
||||
*
|
||||
* @param {string} devicePath
|
||||
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{detectedTitle: string, metadataCandidates: Array}>}
|
||||
*/
|
||||
async analyze(devicePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'VideoDiscPlugin.js'
|
||||
});
|
||||
|
||||
const discInfo = ctx.extra?.discInfo || {};
|
||||
const detectedTitle = String(
|
||||
discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc'
|
||||
).trim();
|
||||
|
||||
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
|
||||
|
||||
const metadataCandidates = await tmdbService.searchMoviesWithDetails(detectedTitle).catch((error) => {
|
||||
ctx.logger.warn(`${this.id}:analyze:tmdb-failed`, {
|
||||
jobId: job?.id,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return [];
|
||||
});
|
||||
|
||||
ctx.logger.info(`${this.id}:analyze:done`, {
|
||||
jobId: job?.id,
|
||||
detectedTitle,
|
||||
metadataCandidates: metadataCandidates.length
|
||||
});
|
||||
|
||||
return { detectedTitle, metadataCandidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück.
|
||||
* Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview).
|
||||
*
|
||||
* ctx.extra.reviewMode:
|
||||
* 'pre_rip' → Scan direkt vom Laufwerk (deviceInfo)
|
||||
* 'rip' → Scan aus dem RAW-Ordner (rawJobDir)
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<{scanLines: string[], runInfo: object}|null>}
|
||||
*/
|
||||
async review(job, ctx) {
|
||||
ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'VideoDiscPlugin.js'
|
||||
});
|
||||
|
||||
const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase();
|
||||
const deviceInfo = ctx.extra?.deviceInfo || null;
|
||||
const rawJobDir = ctx.extra?.rawJobDir || null;
|
||||
|
||||
ctx.logger.info(`${this.id}:review:start`, {
|
||||
jobId: job?.id,
|
||||
reviewMode,
|
||||
deviceInfo: deviceInfo?.path || null,
|
||||
rawJobDir: rawJobDir || null
|
||||
});
|
||||
|
||||
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||
|
||||
let scanConfig;
|
||||
if (reviewMode === 'rip' && rawJobDir) {
|
||||
// Post-Rip scan: scan from the ripped RAW folder
|
||||
scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, {
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings
|
||||
});
|
||||
} else {
|
||||
// Pre-Rip scan: scan directly from disc device
|
||||
scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, {
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(`${this.id}:review:scan-command`, {
|
||||
jobId: job?.id,
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args
|
||||
});
|
||||
|
||||
const scanLines = [];
|
||||
const scanAnalysisLines = [];
|
||||
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||
const reviewStage = String(ctx.extra?.reviewStage || 'MEDIAINFO_CHECK').trim().toUpperCase() || 'MEDIAINFO_CHECK';
|
||||
const reviewSource = String(ctx.extra?.reviewSource || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN';
|
||||
const reviewSilent = Boolean(ctx.extra?.reviewSilent);
|
||||
let runInfo;
|
||||
if (runCommandFn) {
|
||||
try {
|
||||
runInfo = await runCommandFn({
|
||||
jobId: job?.id,
|
||||
stage: reviewStage,
|
||||
source: reviewSource,
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args,
|
||||
collectLines: scanLines,
|
||||
// Keep JSON parsing stable by collecting only stdout in scanLines.
|
||||
// Stderr is still mirrored into scanAnalysisLines for heuristics.
|
||||
collectStderrLines: false,
|
||||
onStdoutLine: (line) => scanAnalysisLines.push(line),
|
||||
onStderrLine: (line) => scanAnalysisLines.push(line),
|
||||
silent: reviewSilent
|
||||
});
|
||||
} catch (cmdError) {
|
||||
// runCommand collected stdout lines via callbacks before throwing.
|
||||
// Recover the runInfo from the error so the caller can still parse
|
||||
// whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON).
|
||||
runInfo = cmdError?.runInfo || {
|
||||
status: 'ERROR',
|
||||
exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1,
|
||||
errorMessage: cmdError?.message || String(cmdError)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
runInfo = await _runCommandCaptured({
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args,
|
||||
onStdoutLine: (line) => {
|
||||
scanLines.push(line);
|
||||
scanAnalysisLines.push(line);
|
||||
},
|
||||
onStderrLine: (line) => scanAnalysisLines.push(line),
|
||||
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(`${this.id}:review:scan-done`, {
|
||||
jobId: job?.id,
|
||||
exitCode: runInfo.exitCode,
|
||||
lineCount: scanAnalysisLines.length
|
||||
});
|
||||
|
||||
// Return raw scan data — the Orchestrator (or legacy pipelineService)
|
||||
// processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
|
||||
return {
|
||||
scanLines,
|
||||
scanAnalysisLines,
|
||||
runInfo,
|
||||
reviewMode,
|
||||
mediaProfile: this.mediaProfile,
|
||||
sourceArg: scanConfig.sourceArg || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rippt die Disc mit makemkvcon in den rawJobDir.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object>} runInfo vom makemkvcon-Prozess
|
||||
*/
|
||||
async rip(job, ctx) {
|
||||
ctx.markExecution('rip', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'VideoDiscPlugin.js'
|
||||
});
|
||||
|
||||
const {
|
||||
rawJobDir,
|
||||
deviceInfo,
|
||||
selectedTitleId = null,
|
||||
backupOutputBase = null,
|
||||
disableMinLengthFilter = false,
|
||||
sourceArgOverride = null,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!rawJobDir) {
|
||||
throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`);
|
||||
}
|
||||
if (!deviceInfo) {
|
||||
throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`);
|
||||
}
|
||||
|
||||
ensureDir(rawJobDir);
|
||||
|
||||
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||
const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, {
|
||||
selectedTitleId,
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings,
|
||||
backupOutputBase,
|
||||
disableMinLengthFilter,
|
||||
sourceArgOverride
|
||||
});
|
||||
|
||||
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
||||
.trim().toLowerCase() === 'backup' ? 'backup' : 'mkv';
|
||||
|
||||
ctx.logger.info(`${this.id}:rip:start`, {
|
||||
jobId: job?.id,
|
||||
cmd: ripConfig.cmd,
|
||||
args: ripConfig.args,
|
||||
ripMode,
|
||||
rawJobDir,
|
||||
selectedTitleId,
|
||||
disableMinLengthFilter,
|
||||
sourceArgOverride: sourceArgOverride || null
|
||||
});
|
||||
|
||||
ctx.emitProgress(0, ripMode === 'backup'
|
||||
? `${this.name}: Backup läuft …`
|
||||
: `${this.name}: Ripping läuft …`
|
||||
);
|
||||
|
||||
// Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand()
|
||||
// des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
|
||||
// Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
|
||||
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||
const makeMkvLineFilter = typeof ctx.extra?.makeMkvLineFilter === 'function'
|
||||
? ctx.extra.makeMkvLineFilter
|
||||
: null;
|
||||
let runInfo;
|
||||
if (runCommandFn) {
|
||||
runInfo = await runCommandFn({
|
||||
jobId: job?.id,
|
||||
stage: 'RIPPING',
|
||||
source: 'MAKEMKV_RIP',
|
||||
cmd: ripConfig.cmd,
|
||||
args: ripConfig.args,
|
||||
parser: parseMakeMkvProgress,
|
||||
lineFilter: makeMkvLineFilter
|
||||
});
|
||||
} else {
|
||||
runInfo = await _spawnAndWait({
|
||||
cmd: ripConfig.cmd,
|
||||
args: ripConfig.args,
|
||||
cwd: rawJobDir,
|
||||
jobId: job?.id,
|
||||
progressParser: parseMakeMkvProgress,
|
||||
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(`${this.id}:rip:done`, {
|
||||
jobId: job?.id,
|
||||
rawJobDir,
|
||||
exitCode: runInfo?.exitCode ?? runInfo?.code ?? null
|
||||
});
|
||||
|
||||
ctx.extra.ripRunInfo = runInfo;
|
||||
return runInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodiert die gerippten Dateien mit HandBrakeCLI.
|
||||
*
|
||||
* @param {object} job
|
||||
* @param {PluginContext} ctx
|
||||
* @returns {Promise<object>} runInfo vom HandBrake-Prozess
|
||||
*/
|
||||
async encode(job, ctx) {
|
||||
ctx.markExecution('encode', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'VideoDiscPlugin.js'
|
||||
});
|
||||
|
||||
const {
|
||||
inputPath,
|
||||
outputPath,
|
||||
encodePlan = {},
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
|
||||
if (!inputPath) {
|
||||
throw new Error(`${this.constructor.name}.encode(): ctx.extra.inputPath fehlt`);
|
||||
}
|
||||
if (!outputPath) {
|
||||
throw new Error(`${this.constructor.name}.encode(): ctx.extra.outputPath fehlt`);
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(outputPath));
|
||||
|
||||
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
|
||||
trackSelection: encodePlan.trackSelection || null,
|
||||
titleId: encodePlan.handBrakeTitleId || null,
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings,
|
||||
userPreset: encodePlan.userPreset || null
|
||||
});
|
||||
|
||||
ctx.logger.info(`${this.id}:encode:start`, {
|
||||
jobId: job?.id,
|
||||
cmd: handBrakeConfig.cmd,
|
||||
args: handBrakeConfig.args,
|
||||
titleId: encodePlan.handBrakeTitleId || null
|
||||
});
|
||||
|
||||
ctx.emitProgress(0, `${this.name}: Encoding läuft …`);
|
||||
|
||||
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||
const encodeSource = String(ctx.extra?.encodeSource || 'HANDBRAKE').trim().toUpperCase() || 'HANDBRAKE';
|
||||
const encodeStage = String(ctx.extra?.encodeStage || 'ENCODING').trim().toUpperCase() || 'ENCODING';
|
||||
const encodeParser = typeof ctx.extra?.progressParser === 'function'
|
||||
? ctx.extra.progressParser
|
||||
: parseHandBrakeProgress;
|
||||
|
||||
let runInfo;
|
||||
if (runCommandFn) {
|
||||
runInfo = await runCommandFn({
|
||||
jobId: job?.id,
|
||||
stage: encodeStage,
|
||||
source: encodeSource,
|
||||
cmd: handBrakeConfig.cmd,
|
||||
args: handBrakeConfig.args,
|
||||
parser: encodeParser
|
||||
});
|
||||
} else {
|
||||
runInfo = await _spawnAndWait({
|
||||
cmd: handBrakeConfig.cmd,
|
||||
args: handBrakeConfig.args,
|
||||
jobId: job?.id,
|
||||
progressParser: parseHandBrakeProgress,
|
||||
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`),
|
||||
isCancelled,
|
||||
onProcessHandle,
|
||||
context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' }
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(`${this.id}:encode:done`, {
|
||||
jobId: job?.id,
|
||||
outputPath,
|
||||
exitCode: runInfo.exitCode
|
||||
});
|
||||
|
||||
ctx.extra.encodeRunInfo = runInfo;
|
||||
return runInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-spezifische Settings (gemeinsam für BluRay + DVD, via this.mediaProfile).
|
||||
*/
|
||||
getSettingsSchema() {
|
||||
const profile = this.mediaProfile;
|
||||
const label = profile === 'bluray' ? 'Blu-ray' : 'DVD';
|
||||
const orderBase = profile === 'bluray' ? 200 : 220;
|
||||
|
||||
return [
|
||||
{
|
||||
key: `handbrake_preset_${profile}`,
|
||||
category: 'Tools',
|
||||
label: `HandBrake Preset (${label})`,
|
||||
type: 'string',
|
||||
required: 0,
|
||||
description: `Preset Name für -Z (${label}). Leer = kein Preset, nur CLI-Parameter werden verwendet.`,
|
||||
default_value: null,
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: orderBase
|
||||
},
|
||||
{
|
||||
key: `output_extension_${profile}`,
|
||||
category: 'Tools',
|
||||
label: `Ausgabe-Format (${label})`,
|
||||
type: 'select',
|
||||
required: 1,
|
||||
description: `Dateiendung des encodierten ${label}-Outputs.`,
|
||||
default_value: 'mkv',
|
||||
options_json: '["mkv","mp4"]',
|
||||
validation_json: '{}',
|
||||
order_index: orderBase + 1
|
||||
},
|
||||
{
|
||||
key: `output_template_${profile}`,
|
||||
category: 'Pfade',
|
||||
label: `Output Template (${label})`,
|
||||
type: 'string',
|
||||
required: 1,
|
||||
description: `Template für relative ${label}-Ausgabepfade ohne Dateiendung. Platzhalter: {title}, {year}. Unterordner über "/".`,
|
||||
default_value: '{title} ({year})/{title} ({year})',
|
||||
options_json: '[]',
|
||||
validation_json: '{"minLength":1}',
|
||||
order_index: 700 + (profile === 'bluray' ? 0 : 5)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
|
||||
|
||||
function _assertNotCancelled(isCancelled) {
|
||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) {
|
||||
const lines = [];
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
onStdoutLine: (line) => {
|
||||
lines.push(line);
|
||||
if (typeof onStdoutLine === 'function') {
|
||||
onStdoutLine(line);
|
||||
}
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
lines.push(line);
|
||||
if (typeof onStderrLine === 'function') {
|
||||
onStderrLine(line);
|
||||
}
|
||||
},
|
||||
context: context || {}
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.promise;
|
||||
return { exitCode: 0, lines };
|
||||
} catch (error) {
|
||||
return {
|
||||
exitCode: typeof error?.code === 'number' ? error.code : 1,
|
||||
lines,
|
||||
error: error?.message || String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function _spawnAndWait({ cmd, args, cwd, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
|
||||
_assertNotCancelled(isCancelled);
|
||||
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
onStdoutLine: (line) => {
|
||||
if (progressParser && typeof onProgress === 'function') {
|
||||
const progress = progressParser(line);
|
||||
if (progress?.percent != null) {
|
||||
onProgress(progress.percent, progress.statusText || null);
|
||||
}
|
||||
}
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
if (progressParser && typeof onProgress === 'function') {
|
||||
const progress = progressParser(line);
|
||||
if (progress?.percent != null) {
|
||||
onProgress(progress.percent, progress.statusText || null);
|
||||
}
|
||||
}
|
||||
},
|
||||
context: context || { jobId }
|
||||
});
|
||||
|
||||
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()) {
|
||||
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
cancelError.statusCode = 409;
|
||||
throw cancelError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { VideoDiscPlugin };
|
||||
@@ -0,0 +1,412 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const converterScanService = require('../services/converterScanService');
|
||||
const historyService = require('../services/historyService');
|
||||
const logger = require('../services/logger').child('CONVERTER_ROUTE');
|
||||
const { tempDir } = require('../config');
|
||||
|
||||
/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */
|
||||
function resolveTarget(rawDir, input) {
|
||||
const rel = converterScanService.normalizeRelPath(input != null ? String(input) : '');
|
||||
if (rel === null) return { error: 'Ungültiger Pfad' };
|
||||
const absolute = path.join(rawDir, rel || '.');
|
||||
return { rel: rel || '', absolute };
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
const converterUploadDir = path.join(tempDir, 'ripster-converter-uploads');
|
||||
fs.mkdirSync(converterUploadDir, { recursive: true });
|
||||
|
||||
const converterUpload = multer({
|
||||
dest: converterUploadDir
|
||||
});
|
||||
|
||||
// ── Scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/converter/tree
|
||||
* Vollständiger FS-Verzeichnisbaum des converter_raw_dir (keine DB).
|
||||
*/
|
||||
router.get(
|
||||
'/tree',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:tree');
|
||||
const result = await converterScanService.getTree();
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/converter/browse?parent=relPath
|
||||
* File-Explorer (DB-basiert, wird weiterhin für Job-Zuweisung gebraucht).
|
||||
*/
|
||||
router.get(
|
||||
'/browse',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parent = req.query.parent ? String(req.query.parent).trim() : null;
|
||||
logger.debug('get:browse', { parent });
|
||||
const entries = await converterScanService.getEntries(parent);
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
res.json({ entries, rawDir });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/scan
|
||||
* Manuellen Scan des converter_raw_dir auslösen.
|
||||
*/
|
||||
router.post(
|
||||
'/scan',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:scan');
|
||||
const result = await converterScanService.scan();
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
// ── Jobs erstellen ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/converter/create-jobs
|
||||
* Body: { entries: [{ relPath, converterMediaType }] }
|
||||
* Erstellt Jobs aus Scan-Einträgen (File-Explorer-Auswahl).
|
||||
*/
|
||||
router.post(
|
||||
'/create-jobs',
|
||||
asyncHandler(async (req, res) => {
|
||||
const entries = Array.isArray(req.body?.entries) ? req.body.entries : [];
|
||||
if (entries.length === 0) {
|
||||
const error = new Error('Keine Einträge ausgewählt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.info('post:create-jobs', { count: entries.length });
|
||||
|
||||
const jobs = [];
|
||||
for (const entry of entries) {
|
||||
const relPath = String(entry?.relPath || '').trim();
|
||||
if (!relPath) continue;
|
||||
const job = await pipelineService.createFileJob({
|
||||
kind: 'converter_entry',
|
||||
relPath,
|
||||
options: {
|
||||
converterMediaType: entry?.converterMediaType || null
|
||||
}
|
||||
});
|
||||
jobs.push(job);
|
||||
}
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/upload
|
||||
* Datei(en) hochladen → Unterordner anlegen (kein Job-Anlegen).
|
||||
* Gibt { folders: [{folderRelPath, fileCount}] } zurück.
|
||||
*/
|
||||
router.post(
|
||||
'/upload',
|
||||
converterUpload.array('files', 50),
|
||||
asyncHandler(async (req, res) => {
|
||||
const files = Array.isArray(req.files) ? req.files : [];
|
||||
if (files.length === 0) {
|
||||
const error = new Error('Keine Dateien hochgeladen.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const folderName = req.body?.folderName ? String(req.body.folderName).trim() : null;
|
||||
logger.info('post:upload', {
|
||||
fileCount: files.length,
|
||||
folderName,
|
||||
files: files.map((f) => ({ name: f.originalname, size: f.size }))
|
||||
});
|
||||
const result = await pipelineService.uploadConverterFiles(files, { folderName });
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/from-selection
|
||||
* Body: { relPaths: string[], audioMode: 'individual'|'shared' }
|
||||
* Erstellt Jobs aus im File-Explorer ausgewählten Dateien.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/from-selection',
|
||||
asyncHandler(async (req, res) => {
|
||||
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
|
||||
const audioMode = String(req.body?.audioMode || 'individual');
|
||||
if (relPaths.length === 0) {
|
||||
const error = new Error('Keine Dateien ausgewählt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
logger.info('post:jobs:from-selection', { count: relPaths.length, audioMode });
|
||||
const jobs = await pipelineService.createConverterJobsFromSelection(relPaths, audioMode);
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/assign-files
|
||||
* Body: { relPaths: string[] }
|
||||
* Fügt ausgewählte Dateien einem bestehenden (nicht gestarteten) Converter-Job hinzu.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/assign-files',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (relPaths.length === 0) {
|
||||
return res.status(400).json({ detail: 'Keine Dateien übergeben.' });
|
||||
}
|
||||
logger.info('post:jobs:assign-files', { jobId, count: relPaths.length });
|
||||
const result = await pipelineService.assignConverterFilesToJob(jobId, relPaths);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/remove-file
|
||||
* Body: { relPath: string }
|
||||
* Entfernt eine Datei aus einem bestehenden Converter-Job.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/remove-file',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (!relPath) {
|
||||
return res.status(400).json({ detail: 'relPath fehlt.' });
|
||||
}
|
||||
logger.info('post:jobs:remove-file', { jobId, relPath });
|
||||
const result = await pipelineService.removeConverterFileFromJob(jobId, relPath);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/remove-input
|
||||
* Body: { inputPath: string }
|
||||
* Entfernt eine Datei aus einem Converter-Job anhand des absoluten Input-Pfads.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/remove-input',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const inputPath = String(req.body?.inputPath || '').trim();
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
if (!inputPath) {
|
||||
return res.status(400).json({ detail: 'inputPath fehlt.' });
|
||||
}
|
||||
logger.info('post:jobs:remove-input', { jobId, inputPath });
|
||||
const result = await pipelineService.removeConverterInputFromJob(jobId, inputPath);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/config
|
||||
* Body: partial config draft (outputFormat, presets, metadata, tracks, MusicBrainz-UI-Stand)
|
||||
* Speichert den Draft für READY_TO_START Jobs persistent im encode_plan_json.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/config',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return res.status(400).json({ detail: 'Ungültige jobId.' });
|
||||
}
|
||||
logger.debug('post:jobs:config', { jobId });
|
||||
const result = await pipelineService.updateConverterJobConfig(jobId, req.body || {});
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
// ── Datei-Operationen (reines FS, keine DB) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* DELETE /api/converter/files
|
||||
* Body: { relPath }
|
||||
* Datei oder Ordner löschen (fs.rmSync, ohne DB).
|
||||
*/
|
||||
router.delete(
|
||||
'/files',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
if (!relPath) return res.status(400).json({ detail: 'relPath fehlt.' });
|
||||
const target = resolveTarget(rawDir, relPath);
|
||||
if (target.error || !target.rel) return res.status(400).json({ detail: target.error || 'Ungültiger Pfad.' });
|
||||
logger.info('delete:files', { relPath });
|
||||
fs.rmSync(target.absolute, { recursive: true, force: true });
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/files/rename
|
||||
* Body: { relPath, newName }
|
||||
* Umbenennen (fs.renameSync, ohne DB).
|
||||
*/
|
||||
router.post(
|
||||
'/files/rename',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
const newName = String(req.body?.newName || '').trim();
|
||||
if (!relPath || !newName) return res.status(400).json({ detail: 'relPath und newName erforderlich.' });
|
||||
if (newName.includes('/') || newName.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
|
||||
const source = resolveTarget(rawDir, relPath);
|
||||
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
|
||||
const parentAbs = path.dirname(source.absolute);
|
||||
const destAbs = path.join(parentAbs, newName);
|
||||
logger.info('post:files:rename', { relPath, newName });
|
||||
fs.renameSync(source.absolute, destAbs);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/files/move
|
||||
* Body: { relPath, targetParentRelPath }
|
||||
* Verschieben (fs.renameSync, ohne DB). targetParentRelPath = '' → Root.
|
||||
*/
|
||||
router.post(
|
||||
'/files/move',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
|
||||
const relPath = String(req.body?.relPath || '').trim();
|
||||
if (!relPath) return res.status(400).json({ detail: 'relPath erforderlich.' });
|
||||
const targetParentRelPath = req.body?.targetParentRelPath != null ? String(req.body.targetParentRelPath) : '';
|
||||
const source = resolveTarget(rawDir, relPath);
|
||||
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
|
||||
const targetParent = resolveTarget(rawDir, targetParentRelPath);
|
||||
if (targetParent.error) return res.status(400).json({ detail: targetParent.error });
|
||||
const name = path.basename(source.absolute);
|
||||
const destAbs = path.join(targetParent.absolute, name);
|
||||
logger.info('post:files:move', { relPath, targetParentRelPath });
|
||||
fs.renameSync(source.absolute, destAbs);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/files/folder
|
||||
* Body: { parentRelPath, name }
|
||||
* Neuen Ordner anlegen (fs.mkdirSync, ohne DB).
|
||||
*/
|
||||
router.post(
|
||||
'/files/folder',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
|
||||
const parentRelPath = req.body?.parentRelPath != null ? String(req.body.parentRelPath) : '';
|
||||
const name = String(req.body?.name || '').trim();
|
||||
if (!name || name.includes('/') || name.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
|
||||
const parent = resolveTarget(rawDir, parentRelPath);
|
||||
if (parent.error) return res.status(400).json({ detail: parent.error });
|
||||
logger.info('post:files:folder', { parentRelPath, name });
|
||||
fs.mkdirSync(path.join(parent.absolute, name), { recursive: true });
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
// ── Job-Status ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/converter/jobs
|
||||
* Alle Converter-Jobs zurückgeben.
|
||||
*/
|
||||
router.get(
|
||||
'/jobs',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobs = await pipelineService.getConverterJobs();
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/converter/jobs/:jobId
|
||||
* Einzelnen Converter-Job abrufen.
|
||||
*/
|
||||
router.get(
|
||||
'/jobs/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const job = await historyService.getJobById(jobId);
|
||||
if (!job) {
|
||||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/start
|
||||
* Job mit finaler Konfiguration starten.
|
||||
* Body: { converterMediaType, outputFormat, userPreset, trackSelection, handBrakeTitleId, audioFormatOptions }
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/start',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const config = req.body || {};
|
||||
logger.info('post:jobs:start', {
|
||||
jobId,
|
||||
converterMediaType: config.converterMediaType,
|
||||
outputFormat: config.outputFormat
|
||||
});
|
||||
const result = await pipelineService.startConverterJob(jobId, config);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/converter/jobs/:jobId/cancel
|
||||
* Job abbrechen.
|
||||
*/
|
||||
router.post(
|
||||
'/jobs/:jobId/cancel',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:jobs:cancel', { jobId });
|
||||
const result = await pipelineService.cancel(jobId);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/converter/jobs/:jobId
|
||||
* Job aus der DB löschen.
|
||||
*/
|
||||
router.delete(
|
||||
'/jobs/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('delete:jobs', { jobId });
|
||||
await historyService.deleteJob(jobId, 'none', { includeRelated: false });
|
||||
await converterScanService.clearAssignmentsForJob(jobId);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,101 @@
|
||||
const express = require('express');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const cronService = require('../services/cronService');
|
||||
const wsService = require('../services/websocketService');
|
||||
const logger = require('../services/logger').child('CRON_ROUTE');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/crons – alle Cronjobs auflisten
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:crons', { reqId: req.reqId });
|
||||
const jobs = await cronService.listJobs();
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/crons/validate-expression – Cron-Ausdruck validieren
|
||||
router.post(
|
||||
'/validate-expression',
|
||||
asyncHandler(async (req, res) => {
|
||||
const expr = String(req.body?.cronExpression || '').trim();
|
||||
const validation = cronService.validateExpression(expr);
|
||||
const nextRunAt = validation.valid ? cronService.getNextRunTime(expr) : null;
|
||||
res.json({ ...validation, nextRunAt });
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/crons – neuen Cronjob anlegen
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('post:crons:create', { reqId: req.reqId, name: payload?.name });
|
||||
const job = await cronService.createJob(payload);
|
||||
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'created', id: job.id });
|
||||
res.status(201).json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/crons/:id – einzelnen Cronjob abrufen
|
||||
router.get(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.debug('get:crons:one', { reqId: req.reqId, cronJobId: id });
|
||||
const job = await cronService.getJobById(id);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
// PUT /api/crons/:id – Cronjob aktualisieren
|
||||
router.put(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('put:crons:update', { reqId: req.reqId, cronJobId: id });
|
||||
const job = await cronService.updateJob(id, payload);
|
||||
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'updated', id: job.id });
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
// DELETE /api/crons/:id – Cronjob löschen
|
||||
router.delete(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.info('delete:crons', { reqId: req.reqId, cronJobId: id });
|
||||
const removed = await cronService.deleteJob(id);
|
||||
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'deleted', id: removed.id });
|
||||
res.json({ removed });
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/crons/:id/logs – Ausführungs-Logs eines Cronjobs
|
||||
router.get(
|
||||
'/:id/logs',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const limit = Math.min(Number(req.query?.limit) || 20, 100);
|
||||
logger.debug('get:crons:logs', { reqId: req.reqId, cronJobId: id, limit });
|
||||
const logs = await cronService.getJobLogs(id, limit);
|
||||
res.json({ logs });
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/crons/:id/run – Cronjob manuell auslösen
|
||||
router.post(
|
||||
'/:id/run',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.info('post:crons:run', { reqId: req.reqId, cronJobId: id });
|
||||
const result = await cronService.triggerJobManually(id);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,71 @@
|
||||
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();
|
||||
const outputPath = String(req.body?.outputPath || '').trim() || null;
|
||||
logger.info('post:downloads:history', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
target,
|
||||
outputPath
|
||||
});
|
||||
const result = await downloadService.enqueueHistoryJob(jobId, target, { outputPath });
|
||||
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;
|
||||
@@ -0,0 +1,462 @@
|
||||
const express = require('express');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const historyService = require('../services/historyService');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const logger = require('../services/logger').child('HISTORY_ROUTE');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function parseSelectedJobIds(value, options = {}) {
|
||||
const { hasExplicitValue = true } = options;
|
||||
if (!hasExplicitValue) {
|
||||
return null;
|
||||
}
|
||||
const sourceValues = Array.isArray(value)
|
||||
? value
|
||||
: String(value || '')
|
||||
.split(',');
|
||||
return sourceValues
|
||||
.map((entry) => Number(entry))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||
.map((entry) => Math.trunc(entry));
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsedLimit = Number(req.query.limit);
|
||||
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||
? Math.trunc(parsedLimit)
|
||||
: null;
|
||||
const statuses = String(req.query.statuses || '')
|
||||
.split(',')
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
|
||||
const includeChildren = ['1', 'true', 'yes'].includes(String(req.query.includeChildren || '').toLowerCase());
|
||||
logger.info('get:jobs', {
|
||||
reqId: req.reqId,
|
||||
status: req.query.status,
|
||||
statuses: statuses.length > 0 ? statuses : null,
|
||||
search: req.query.search,
|
||||
limit,
|
||||
lite,
|
||||
includeChildren
|
||||
});
|
||||
|
||||
const jobs = await historyService.getJobs({
|
||||
status: req.query.status,
|
||||
statuses,
|
||||
search: req.query.search,
|
||||
limit,
|
||||
includeFsChecks: !lite,
|
||||
includeChildren
|
||||
});
|
||||
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/orphan-raw',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('get:orphan-raw', { reqId: req.reqId });
|
||||
const result = await historyService.getOrphanRawFolders();
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tmdb-migration/pending',
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsedLimit = Number(req.query.limit);
|
||||
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||
? Math.trunc(parsedLimit)
|
||||
: 500;
|
||||
logger.info('get:tmdb-migration:pending', {
|
||||
reqId: req.reqId,
|
||||
limit
|
||||
});
|
||||
const jobs = await historyService.getTmdbMigrationPendingJobs({
|
||||
limit,
|
||||
includeFsChecks: false
|
||||
});
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/tmdb-migration',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const migrated = ['1', 'true', 'yes', 'on'].includes(
|
||||
String(req.body?.migrated ?? req.body?.migrateTMDB ?? '').trim().toLowerCase()
|
||||
);
|
||||
logger.info('post:tmdb-migration:flag', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
migrated
|
||||
});
|
||||
const job = await historyService.setTmdbMigrationFlag(id, migrated);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/orphan-raw/import',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawPath = String(req.body?.rawPath || '').trim();
|
||||
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
|
||||
const importedJob = await historyService.importOrphanRawFolder(rawPath);
|
||||
const importedJobId = Number(importedJob?.id || 0);
|
||||
let activation = null;
|
||||
let activationError = null;
|
||||
if (Number.isFinite(importedJobId) && importedJobId > 0) {
|
||||
try {
|
||||
activation = await pipelineService.analyzeRawImportJob(importedJobId, {
|
||||
rawPath: importedJob?.raw_path || rawPath
|
||||
});
|
||||
} catch (error) {
|
||||
activationError = error?.message || String(error);
|
||||
logger.warn('post:orphan-raw:import:activation-failed', {
|
||||
reqId: req.reqId,
|
||||
jobId: importedJobId,
|
||||
rawPath,
|
||||
error: activationError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedJob = importedJobId > 0
|
||||
? await historyService.getJobById(importedJobId)
|
||||
: null;
|
||||
|
||||
res.json({
|
||||
job: refreshedJob || importedJob,
|
||||
activation,
|
||||
...(activationError ? { activationError } : {})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/orphan-raw/delete',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawPath = String(req.body?.rawPath || '').trim();
|
||||
logger.warn('post:orphan-raw:delete', { reqId: req.reqId, rawPath });
|
||||
const result = await historyService.deleteOrphanRawFolder(rawPath);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/metadata/assign',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('post:job:metadata:assign', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
imdbId: payload?.imdbId || null,
|
||||
hasTitle: Boolean(payload?.title),
|
||||
hasYear: Boolean(payload?.year)
|
||||
});
|
||||
|
||||
const job = await historyService.assignMetadata(id, payload);
|
||||
|
||||
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
|
||||
pipelineService.renameJobFolders(id).catch((err) => {
|
||||
logger.warn('post:job:metadata: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 pipelineService.selectCdMetadata({
|
||||
jobId: id,
|
||||
title: payload?.title,
|
||||
artist: payload?.artist,
|
||||
year: payload?.year,
|
||||
mbId: payload?.mbId,
|
||||
coverUrl: payload?.coverUrl,
|
||||
tracks: payload?.tracks
|
||||
});
|
||||
|
||||
// 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 });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/error/ack',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.info('post:job:error:ack', { reqId: req.reqId, id });
|
||||
const job = await historyService.acknowledgeJobError(id);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/nfo/generate',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.info('post:job:nfo:generate', { reqId: req.reqId, id });
|
||||
const result = await historyService.generateJobNfo(id, {
|
||||
mode: 'manual',
|
||||
requireSettingDisabled: true,
|
||||
failIfExists: true,
|
||||
failIfOutputMissing: true
|
||||
});
|
||||
const job = await historyService.getJobWithLogs(id, { includeFsChecks: true });
|
||||
res.json({ result, job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/delete-files',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const target = String(req.body?.target || 'both');
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
|
||||
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
|
||||
});
|
||||
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
|
||||
? req.body.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||
? req.body.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
logger.warn('post:delete-files', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
target,
|
||||
includeRelated,
|
||||
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
|
||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||
});
|
||||
|
||||
const result = await historyService.deleteJobFiles(id, target, {
|
||||
includeRelated,
|
||||
selectedJobIds,
|
||||
selectedRawPaths,
|
||||
selectedMoviePaths
|
||||
});
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
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());
|
||||
const selectedJobIds = parseSelectedJobIds(req.query.selectedJobIds, {
|
||||
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.query || {}, 'selectedJobIds')
|
||||
});
|
||||
|
||||
logger.info('get:delete-preview', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
includeRelated,
|
||||
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0
|
||||
});
|
||||
|
||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
|
||||
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());
|
||||
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
|
||||
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
|
||||
});
|
||||
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
|
||||
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
|
||||
const hasRequestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined;
|
||||
const requestedKeepDetectedDevice = hasRequestedKeepDetectedDevice
|
||||
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
|
||||
: null;
|
||||
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
|
||||
? req.body.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||
? req.body.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
logger.warn('post:delete-job', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
target,
|
||||
includeRelated,
|
||||
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
|
||||
requestedResetDriveState,
|
||||
preserveRawForImportJobs,
|
||||
requestedKeepDetectedDevice,
|
||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||
});
|
||||
|
||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
|
||||
const containsOrphanRawImportJob = Boolean(
|
||||
preview?.flags?.containsOrphanRawImportJob
|
||||
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some(
|
||||
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
|
||||
))
|
||||
);
|
||||
const selectedDeleteJobIds = Array.isArray(preview?.selectedJobIds)
|
||||
? preview.selectedJobIds
|
||||
: [id];
|
||||
const selectedDeleteJobs = await historyService.getJobsByIds(selectedDeleteJobIds).catch(() => []);
|
||||
const autoResetDriveStateForPhysicalCd = Array.isArray(selectedDeleteJobs) && selectedDeleteJobs.some((job) => {
|
||||
const mediaType = String(job?.media_type || job?.mediaType || '').trim().toLowerCase();
|
||||
const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase();
|
||||
const status = String(job?.status || '').trim().toUpperCase();
|
||||
const lastState = String(job?.last_state || '').trim().toUpperCase();
|
||||
const isCdJob = mediaType === 'cd'
|
||||
|| jobKind === 'cd'
|
||||
|| status.startsWith('CD_')
|
||||
|| lastState.startsWith('CD_');
|
||||
if (!isCdJob) {
|
||||
return false;
|
||||
}
|
||||
const discDevice = String(job?.disc_device || job?.discDevice || '').trim();
|
||||
return Boolean(discDevice) && !discDevice.startsWith('__virtual__');
|
||||
});
|
||||
|
||||
const requestedResetDriveStateEffective = requestedResetDriveState || autoResetDriveStateForPhysicalCd;
|
||||
const resetDriveState = containsOrphanRawImportJob
|
||||
? false
|
||||
: requestedResetDriveStateEffective;
|
||||
const keepDetectedDevice = containsOrphanRawImportJob
|
||||
? true
|
||||
: (hasRequestedKeepDetectedDevice
|
||||
? requestedKeepDetectedDevice
|
||||
: !requestedResetDriveStateEffective);
|
||||
|
||||
if (containsOrphanRawImportJob && requestedResetDriveState) {
|
||||
logger.info('post:delete-job:orphan-drive-reset-skipped', {
|
||||
reqId: req.reqId,
|
||||
id
|
||||
});
|
||||
}
|
||||
|
||||
if (autoResetDriveStateForPhysicalCd && !requestedResetDriveState) {
|
||||
logger.info('post:delete-job:auto-drive-reset-cd', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
selectedJobIds: selectedDeleteJobIds
|
||||
});
|
||||
}
|
||||
|
||||
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
|
||||
? preview.relatedJobs
|
||||
.filter((row) => Boolean(row?.selected))
|
||||
.map((row) => String(row?.discDevice || '').trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const result = await historyService.deleteJob(id, target, {
|
||||
includeRelated,
|
||||
selectedJobIds,
|
||||
selectedRawPaths,
|
||||
selectedMoviePaths,
|
||||
preserveRawForImportJobs
|
||||
});
|
||||
await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
|
||||
resetDriveState,
|
||||
devicePaths: relatedDevicePaths
|
||||
}).catch((error) => {
|
||||
logger.warn('post:delete-job:cleanup-failed', { id, error: error?.message || String(error) });
|
||||
});
|
||||
const uiReset = await pipelineService.resetFrontendState('history_delete', {
|
||||
keepDetectedDevice
|
||||
});
|
||||
res.json({
|
||||
...result,
|
||||
uiReset,
|
||||
safeguards: {
|
||||
containsOrphanRawImportJob,
|
||||
resetDriveStateApplied: resetDriveState,
|
||||
keepDetectedDeviceApplied: keepDetectedDevice
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase());
|
||||
const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase());
|
||||
const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase());
|
||||
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
|
||||
const parsedTail = Number(req.query.logTailLines);
|
||||
const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0
|
||||
? Math.trunc(parsedTail)
|
||||
: null;
|
||||
const includeFsChecks = !(lite || includeLiveLog);
|
||||
|
||||
logger.info('get:job-detail', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
includeLiveLog,
|
||||
includeLogs,
|
||||
includeAllLogs,
|
||||
logTailLines,
|
||||
lite,
|
||||
includeFsChecks
|
||||
});
|
||||
const job = await historyService.getJobWithLogs(id, {
|
||||
includeLiveLog,
|
||||
includeLogs,
|
||||
includeAllLogs,
|
||||
logTailLines,
|
||||
includeFsChecks
|
||||
});
|
||||
if (!job) {
|
||||
const error = new Error('Job nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,766 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const historyService = require('../services/historyService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const settingsService = require('../services/settingsService');
|
||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const { tempDir } = require('../config');
|
||||
const { getDb } = require('../db/database');
|
||||
|
||||
const router = express.Router();
|
||||
const audiobookUploadDir = path.join(tempDir, 'ripster-audiobook-uploads');
|
||||
fs.mkdirSync(audiobookUploadDir, { recursive: true });
|
||||
const audiobookUpload = multer({
|
||||
dest: audiobookUploadDir
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/state',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:state', { reqId: req.reqId });
|
||||
res.json({
|
||||
pipeline: pipelineService.getSnapshot(),
|
||||
hardwareMonitoring: hardwareMonitorService.getSnapshot()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/hardware/history',
|
||||
asyncHandler(async (req, res) => {
|
||||
const hoursRaw = Number(req.query?.hours);
|
||||
const maxPointsRaw = Number(req.query?.maxPoints);
|
||||
const hours = Number.isFinite(hoursRaw) ? Math.max(1, Math.min(96, Math.trunc(hoursRaw))) : 24;
|
||||
const maxPoints = Number.isFinite(maxPointsRaw) ? Math.max(120, Math.min(5000, Math.trunc(maxPointsRaw))) : 720;
|
||||
const sinceIso = new Date(Date.now() - (hours * 60 * 60 * 1000)).toISOString();
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT
|
||||
captured_at,
|
||||
cpu_usage_percent,
|
||||
cpu_temperature_c,
|
||||
ram_usage_percent,
|
||||
ram_used_bytes,
|
||||
ram_total_bytes,
|
||||
gpu_usage_percent,
|
||||
gpu_temperature_c,
|
||||
vram_used_bytes,
|
||||
vram_total_bytes
|
||||
FROM hardware_metrics_history
|
||||
WHERE captured_at >= ?
|
||||
ORDER BY captured_at ASC
|
||||
`,
|
||||
[sinceIso]
|
||||
);
|
||||
|
||||
const stride = rows.length > maxPoints ? Math.ceil(rows.length / maxPoints) : 1;
|
||||
const sampled = [];
|
||||
for (let index = 0; index < rows.length; index += stride) {
|
||||
sampled.push(rows[index]);
|
||||
}
|
||||
if (rows.length > 0 && sampled[sampled.length - 1] !== rows[rows.length - 1]) {
|
||||
sampled.push(rows[rows.length - 1]);
|
||||
}
|
||||
|
||||
const points = sampled.map((row) => ({
|
||||
capturedAt: row.captured_at,
|
||||
cpuUsagePercent: row.cpu_usage_percent,
|
||||
cpuTemperatureC: row.cpu_temperature_c,
|
||||
ramUsagePercent: row.ram_usage_percent,
|
||||
ramUsedBytes: row.ram_used_bytes,
|
||||
ramTotalBytes: row.ram_total_bytes,
|
||||
gpuUsagePercent: row.gpu_usage_percent,
|
||||
gpuTemperatureC: row.gpu_temperature_c,
|
||||
vramUsedBytes: row.vram_used_bytes,
|
||||
vramTotalBytes: row.vram_total_bytes
|
||||
}));
|
||||
|
||||
res.json({
|
||||
history: {
|
||||
hours,
|
||||
maxPoints,
|
||||
totalPoints: rows.length,
|
||||
points
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/analyze',
|
||||
asyncHandler(async (req, res) => {
|
||||
const devicePath = String(req.body?.devicePath || '').trim() || null;
|
||||
logger.info('post:analyze', { reqId: req.reqId, devicePath });
|
||||
const result = await pipelineService.analyzeDisc(devicePath);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cd/drives',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:cd:drives', { reqId: req.reqId });
|
||||
const snapshot = pipelineService.getSnapshot();
|
||||
res.json({ cdDrives: snapshot.cdDrives || {} });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/rescan-disc',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:rescan-disc', { reqId: req.reqId });
|
||||
const result = await diskDetectionService.rescanAndEmit();
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/rescan-drive',
|
||||
asyncHandler(async (req, res) => {
|
||||
const devicePath = String(req.body?.devicePath || '').trim();
|
||||
logger.info('post:rescan-drive', { reqId: req.reqId, devicePath });
|
||||
if (!devicePath) {
|
||||
const err = new Error('devicePath ist erforderlich');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const result = await diskDetectionService.rescanDriveAndEmit(devicePath);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tmdb/movie/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
logger.info('get:tmdb:movie-search', { reqId: req.reqId, query });
|
||||
const results = await pipelineService.searchTmdbMovies(String(query));
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tmdb/series/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
const seasonNumber = req.query.season || null;
|
||||
logger.info('get:tmdb:series-search', { reqId: req.reqId, query, seasonNumber });
|
||||
const results = await pipelineService.searchTmdbSeries(String(query), seasonNumber);
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cd/musicbrainz/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
const trackCountRaw = Number(req.query.trackCount);
|
||||
const trackCount = Number.isFinite(trackCountRaw) && trackCountRaw > 0
|
||||
? Math.trunc(trackCountRaw)
|
||||
: null;
|
||||
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query, trackCount });
|
||||
const results = await pipelineService.searchMusicBrainz(String(query), { trackCount });
|
||||
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.createFileJob({
|
||||
kind: 'audiobook_upload',
|
||||
file: req.file,
|
||||
options: {
|
||||
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.get(
|
||||
'/audiobook/jobs',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const jobs = await pipelineService.getAudiobookJobs();
|
||||
res.json({ jobs });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
const {
|
||||
jobId,
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
imdbId,
|
||||
poster,
|
||||
mbId,
|
||||
coverUrl,
|
||||
tracks,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
metadataKind,
|
||||
workflowKind,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
} = req.body;
|
||||
|
||||
if (!jobId) {
|
||||
const error = new Error('jobId fehlt.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('post:select-metadata', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
imdbId,
|
||||
poster,
|
||||
mbId,
|
||||
coverUrl,
|
||||
trackCount: Array.isArray(tracks) ? tracks.length : null,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
workflowKind,
|
||||
seasonNumber,
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
});
|
||||
|
||||
const looksLikeCdMetadataPayload = (
|
||||
artist !== undefined
|
||||
|| mbId !== undefined
|
||||
|| coverUrl !== undefined
|
||||
|| Array.isArray(tracks)
|
||||
);
|
||||
if (looksLikeCdMetadataPayload) {
|
||||
logger.warn('post:select-metadata:compat-cd-route', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
trackCount: Array.isArray(tracks) ? tracks.length : 0
|
||||
});
|
||||
const job = await pipelineService.selectCdMetadata({
|
||||
jobId: Number(jobId),
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
mbId,
|
||||
coverUrl: coverUrl || poster || null,
|
||||
tracks
|
||||
});
|
||||
res.json({ job });
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await pipelineService.selectMetadata({
|
||||
jobId: Number(jobId),
|
||||
title,
|
||||
year,
|
||||
imdbId,
|
||||
poster,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
metadataKind,
|
||||
workflowKind,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
});
|
||||
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/jobs/:jobId/raw-decision',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const { decision } = req.body;
|
||||
logger.info('post:raw-decision', { reqId: req.reqId, jobId, decision });
|
||||
const result = await pipelineService.submitRawDecision(jobId, decision);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/start/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:start-job', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.startPreparedJob(jobId);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/multipart-merge/:jobId/reorder',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const orderedSourceJobIds = Array.isArray(req.body?.orderedSourceJobIds)
|
||||
? req.body.orderedSourceJobIds
|
||||
: [];
|
||||
logger.info('post:multipart-merge:reorder', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
orderedCount: orderedSourceJobIds.length
|
||||
});
|
||||
const job = await pipelineService.updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/multipart-merge/:jobId/settings',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const deleteInputsAfterMerge = Boolean(req.body?.deleteInputsAfterMerge);
|
||||
logger.info('post:multipart-merge:settings', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
deleteInputsAfterMerge
|
||||
});
|
||||
const job = await pipelineService.updateMultipartMergeSettings(jobId, {
|
||||
deleteInputsAfterMerge
|
||||
});
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/multipart-merge/:jobId/preview',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('get:multipart-merge:preview', {
|
||||
reqId: req.reqId,
|
||||
jobId
|
||||
});
|
||||
const preview = await pipelineService.getMultipartMergePreview(jobId);
|
||||
res.json({ preview });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/multipart-merge/:containerJobId/restore',
|
||||
asyncHandler(async (req, res) => {
|
||||
const containerJobId = Number(req.params.containerJobId);
|
||||
logger.info('post:multipart-merge:restore', {
|
||||
reqId: req.reqId,
|
||||
containerJobId
|
||||
});
|
||||
const job = await pipelineService.restoreMultipartMergeJobForContainer(containerJobId);
|
||||
await pipelineService.emitQueueChanged().catch((error) => {
|
||||
logger.warn('post:multipart-merge:restore:queue-emit-failed', {
|
||||
reqId: req.reqId,
|
||||
containerJobId,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
});
|
||||
res.json({
|
||||
result: {
|
||||
restored: true,
|
||||
mergeJobId: Number(job?.id || 0) || null
|
||||
},
|
||||
job
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/confirm-encode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const hasSelectedUserPresetId = Object.prototype.hasOwnProperty.call(body, 'selectedUserPresetId');
|
||||
const hasSelectedHandBrakePreset = Object.prototype.hasOwnProperty.call(body, 'selectedHandBrakePreset');
|
||||
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||
const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null;
|
||||
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||
const episodeAssignments = req.body?.episodeAssignments ?? null;
|
||||
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
|
||||
const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds;
|
||||
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
|
||||
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
|
||||
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
|
||||
const selectedUserPresetId = hasSelectedUserPresetId
|
||||
? body.selectedUserPresetId
|
||||
: undefined;
|
||||
const selectedHandBrakePreset = hasSelectedHandBrakePreset
|
||||
? body.selectedHandBrakePreset
|
||||
: undefined;
|
||||
logger.info('post:confirm-encode', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
selectedEncodeTitleId,
|
||||
selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0,
|
||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||
episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'),
|
||||
skipPipelineStateUpdate,
|
||||
selectedUserPresetId,
|
||||
selectedHandBrakePreset,
|
||||
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
|
||||
? selectedPostEncodeScriptIds.length
|
||||
: 0,
|
||||
selectedPreEncodeScriptIdsCount: Array.isArray(selectedPreEncodeScriptIds)
|
||||
? selectedPreEncodeScriptIds.length
|
||||
: 0,
|
||||
selectedPostEncodeChainIdsCount: Array.isArray(selectedPostEncodeChainIds)
|
||||
? selectedPostEncodeChainIds.length
|
||||
: 0,
|
||||
selectedPreEncodeChainIdsCount: Array.isArray(selectedPreEncodeChainIds)
|
||||
? selectedPreEncodeChainIds.length
|
||||
: 0
|
||||
});
|
||||
const job = await pipelineService.confirmEncodeReview(jobId, {
|
||||
selectedEncodeTitleId,
|
||||
selectedEncodeTitleIds,
|
||||
selectedTrackSelection,
|
||||
episodeAssignments,
|
||||
selectedPostEncodeScriptIds,
|
||||
selectedPreEncodeScriptIds,
|
||||
selectedPostEncodeChainIds,
|
||||
selectedPreEncodeChainIds,
|
||||
skipPipelineStateUpdate,
|
||||
selectedUserPresetId,
|
||||
selectedHandBrakePreset
|
||||
});
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cancel',
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawJobId = req.body?.jobId;
|
||||
const jobId = rawJobId === null || rawJobId === undefined || String(rawJobId).trim() === ''
|
||||
? null
|
||||
: Number(rawJobId);
|
||||
logger.warn('post:cancel', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.cancel(jobId);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/retry/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const createNewJob = Boolean(req.body?.createNewJob);
|
||||
logger.info('post:retry', { reqId: req.reqId, jobId, createNewJob });
|
||||
const result = await pipelineService.retry(jobId, { createNewJob });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/resume-ready/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:resume-ready', { reqId: req.reqId, jobId });
|
||||
const job = await pipelineService.resumeReadyToEncodeJob(jobId);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/output-folders/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const folders = await historyService.getJobOutputFoldersForLineage(jobId);
|
||||
res.json({ folders });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/delete-output-folders/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const folderPaths = Array.isArray(req.body?.folderPaths) ? req.body.folderPaths : [];
|
||||
logger.info('post:delete-output-folders', { reqId: req.reqId, jobId, count: folderPaths.length });
|
||||
const result = await historyService.deleteSpecificOutputFolders(jobId, folderPaths);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/reencode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:reencode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.reencodeFromRaw(jobId, { keepBoth, deleteFolders });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/restart-review/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
const createNewJob = Boolean(req.body?.createNewJob);
|
||||
const reuseCurrentJob = Object.prototype.hasOwnProperty.call(req.body || {}, 'reuseCurrentJob')
|
||||
? Boolean(req.body?.reuseCurrentJob)
|
||||
: !createNewJob;
|
||||
logger.info('post:restart-review', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
keepBoth,
|
||||
createNewJob,
|
||||
reuseCurrentJob,
|
||||
deleteFolderCount: deleteFolders.length
|
||||
});
|
||||
const result = await pipelineService.restartReviewFromRaw(jobId, {
|
||||
keepBoth,
|
||||
deleteFolders,
|
||||
reuseCurrentJob,
|
||||
createNewJob
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/restart-encode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
|
||||
const createNewJob = Boolean(req.body?.createNewJob);
|
||||
logger.info('post:restart-encode', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
keepBoth,
|
||||
createNewJob,
|
||||
restartMode,
|
||||
deleteFolderCount: deleteFolders.length
|
||||
});
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
|
||||
keepBoth,
|
||||
deleteFolders,
|
||||
restartMode,
|
||||
createNewJob
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/restart-cd-review/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:restart-cd-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.restartCdReviewFromRaw(jobId, { keepBoth, deleteFolders });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/queue',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:queue', { reqId: req.reqId });
|
||||
const queue = await pipelineService.getQueueSnapshot();
|
||||
res.json({ queue });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/queue/reorder',
|
||||
asyncHandler(async (req, res) => {
|
||||
// Accept orderedEntryIds (new) or orderedJobIds (legacy fallback for job-only queues).
|
||||
const orderedEntryIds = Array.isArray(req.body?.orderedEntryIds)
|
||||
? req.body.orderedEntryIds
|
||||
: (Array.isArray(req.body?.orderedJobIds) ? req.body.orderedJobIds : []);
|
||||
logger.info('post:queue:reorder', { reqId: req.reqId, orderedEntryIds });
|
||||
const queue = await pipelineService.reorderQueue(orderedEntryIds);
|
||||
res.json({ queue });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/queue/entry',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { type, scriptId, chainId, waitSeconds, insertAfterEntryId } = req.body || {};
|
||||
logger.info('post:queue:entry', { reqId: req.reqId, type });
|
||||
const result = await pipelineService.enqueueNonJobEntry(
|
||||
type,
|
||||
{ scriptId, chainId, waitSeconds },
|
||||
insertAfterEntryId ?? null
|
||||
);
|
||||
const queue = await pipelineService.getQueueSnapshot();
|
||||
res.json({ result, queue });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/queue/entry/:entryId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const entryId = req.params.entryId;
|
||||
logger.info('delete:queue:entry', { reqId: req.reqId, entryId });
|
||||
const queue = await pipelineService.removeQueueEntry(entryId);
|
||||
res.json({ queue });
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,69 @@
|
||||
const express = require('express');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const runtimeActivityService = require('../services/runtimeActivityService');
|
||||
const logger = require('../services/logger').child('RUNTIME_ROUTE');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/activities',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:runtime:activities', { reqId: req.reqId });
|
||||
const snapshot = runtimeActivityService.getSnapshot();
|
||||
res.json(snapshot);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/activities/:id/cancel',
|
||||
asyncHandler(async (req, res) => {
|
||||
const activityId = Number(req.params.id);
|
||||
const reason = String(req.body?.reason || '').trim() || null;
|
||||
logger.info('post:runtime:activities:cancel', { reqId: req.reqId, activityId, reason });
|
||||
const action = await runtimeActivityService.requestCancel(activityId, { reason });
|
||||
if (!action?.ok) {
|
||||
const error = new Error(action?.message || 'Abbrechen fehlgeschlagen.');
|
||||
error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409;
|
||||
throw error;
|
||||
}
|
||||
res.json({
|
||||
ok: true,
|
||||
action: action.result || null,
|
||||
snapshot: runtimeActivityService.getSnapshot()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/activities/:id/next-step',
|
||||
asyncHandler(async (req, res) => {
|
||||
const activityId = Number(req.params.id);
|
||||
logger.info('post:runtime:activities:next-step', { reqId: req.reqId, activityId });
|
||||
const action = await runtimeActivityService.requestNextStep(activityId, {});
|
||||
if (!action?.ok) {
|
||||
const error = new Error(action?.message || 'Nächster Schritt fehlgeschlagen.');
|
||||
error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409;
|
||||
throw error;
|
||||
}
|
||||
res.json({
|
||||
ok: true,
|
||||
action: action.result || null,
|
||||
snapshot: runtimeActivityService.getSnapshot()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/activities/clear-recent',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:runtime:activities:clear-recent', { reqId: req.reqId });
|
||||
const result = runtimeActivityService.clearRecent();
|
||||
res.json({
|
||||
ok: true,
|
||||
removed: Number(result?.removed || 0),
|
||||
snapshot: result?.snapshot || runtimeActivityService.getSnapshot()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,667 @@
|
||||
const express = require('express');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const settingsService = require('../services/settingsService');
|
||||
const scriptService = require('../services/scriptService');
|
||||
const scriptChainService = require('../services/scriptChainService');
|
||||
const notificationService = require('../services/notificationService');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const wsService = require('../services/websocketService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const userPresetService = require('../services/userPresetService');
|
||||
const userPresetDefaultsService = require('../services/userPresetDefaultsService');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
|
||||
const { fetchCurrentBetaKey, getCachedBetaKey } = require('../services/makemkvKeyService');
|
||||
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||
|
||||
const { getDb } = require('../db/database');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isSensitiveSettingKey(key) {
|
||||
const normalized = String(key || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized);
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:settings', { reqId: req.reqId });
|
||||
const categories = await settingsService.getCategorizedSettings();
|
||||
res.json({ categories });
|
||||
})
|
||||
);
|
||||
|
||||
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) => {
|
||||
logger.debug('get:settings:handbrake-presets', { reqId: req.reqId });
|
||||
const presets = await settingsService.getHandBrakePresetOptions();
|
||||
res.json(presets);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/makemkv/beta-key',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
|
||||
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: false });
|
||||
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
|
||||
const cached = await getCachedBetaKey({ allowExpired: true });
|
||||
|
||||
res.json({
|
||||
betaKey: cached?.key || '',
|
||||
sourceUrl: cached?.sourceUrl || null,
|
||||
validUntil: cached?.validUntil || null,
|
||||
fetchedAt: cached?.fetchedAt || null,
|
||||
cached: cached?.cached === true,
|
||||
stale: cached?.stale === true,
|
||||
appliedKey: existingKey || null,
|
||||
appliedMatchesCache: Boolean(cached?.key) && existingKey === cached.key
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/makemkv/beta-key/check',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:settings:makemkv:beta-key:check', { reqId: req.reqId });
|
||||
const betaKeyResult = await fetchCurrentBetaKey({ forceRefresh: true });
|
||||
res.json({
|
||||
betaKey: betaKeyResult.key,
|
||||
sourceUrl: betaKeyResult.sourceUrl,
|
||||
validUntil: betaKeyResult.validUntil || null,
|
||||
fetchedAt: betaKeyResult.fetchedAt || null,
|
||||
cached: betaKeyResult.cached === true,
|
||||
stale: betaKeyResult.stale === true
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/makemkv/beta-key/apply',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId });
|
||||
const requestedKey = String(req.body?.betaKey || '').trim();
|
||||
const cachedResult = requestedKey
|
||||
? { key: requestedKey }
|
||||
: await getCachedBetaKey({ allowExpired: true });
|
||||
const betaKey = String(cachedResult?.key || '').trim();
|
||||
|
||||
if (!betaKey) {
|
||||
const error = new Error('Kein geprüfter Betakey vorhanden. Bitte zuerst prüfen.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKey);
|
||||
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||
|
||||
res.json({
|
||||
applied: true,
|
||||
setting: updated,
|
||||
betaKey,
|
||||
sourceUrl: String(cachedResult?.sourceUrl || '').trim() || null
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/scripts',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:settings:scripts', { reqId: req.reqId });
|
||||
const scripts = await scriptService.listScripts();
|
||||
res.json({ scripts });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/scripts',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('post:settings:scripts:create', {
|
||||
reqId: req.reqId,
|
||||
name: String(payload?.name || '').trim() || null,
|
||||
scriptBodyLength: String(payload?.scriptBody || '').length
|
||||
});
|
||||
const script = await scriptService.createScript(payload);
|
||||
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'created', id: script.id });
|
||||
res.status(201).json({ script });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/scripts/reorder',
|
||||
asyncHandler(async (req, res) => {
|
||||
const orderedScriptIds = Array.isArray(req.body?.orderedScriptIds) ? req.body.orderedScriptIds : [];
|
||||
logger.info('post:settings:scripts:reorder', {
|
||||
reqId: req.reqId,
|
||||
count: orderedScriptIds.length
|
||||
});
|
||||
const scripts = await scriptService.reorderScripts(orderedScriptIds);
|
||||
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'reordered', count: scripts.length });
|
||||
res.json({ scripts });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/scripts/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const scriptId = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('put:settings:scripts:update', {
|
||||
reqId: req.reqId,
|
||||
scriptId,
|
||||
name: String(payload?.name || '').trim() || null,
|
||||
scriptBodyLength: String(payload?.scriptBody || '').length
|
||||
});
|
||||
const script = await scriptService.updateScript(scriptId, payload);
|
||||
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'updated', id: script.id });
|
||||
res.json({ script });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/scripts/:id/favorite',
|
||||
asyncHandler(async (req, res) => {
|
||||
const scriptId = Number(req.params.id);
|
||||
const isFavorite = req.body?.isFavorite === true;
|
||||
logger.info('put:settings:scripts:favorite', {
|
||||
reqId: req.reqId,
|
||||
scriptId,
|
||||
isFavorite
|
||||
});
|
||||
const script = await scriptService.setScriptFavorite(scriptId, isFavorite);
|
||||
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', {
|
||||
action: 'favorite-updated',
|
||||
id: script.id,
|
||||
isFavorite: script.isFavorite === true
|
||||
});
|
||||
res.json({ script });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/scripts/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const scriptId = Number(req.params.id);
|
||||
logger.info('delete:settings:scripts', {
|
||||
reqId: req.reqId,
|
||||
scriptId
|
||||
});
|
||||
const removed = await scriptService.deleteScript(scriptId);
|
||||
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'deleted', id: removed.id });
|
||||
res.json({ removed });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/scripts/:id/test',
|
||||
asyncHandler(async (req, res) => {
|
||||
const scriptId = Number(req.params.id);
|
||||
logger.info('post:settings:scripts:test', {
|
||||
reqId: req.reqId,
|
||||
scriptId
|
||||
});
|
||||
const result = await scriptService.testScript(scriptId);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/script-chains/:id/test',
|
||||
asyncHandler(async (req, res) => {
|
||||
const chainId = Number(req.params.id);
|
||||
logger.info('post:settings:script-chains:test', { reqId: req.reqId, chainId });
|
||||
const result = await scriptChainService.executeChain(chainId, { source: 'settings_test', mode: 'test' });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/script-chains',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:settings:script-chains', { reqId: req.reqId });
|
||||
const chains = await scriptChainService.listChains();
|
||||
res.json({ chains });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/script-chains',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('post:settings:script-chains:create', { reqId: req.reqId, name: payload?.name });
|
||||
const chain = await scriptChainService.createChain(payload);
|
||||
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'created', id: chain.id });
|
||||
res.status(201).json({ chain });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/script-chains/reorder',
|
||||
asyncHandler(async (req, res) => {
|
||||
const orderedChainIds = Array.isArray(req.body?.orderedChainIds) ? req.body.orderedChainIds : [];
|
||||
logger.info('post:settings:script-chains:reorder', {
|
||||
reqId: req.reqId,
|
||||
count: orderedChainIds.length
|
||||
});
|
||||
const chains = await scriptChainService.reorderChains(orderedChainIds);
|
||||
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'reordered', count: chains.length });
|
||||
res.json({ chains });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/script-chains/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const chainId = Number(req.params.id);
|
||||
logger.debug('get:settings:script-chains:one', { reqId: req.reqId, chainId });
|
||||
const chain = await scriptChainService.getChainById(chainId);
|
||||
res.json({ chain });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/script-chains/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const chainId = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('put:settings:script-chains:update', { reqId: req.reqId, chainId, name: payload?.name });
|
||||
const chain = await scriptChainService.updateChain(chainId, payload);
|
||||
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'updated', id: chain.id });
|
||||
res.json({ chain });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/script-chains/:id/favorite',
|
||||
asyncHandler(async (req, res) => {
|
||||
const chainId = Number(req.params.id);
|
||||
const isFavorite = req.body?.isFavorite === true;
|
||||
logger.info('put:settings:script-chains:favorite', { reqId: req.reqId, chainId, isFavorite });
|
||||
const chain = await scriptChainService.setChainFavorite(chainId, isFavorite);
|
||||
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', {
|
||||
action: 'favorite-updated',
|
||||
id: chain.id,
|
||||
isFavorite: chain.isFavorite === true
|
||||
});
|
||||
res.json({ chain });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/script-chains/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const chainId = Number(req.params.id);
|
||||
logger.info('delete:settings:script-chains', { reqId: req.reqId, chainId });
|
||||
const removed = await scriptChainService.deleteChain(chainId);
|
||||
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'deleted', id: removed.id });
|
||||
res.json({ removed });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/coverart/recover',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:settings:coverart:recover', { reqId: req.reqId });
|
||||
const result = await coverArtRecoveryService.runNow({
|
||||
trigger: 'manual',
|
||||
force: true,
|
||||
logFailures: true
|
||||
});
|
||||
res.json({
|
||||
result,
|
||||
scheduler: coverArtRecoveryService.getStatus()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// ── User Presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const mediaType = req.query.media_type || null;
|
||||
logger.debug('get:user-presets', { reqId: req.reqId, mediaType });
|
||||
const presets = await userPresetService.listPresets(mediaType);
|
||||
res.json({ presets });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/user-preset-defaults',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:user-preset-defaults', { reqId: req.reqId });
|
||||
const defaults = await userPresetDefaultsService.listDefaults();
|
||||
res.json({ defaults });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/user-preset-defaults',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('put:user-preset-defaults:update', { reqId: req.reqId });
|
||||
const defaults = await userPresetDefaultsService.updateDefaults(payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'defaults-updated' });
|
||||
res.json({ defaults });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name });
|
||||
const preset = await userPresetService.createPreset(payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id });
|
||||
res.status(201).json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('put:user-presets:update', { reqId: req.reqId, presetId });
|
||||
const preset = await userPresetService.updatePreset(presetId, payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id });
|
||||
res.json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
logger.info('delete:user-presets', { reqId: req.reqId, presetId });
|
||||
const removed = await userPresetService.deletePreset(presetId);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id });
|
||||
res.json({ removed });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { key } = req.params;
|
||||
const { value } = req.body;
|
||||
|
||||
logger.info('put:setting', {
|
||||
reqId: req.reqId,
|
||||
key,
|
||||
value: isSensitiveSettingKey(key) ? '[redacted]' : value
|
||||
});
|
||||
const updated = await settingsService.setSettingValue(key, value);
|
||||
let reviewRefresh = null;
|
||||
try {
|
||||
reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave([key]);
|
||||
if (reviewRefresh?.triggered) {
|
||||
logger.info('put:setting:review-refresh-started', {
|
||||
reqId: req.reqId,
|
||||
key,
|
||||
jobId: reviewRefresh.jobId
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('put:setting:review-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
key,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
reviewRefresh = {
|
||||
triggered: false,
|
||||
reason: 'refresh_error',
|
||||
message: error?.message || 'unknown'
|
||||
};
|
||||
}
|
||||
try {
|
||||
await hardwareMonitorService.handleSettingsChanged([key]);
|
||||
} catch (error) {
|
||||
logger.warn('put:setting:hardware-monitor-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
key,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await coverArtRecoveryService.handleSettingsChanged([key]);
|
||||
} catch (error) {
|
||||
logger.warn('put:setting:coverart-scheduler-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
key,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
}
|
||||
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||
|
||||
res.json({ setting: updated, reviewRefresh });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { settings } = req.body || {};
|
||||
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
||||
const error = new Error('settings fehlt oder ist ungültig.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('put:settings:bulk', { reqId: req.reqId, count: Object.keys(settings).length });
|
||||
const changes = await settingsService.setSettingsBulk(settings);
|
||||
let reviewRefresh = null;
|
||||
try {
|
||||
reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave(changes.map((item) => item.key));
|
||||
if (reviewRefresh?.triggered) {
|
||||
logger.info('put:settings:bulk:review-refresh-started', {
|
||||
reqId: req.reqId,
|
||||
jobId: reviewRefresh.jobId,
|
||||
relevantKeys: reviewRefresh.relevantKeys
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('put:settings:bulk:review-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
reviewRefresh = {
|
||||
triggered: false,
|
||||
reason: 'refresh_error',
|
||||
message: error?.message || 'unknown'
|
||||
};
|
||||
}
|
||||
try {
|
||||
await hardwareMonitorService.handleSettingsChanged(changes.map((item) => item.key));
|
||||
} catch (error) {
|
||||
logger.warn('put:settings:bulk:hardware-monitor-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await coverArtRecoveryService.handleSettingsChanged(changes.map((item) => item.key));
|
||||
} catch (error) {
|
||||
logger.warn('put:settings:bulk:coverart-scheduler-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
}
|
||||
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
|
||||
|
||||
res.json({ changes, reviewRefresh });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/pushover/test',
|
||||
asyncHandler(async (req, res) => {
|
||||
const title = req.body?.title;
|
||||
const message = req.body?.message;
|
||||
logger.info('post:pushover:test', {
|
||||
reqId: req.reqId,
|
||||
hasTitle: Boolean(title),
|
||||
hasMessage: Boolean(message)
|
||||
});
|
||||
const result = await notificationService.sendTest({ title, message });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
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 });
|
||||
})
|
||||
);
|
||||
|
||||
// ── Optical drive scan ────────────────────────────────────────────────────────
|
||||
router.get(
|
||||
'/drives',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:settings:drives', { reqId: req.reqId });
|
||||
const devices = await diskDetectionService.getBlockDeviceInfo();
|
||||
const drives = devices
|
||||
.filter((d) => d.type === 'rom')
|
||||
.map((d) => {
|
||||
const name = String(d.name || '');
|
||||
const devicePath = d.path || (name ? `/dev/${name}` : null);
|
||||
const resolvedIndex = Number(d.makemkvIndex);
|
||||
const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0
|
||||
? Math.trunc(resolvedIndex)
|
||||
: null;
|
||||
return {
|
||||
path: devicePath,
|
||||
name,
|
||||
model: d.model || null,
|
||||
discIndex,
|
||||
discArg: discIndex != null ? `disc:${discIndex}` : null
|
||||
};
|
||||
})
|
||||
.filter((d) => d.path);
|
||||
res.json({ drives });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/drives/force-unlock',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { devicePath, all } = req.body || {};
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase());
|
||||
if (!expertMode) {
|
||||
const error = new Error('Expertenmodus erforderlich.');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const devices = await diskDetectionService.getBlockDeviceInfo();
|
||||
const drives = devices
|
||||
.filter((d) => d.type === 'rom')
|
||||
.map((d) => {
|
||||
const name = String(d.name || '');
|
||||
return d.path || (name ? `/dev/${name}` : null);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const activeLockPaths = (diskDetectionService.getActiveLocks?.() || [])
|
||||
.map((entry) => String(entry?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const targetPaths = all
|
||||
? Array.from(new Set([...drives, ...activeLockPaths]))
|
||||
: [String(devicePath || '').trim()].filter(Boolean);
|
||||
if (targetPaths.length === 0) {
|
||||
const error = new Error('Kein Laufwerk angegeben.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const target of targetPaths) {
|
||||
const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' });
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
// User preferences (UI state, persisted per-installation)
|
||||
router.get(
|
||||
'/prefs/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { key } = req.params;
|
||||
const db = await getDb();
|
||||
const row = await db.get('SELECT value FROM user_prefs WHERE key = ?', [key]);
|
||||
res.json({ key, value: row ? row.value : null });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/prefs/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { key } = req.params;
|
||||
const { value } = req.body || {};
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
`INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
[key, value ?? null]
|
||||
);
|
||||
res.json({ key, value: value ?? null });
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNameList(items) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const names = items
|
||||
.map((item) => String(item?.name || '').trim())
|
||||
.filter(Boolean);
|
||||
return names.length > 0 ? names.join(', ') : null;
|
||||
}
|
||||
|
||||
async function fetchBookByAsin(asin, region = 'de') {
|
||||
const normalizedAsin = normalizeAsin(asin);
|
||||
if (!normalizedAsin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}`);
|
||||
url.searchParams.set('region', String(region || 'de').trim() || 'de');
|
||||
logger.info('book:fetch:start', { asin: normalizedAsin, url: url.toString() });
|
||||
|
||||
const payload = await audnexFetch(url.toString());
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const narrator = normalizeNameList(payload.narrators);
|
||||
const author = normalizeNameList(payload.authors);
|
||||
const title = String(payload.title || '').trim() || null;
|
||||
const series = String(payload.seriesName || payload.series || '').trim() || null;
|
||||
const part = String(payload.seriesPart || payload.part || '').trim() || null;
|
||||
const description = String(payload.summary || payload.description || '').trim() || null;
|
||||
const year = payload.releaseDate
|
||||
? String(payload.releaseDate).slice(0, 4)
|
||||
: null;
|
||||
|
||||
logger.info('book:fetch:done', { asin: normalizedAsin, narrator, author, title });
|
||||
return { narrator, author, title, series, part, description, year };
|
||||
}
|
||||
|
||||
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,
|
||||
fetchBookByAsin,
|
||||
fetchChaptersByAsin
|
||||
};
|
||||
@@ -0,0 +1,784 @@
|
||||
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, transliterateForFilename } = 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 = [];
|
||||
const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g;
|
||||
const tocTablePattern =
|
||||
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i;
|
||||
|
||||
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(tocTimePattern, ' ');
|
||||
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(tocTablePattern);
|
||||
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 = transliterateForFilename(String(value == null ? '' : value))
|
||||
.replace(/[\\/:*?"<>|]/g, '-')
|
||||
.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++) {
|
||||
const outputPart = outputSegments[offset + i];
|
||||
const relativePart = relativeSegments[i];
|
||||
if (outputPart === relativePart) {
|
||||
continue;
|
||||
}
|
||||
// Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2").
|
||||
// Treat this as containing the same relative directory to avoid nesting:
|
||||
// Album (2007)_2/Album (2007)/...
|
||||
const isLastRelativeSegment = i === relativeSegments.length - 1;
|
||||
const matchesNumberedSuffix = isLastRelativeSegment
|
||||
&& outputPart.startsWith(`${relativePart}_`)
|
||||
&& /^\d+$/.test(outputPart.slice(relativePart.length + 1));
|
||||
if (!matchesNumberedSuffix) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir.
|
||||
* Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files).
|
||||
*/
|
||||
function getSortedPlainWavFiles(rawWavDir) {
|
||||
try {
|
||||
return fs.readdirSync(rawWavDir)
|
||||
.filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the WAV file path for a given track position.
|
||||
* Primary: track01.cdda.wav (cdparanoia format)
|
||||
* Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames)
|
||||
*/
|
||||
function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) {
|
||||
const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`);
|
||||
if (fs.existsSync(cddaPath)) return cddaPath;
|
||||
if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) {
|
||||
const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]);
|
||||
if (fs.existsSync(fallback)) return fallback;
|
||||
}
|
||||
return cddaPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {boolean} options.skipEncode - true => rip only (no encode)
|
||||
* @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,
|
||||
skipRip = false,
|
||||
skipEncode = false
|
||||
} = options;
|
||||
|
||||
if (!skipEncode && !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);
|
||||
if (!skipEncode) {
|
||||
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);
|
||||
};
|
||||
const ripPercentSpan = skipEncode ? 100 : 50;
|
||||
const encodePercentStart = ripPercentSpan;
|
||||
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
|
||||
|
||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||
const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null;
|
||||
|
||||
if (skipRip) {
|
||||
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
|
||||
for (const track of tracksToRip) {
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
if (!fs.existsSync(wavFile)) {
|
||||
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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) * ripPercentSpan
|
||||
});
|
||||
|
||||
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) * ripPercentSpan;
|
||||
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) * ripPercentSpan
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} gerippt.`);
|
||||
}
|
||||
} // end if (!skipRip)
|
||||
|
||||
if (skipEncode) {
|
||||
return {
|
||||
outputDir: null,
|
||||
format: 'raw',
|
||||
trackCount: tracksToRip.length,
|
||||
encodeResults: []
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||
const encodeResults = [];
|
||||
|
||||
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 = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
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: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||
});
|
||||
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: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||
});
|
||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
|
||||
}
|
||||
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
|
||||
}
|
||||
|
||||
for (let i = 0; i < tracksToRip.length; i++) {
|
||||
assertNotCancelled(isCancelled);
|
||||
const track = tracksToRip[i];
|
||||
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
|
||||
|
||||
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: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false });
|
||||
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: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||
});
|
||||
|
||||
log('info', `Track ${track.position} encodiert.`);
|
||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: true });
|
||||
}
|
||||
|
||||
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,697 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getDb } = require('../db/database');
|
||||
const settingsService = require('./settingsService');
|
||||
const wsService = require('./websocketService');
|
||||
const logger = require('./logger').child('CONVERTER_SCAN');
|
||||
const {
|
||||
defaultConverterRawDir
|
||||
} = require('../config');
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
|
||||
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
|
||||
const ISO_EXTENSIONS = new Set(['iso']);
|
||||
const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
|
||||
|
||||
let _pollingTimer = null;
|
||||
let _pollingEnabled = false;
|
||||
let _pollingInterval = 300;
|
||||
|
||||
function detectMediaType(fileName) {
|
||||
const ext = path.extname(String(fileName || '')).slice(1).toLowerCase();
|
||||
if (ISO_EXTENSIONS.has(ext)) return 'iso';
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
||||
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectFormat(fileName) {
|
||||
return path.extname(String(fileName || '')).slice(1).toLowerCase() || null;
|
||||
}
|
||||
|
||||
function getConfiguredExtensions(settings) {
|
||||
const raw = String(settings?.converter_scan_extensions || '').trim();
|
||||
if (!raw) {
|
||||
return new Set(SUPPORTED_SCAN_EXTENSIONS);
|
||||
}
|
||||
const configured = raw.split(',')
|
||||
.map((ext) => ext.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const filtered = configured.filter((ext) => SUPPORTED_SCAN_EXTENSIONS.has(ext));
|
||||
if (filtered.length === 0) {
|
||||
return new Set(SUPPORTED_SCAN_EXTENSIONS);
|
||||
}
|
||||
return new Set(filtered);
|
||||
}
|
||||
|
||||
function getFileSize(fullPath) {
|
||||
try {
|
||||
return fs.statSync(fullPath).size;
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SCAN_MAX_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* Rekursiv alle Dateien und direkten Unterordner im rawDir scannen.
|
||||
* Gibt ein flaches Array von { relPath, entryType, fileSize, detectedMediaType, detectedFormat } zurück.
|
||||
* Maximale Tiefe: SCAN_MAX_DEPTH (verhindert unendliche Rekursion bei geschachtelten Ordnern).
|
||||
*/
|
||||
function scanDirectory(rawDir, allowedExtensions, parentRelPath = '', depth = 0) {
|
||||
const entries = [];
|
||||
|
||||
if (depth >= SCAN_MAX_DEPTH) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
||||
} catch (_err) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
for (const dirent of dirEntries) {
|
||||
const relPath = parentRelPath ? `${parentRelPath}/${dirent.name}` : dirent.name;
|
||||
const fullPath = path.join(rawDir, relPath);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
entries.push({
|
||||
relPath,
|
||||
entryType: 'directory',
|
||||
fileSize: null,
|
||||
detectedMediaType: null,
|
||||
detectedFormat: null
|
||||
});
|
||||
// Rekursiv in Unterordner (mit Tiefenbegrenzung)
|
||||
const subEntries = scanDirectory(rawDir, allowedExtensions, relPath, depth + 1);
|
||||
entries.push(...subEntries);
|
||||
} else if (dirent.isFile()) {
|
||||
const ext = path.extname(dirent.name).slice(1).toLowerCase();
|
||||
if (!allowedExtensions.has(ext)) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
relPath,
|
||||
entryType: 'file',
|
||||
fileSize: getFileSize(fullPath),
|
||||
detectedMediaType: detectMediaType(dirent.name),
|
||||
detectedFormat: detectFormat(dirent.name)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan-Ergebnisse in die DB schreiben (INSERT OR REPLACE) und
|
||||
* nicht mehr vorhandene Einträge ohne Job entfernen.
|
||||
*/
|
||||
async function persistScanResults(rawDir, entries) {
|
||||
const db = await getDb();
|
||||
|
||||
if (entries.length > 0) {
|
||||
const stmt = await db.prepare(`
|
||||
INSERT INTO converter_scan_entries (rel_path, entry_type, file_size, detected_media_type, detected_format, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(rel_path) DO UPDATE SET
|
||||
entry_type = excluded.entry_type,
|
||||
file_size = excluded.file_size,
|
||||
detected_media_type = excluded.detected_media_type,
|
||||
detected_format = excluded.detected_format,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`);
|
||||
|
||||
for (const entry of entries) {
|
||||
await stmt.run(
|
||||
entry.relPath,
|
||||
entry.entryType,
|
||||
entry.fileSize,
|
||||
entry.detectedMediaType,
|
||||
entry.detectedFormat
|
||||
);
|
||||
}
|
||||
await stmt.finalize();
|
||||
}
|
||||
|
||||
// Einträge ohne zugewiesenen Job entfernen, wenn Datei nicht mehr vorhanden
|
||||
const existingEntries = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE job_id IS NULL`
|
||||
);
|
||||
const currentRelPaths = new Set(entries.map((e) => e.relPath));
|
||||
|
||||
for (const row of existingEntries) {
|
||||
if (!currentRelPaths.has(row.rel_path)) {
|
||||
const fullPath = path.join(rawDir, row.rel_path);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
await db.run(
|
||||
`DELETE FROM converter_scan_entries WHERE rel_path = ? AND job_id IS NULL`,
|
||||
[row.rel_path]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hauptmethode: rawDir scannen, DB aktualisieren, WebSocket-Event senden.
|
||||
*/
|
||||
async function scan() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const rawDir = String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
||||
|
||||
if (!rawDir) {
|
||||
logger.warn('converter:scan:no-raw-dir');
|
||||
return { rawDir: null, entryCount: 0 };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(rawDir)) {
|
||||
logger.warn('converter:scan:dir-missing', { rawDir });
|
||||
return { rawDir, entryCount: 0 };
|
||||
}
|
||||
|
||||
const allowedExtensions = getConfiguredExtensions(settings);
|
||||
logger.info('converter:scan:start', { rawDir, allowedExtensions: [...allowedExtensions] });
|
||||
|
||||
const entries = scanDirectory(rawDir, allowedExtensions);
|
||||
await persistScanResults(rawDir, entries);
|
||||
|
||||
logger.info('converter:scan:done', { rawDir, entryCount: entries.length });
|
||||
|
||||
wsService.broadcast('CONVERTER_SCAN_UPDATE', { entryCount: entries.length });
|
||||
|
||||
return { rawDir, entryCount: entries.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Einträge für den File-Explorer zurückgeben.
|
||||
* Optionaler parentRelPath filtert auf Kinder eines bestimmten Verzeichnisses.
|
||||
*/
|
||||
async function getEntries(parentRelPath = null) {
|
||||
const db = await getDb();
|
||||
|
||||
let rows;
|
||||
if (!parentRelPath) {
|
||||
// Root-Ebene: nur Einträge ohne '/' im rel_path
|
||||
rows = await db.all(`
|
||||
SELECT e.*, j.status AS job_status, j.title AS job_title
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
ORDER BY e.entry_type DESC, e.rel_path ASC
|
||||
`);
|
||||
// Nur direkte Kinder (kein '/' im rel_path)
|
||||
rows = rows.filter((r) => !String(r.rel_path).includes('/'));
|
||||
} else {
|
||||
const prefix = parentRelPath.endsWith('/') ? parentRelPath : `${parentRelPath}/`;
|
||||
rows = await db.all(`
|
||||
SELECT e.*, j.status AS job_status, j.title AS job_title
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
ORDER BY e.entry_type DESC, e.rel_path ASC
|
||||
`);
|
||||
// Direkte Kinder des angegebenen Verzeichnisses
|
||||
rows = rows.filter((r) => {
|
||||
const rel = String(r.rel_path);
|
||||
if (!rel.startsWith(prefix)) return false;
|
||||
const remainder = rel.slice(prefix.length);
|
||||
return !remainder.includes('/');
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
relPath: r.rel_path,
|
||||
entryType: r.entry_type,
|
||||
fileSize: r.file_size,
|
||||
detectedMediaType: r.detected_media_type,
|
||||
detectedFormat: r.detected_format,
|
||||
jobId: r.job_id,
|
||||
jobStatus: r.job_status || null,
|
||||
jobTitle: r.job_title || null,
|
||||
lastSeenAt: r.last_seen_at
|
||||
}));
|
||||
}
|
||||
|
||||
async function getEntryById(id) {
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`SELECT * FROM converter_scan_entries WHERE id = ?`,
|
||||
[Number(id)]
|
||||
);
|
||||
return row || null;
|
||||
}
|
||||
|
||||
async function getEntryByRelPath(relPath) {
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`SELECT * FROM converter_scan_entries WHERE rel_path = ?`,
|
||||
[relPath]
|
||||
);
|
||||
return row || null;
|
||||
}
|
||||
|
||||
async function setEntryJobAssignment(relPath, jobId) {
|
||||
const normalizedRelPath = normalizeRelPath(relPath);
|
||||
if (normalizedRelPath === null || normalizedRelPath === '') {
|
||||
throw makeError('Ungültiger relPath für Job-Zuweisung.', 400);
|
||||
}
|
||||
const normalizedJobId = Number(jobId);
|
||||
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
||||
throw makeError('Ungültige jobId für Job-Zuweisung.', 400);
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const existing = await db.get(
|
||||
`SELECT entry_type, file_size, detected_media_type, detected_format
|
||||
FROM converter_scan_entries
|
||||
WHERE rel_path = ?`,
|
||||
[normalizedRelPath]
|
||||
);
|
||||
|
||||
let entryType = String(existing?.entry_type || 'file').trim() || 'file';
|
||||
let fileSize = existing?.file_size ?? null;
|
||||
let detectedMediaType = existing?.detected_media_type ?? null;
|
||||
let detectedFormat = existing?.detected_format ?? null;
|
||||
|
||||
const rawDir = await getRawDir();
|
||||
const absPath = rawDir ? path.join(rawDir, normalizedRelPath) : null;
|
||||
if (absPath && fs.existsSync(absPath)) {
|
||||
try {
|
||||
const stat = fs.statSync(absPath);
|
||||
entryType = stat.isDirectory() ? 'directory' : 'file';
|
||||
fileSize = stat.isFile() ? stat.size : null;
|
||||
if (stat.isFile()) {
|
||||
const fileName = path.basename(normalizedRelPath);
|
||||
detectedMediaType = detectMediaType(fileName);
|
||||
detectedFormat = detectFormat(fileName);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Keep existing metadata values if stat/read fails.
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO converter_scan_entries (
|
||||
rel_path,
|
||||
entry_type,
|
||||
file_size,
|
||||
detected_media_type,
|
||||
detected_format,
|
||||
job_id,
|
||||
last_seen_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(rel_path) DO UPDATE SET
|
||||
entry_type = excluded.entry_type,
|
||||
file_size = excluded.file_size,
|
||||
detected_media_type = excluded.detected_media_type,
|
||||
detected_format = excluded.detected_format,
|
||||
job_id = excluded.job_id,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`,
|
||||
[
|
||||
normalizedRelPath,
|
||||
entryType,
|
||||
fileSize,
|
||||
detectedMediaType,
|
||||
detectedFormat,
|
||||
Math.trunc(normalizedJobId)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function clearEntryJobAssignment(relPath, expectedJobId = null) {
|
||||
const normalizedRelPath = normalizeRelPath(relPath);
|
||||
if (normalizedRelPath === null || normalizedRelPath === '') {
|
||||
throw makeError('Ungültiger relPath für Job-Entfernung.', 400);
|
||||
}
|
||||
const db = await getDb();
|
||||
const normalizedExpected = Number(expectedJobId);
|
||||
if (Number.isFinite(normalizedExpected) && normalizedExpected > 0) {
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ? AND job_id = ?`,
|
||||
[normalizedRelPath, Math.trunc(normalizedExpected)]
|
||||
);
|
||||
return;
|
||||
}
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ?`,
|
||||
[normalizedRelPath]
|
||||
);
|
||||
}
|
||||
|
||||
async function clearAssignmentsForJob(jobId) {
|
||||
const normalizedJobId = Number(jobId);
|
||||
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
||||
throw makeError('Ungültige jobId für Job-Entfernung.', 400);
|
||||
}
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
`UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`,
|
||||
[Math.trunc(normalizedJobId)]
|
||||
);
|
||||
}
|
||||
|
||||
async function assignEntriesToJob(relPaths, jobId) {
|
||||
const normalizedPaths = Array.isArray(relPaths) ? relPaths : [];
|
||||
for (const relPath of normalizedPaths) {
|
||||
await setEntryJobAssignment(relPath, jobId);
|
||||
}
|
||||
}
|
||||
|
||||
async function markEntryAsJob(relPath, jobId) {
|
||||
await setEntryJobAssignment(relPath, jobId);
|
||||
}
|
||||
|
||||
async function getRawDir() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling-Loop starten.
|
||||
*/
|
||||
async function startPolling() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
_pollingEnabled = String(settings?.converter_polling_enabled || 'false').toLowerCase() === 'true';
|
||||
_pollingInterval = Math.max(30, Number(settings?.converter_polling_interval || 300)) * 1000;
|
||||
|
||||
stopPolling();
|
||||
|
||||
if (!_pollingEnabled) {
|
||||
logger.info('converter:polling:disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('converter:polling:start', { intervalMs: _pollingInterval });
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
await scan();
|
||||
} catch (error) {
|
||||
logger.error('converter:polling:error', { error: error?.message });
|
||||
}
|
||||
if (_pollingEnabled) {
|
||||
_pollingTimer = setTimeout(tick, _pollingInterval);
|
||||
}
|
||||
};
|
||||
|
||||
_pollingTimer = setTimeout(tick, _pollingInterval);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (_pollingTimer) {
|
||||
clearTimeout(_pollingTimer);
|
||||
_pollingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function restartPolling() {
|
||||
await startPolling();
|
||||
}
|
||||
|
||||
// ── Datei-Operationen (Löschen, Umbenennen, Verschieben, Ordner erstellen) ──
|
||||
|
||||
/**
|
||||
* Relativen Pfad normalisieren und auf Path-Traversal prüfen.
|
||||
* Gibt null zurück wenn der Pfad ungültig ist.
|
||||
*/
|
||||
function normalizeRelPath(input) {
|
||||
if (input === null || input === undefined) return '';
|
||||
const raw = String(input).replace(/\\/g, '/').trim();
|
||||
if (!raw || raw === '.') return '';
|
||||
if (raw.startsWith('/')) return null;
|
||||
const normalized = path.posix.normalize(raw);
|
||||
if (normalized.startsWith('..')) return null;
|
||||
if (normalized === '.') return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function makeError(msg, code) {
|
||||
const err = new Error(msg);
|
||||
err.statusCode = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner löschen. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function deleteEntry(relPath) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger oder leerer Pfad (Root kann nicht gelöscht werden).', 400);
|
||||
|
||||
const absPath = path.join(rawDir, rel);
|
||||
// Traversal-Schutz
|
||||
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht gelöscht werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absPath)) throw makeError(`Pfad existiert nicht: ${rel}`, 404);
|
||||
|
||||
fs.rmSync(absPath, { recursive: true, force: true });
|
||||
|
||||
await db.run(
|
||||
`DELETE FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
|
||||
return { deleted: rel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner umbenennen. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function renameEntry(relPath, newName) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
|
||||
|
||||
const safeName = String(newName || '').trim();
|
||||
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
|
||||
throw makeError('Ungültiger Name.', 400);
|
||||
}
|
||||
|
||||
const parentRel = path.posix.dirname(rel);
|
||||
const newRel = (parentRel === '.' || parentRel === '') ? safeName : `${parentRel}/${safeName}`;
|
||||
|
||||
const absOld = path.join(rawDir, rel);
|
||||
const absNew = path.join(rawDir, newRel);
|
||||
|
||||
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (!absNew.startsWith(rawDir + path.sep)) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht umbenannt werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
|
||||
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
|
||||
|
||||
fs.renameSync(absOld, absNew);
|
||||
|
||||
const rows = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const updatedRel = newRel + row.rel_path.slice(rel.length);
|
||||
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
|
||||
}
|
||||
|
||||
return { oldRelPath: rel, newRelPath: newRel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei oder Ordner in ein anderes Verzeichnis verschieben. Aktualisiert DB-Einträge.
|
||||
*/
|
||||
async function moveEntry(relPath, targetParentRelPath) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const rel = normalizeRelPath(relPath);
|
||||
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
|
||||
|
||||
const targetParentRel = normalizeRelPath(targetParentRelPath != null ? targetParentRelPath : '');
|
||||
if (targetParentRel === null) throw makeError('Ungültiger Zielpfad.', 400);
|
||||
|
||||
const name = path.posix.basename(rel);
|
||||
const newRel = targetParentRel === '' ? name : `${targetParentRel}/${name}`;
|
||||
|
||||
if (rel === newRel) throw makeError('Quelle und Ziel sind identisch.', 400);
|
||||
if (newRel.startsWith(`${rel}/`)) throw makeError('Kann nicht in eigenen Unterordner verschoben werden.', 400);
|
||||
|
||||
const absOld = path.join(rawDir, rel);
|
||||
const absNew = path.join(rawDir, newRel);
|
||||
|
||||
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (!absNew.startsWith(rawDir + path.sep) && absNew !== rawDir) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
|
||||
const db = await getDb();
|
||||
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
||||
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht verschoben werden.', 409);
|
||||
|
||||
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
|
||||
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
|
||||
|
||||
fs.renameSync(absOld, absNew);
|
||||
|
||||
const rows = await db.all(
|
||||
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
||||
[rel, `${rel}/%`]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const updatedRel = newRel + row.rel_path.slice(rel.length);
|
||||
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
|
||||
}
|
||||
|
||||
return { oldRelPath: rel, newRelPath: newRel, targetParentRelPath: targetParentRel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Ordner erstellen (kein DB-Eintrag — erscheint beim nächsten Scan).
|
||||
*/
|
||||
async function createFolder(parentRelPath, name) {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
||||
|
||||
const parentRel = normalizeRelPath(parentRelPath != null ? parentRelPath : '');
|
||||
if (parentRel === null) throw makeError('Ungültiger übergeordneter Pfad.', 400);
|
||||
|
||||
const safeName = String(name || '').trim();
|
||||
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
|
||||
throw makeError('Ungültiger Ordnername.', 400);
|
||||
}
|
||||
|
||||
const newRel = parentRel === '' ? safeName : `${parentRel}/${safeName}`;
|
||||
const absPath = path.join(rawDir, newRel);
|
||||
|
||||
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
|
||||
if (fs.existsSync(absPath)) throw makeError('Ordner existiert bereits.', 409);
|
||||
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
|
||||
return { relPath: newRel };
|
||||
}
|
||||
|
||||
// ── Reines FS-Baum-Listing (keine DB) ─────────────────────────────────────
|
||||
|
||||
const TREE_MAX_DEPTH = 8;
|
||||
|
||||
function buildRawTree(rawDir, relPath, depth, assignments = new Map()) {
|
||||
if (depth >= TREE_MAX_DEPTH) return [];
|
||||
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
|
||||
let dirents;
|
||||
try {
|
||||
dirents = fs.readdirSync(absDir, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
||||
dirents.sort((a, b) => {
|
||||
const ad = a.isDirectory() ? 0 : 1;
|
||||
const bd = b.isDirectory() ? 0 : 1;
|
||||
if (ad !== bd) return ad - bd;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
const nodes = [];
|
||||
for (const dirent of dirents) {
|
||||
// Versteckte Einträge überspringen
|
||||
if (dirent.name.startsWith('.')) continue;
|
||||
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
|
||||
if (dirent.isDirectory()) {
|
||||
const children = buildRawTree(rawDir, childRel, depth + 1, assignments);
|
||||
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
||||
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
|
||||
} else if (dirent.isFile()) {
|
||||
let size = 0;
|
||||
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
|
||||
const assignment = assignments.get(childRel) || null;
|
||||
nodes.push({
|
||||
name: dirent.name,
|
||||
type: 'file',
|
||||
path: childRel,
|
||||
size,
|
||||
detectedMediaType: detectMediaType(dirent.name),
|
||||
detectedFormat: detectFormat(dirent.name),
|
||||
jobId: assignment?.jobId || null,
|
||||
jobTitle: assignment?.jobTitle || null,
|
||||
jobStatus: assignment?.jobStatus || null
|
||||
});
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
async function getTree() {
|
||||
const rawDir = await getRawDir();
|
||||
if (!rawDir || !fs.existsSync(rawDir)) {
|
||||
return { rawDir: rawDir || null, tree: null };
|
||||
}
|
||||
const db = await getDb();
|
||||
const rows = await db.all(`
|
||||
SELECT
|
||||
e.rel_path,
|
||||
e.job_id,
|
||||
j.title AS job_title,
|
||||
j.detected_title AS job_detected_title,
|
||||
j.status AS job_status
|
||||
FROM converter_scan_entries e
|
||||
LEFT JOIN jobs j ON j.id = e.job_id
|
||||
WHERE e.job_id IS NOT NULL
|
||||
`);
|
||||
const assignments = new Map();
|
||||
for (const row of rows) {
|
||||
const rel = String(row?.rel_path || '').trim();
|
||||
const jobId = Number(row?.job_id);
|
||||
if (!rel || !Number.isFinite(jobId) || jobId <= 0) continue;
|
||||
assignments.set(rel, {
|
||||
jobId: Math.trunc(jobId),
|
||||
jobTitle: String(row?.job_title || row?.job_detected_title || '').trim() || null,
|
||||
jobStatus: String(row?.job_status || '').trim() || null
|
||||
});
|
||||
}
|
||||
const children = buildRawTree(rawDir, '', 0, assignments);
|
||||
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
||||
return {
|
||||
rawDir,
|
||||
tree: { name: path.basename(rawDir) || 'raw', type: 'folder', path: '', size, children }
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scan,
|
||||
getEntries,
|
||||
getEntryById,
|
||||
getEntryByRelPath,
|
||||
markEntryAsJob,
|
||||
setEntryJobAssignment,
|
||||
clearEntryJobAssignment,
|
||||
clearAssignmentsForJob,
|
||||
assignEntriesToJob,
|
||||
getRawDir,
|
||||
normalizeRelPath,
|
||||
getTree,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
restartPolling,
|
||||
detectMediaType,
|
||||
detectFormat,
|
||||
deleteEntry,
|
||||
renameEntry,
|
||||
moveEntry,
|
||||
createFolder
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('COVERART_RECOVERY');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const thumbnailService = require('./thumbnailService');
|
||||
|
||||
const COVERART_RECOVERY_ENABLED_KEY = 'coverart_recovery_enabled';
|
||||
const COVERART_RECOVERY_INTERVAL_HOURS_KEY = 'coverart_recovery_interval_hours';
|
||||
const DEFAULT_INTERVAL_HOURS = 6;
|
||||
const MIN_INTERVAL_HOURS = 1;
|
||||
const MAX_INTERVAL_HOURS = 168;
|
||||
const RUNNING_JOB_STATUSES = new Set([
|
||||
'ANALYZING',
|
||||
'RIPPING',
|
||||
'MEDIAINFO_CHECK',
|
||||
'ENCODING',
|
||||
'CD_ANALYZING',
|
||||
'CD_RIPPING',
|
||||
'CD_ENCODING'
|
||||
]);
|
||||
|
||||
function parseJsonSafe(raw, fallback = null) {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toBoolean(value, fallback = false) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
||||
}
|
||||
|
||||
function normalizeIntervalHours(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_INTERVAL_HOURS;
|
||||
}
|
||||
return Math.max(MIN_INTERVAL_HOURS, Math.min(MAX_INTERVAL_HOURS, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function normalizeExternalUrl(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function deriveCoverArtArchiveUrl(mbId) {
|
||||
const normalized = String(mbId || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return `https://coverartarchive.org/release/${encodeURIComponent(normalized)}/front-250`;
|
||||
}
|
||||
|
||||
function isLikelyMusicBrainzId(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^tt\d{6,12}$/i.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-z0-9-]{8,}$/i.test(normalized);
|
||||
}
|
||||
|
||||
function collectCoverCandidates(row) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const push = (url, source) => {
|
||||
const normalized = normalizeExternalUrl(url);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const dedupeKey = normalized.toLowerCase();
|
||||
if (seen.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
candidates.push({
|
||||
url: normalized,
|
||||
source: String(source || '').trim() || null
|
||||
});
|
||||
};
|
||||
|
||||
push(row?.poster_url, 'job.poster_url');
|
||||
|
||||
const makemkvInfo = parseJsonSafe(row?.makemkv_info_json, {});
|
||||
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
|
||||
push(selectedMetadata?.coverUrl, 'selectedMetadata.coverUrl');
|
||||
push(selectedMetadata?.poster, 'selectedMetadata.poster');
|
||||
push(selectedMetadata?.posterUrl, 'selectedMetadata.posterUrl');
|
||||
|
||||
const mbId = String(
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.musicbrainz_id
|
||||
|| selectedMetadata?.music_brainz_id
|
||||
|| selectedMetadata?.musicbrainz
|
||||
|| selectedMetadata?.mbid
|
||||
|| row?.imdb_id
|
||||
|| ''
|
||||
).trim();
|
||||
if (isLikelyMusicBrainzId(mbId)) {
|
||||
push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive');
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
class CoverArtRecoveryService {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
this.inFlight = null;
|
||||
this.nextRunAt = null;
|
||||
this.schedulerEnabled = false;
|
||||
this.intervalHours = DEFAULT_INTERVAL_HOURS;
|
||||
this.lastRunSummary = null;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: this.schedulerEnabled,
|
||||
intervalHours: this.intervalHours,
|
||||
nextRunAt: this.nextRunAt,
|
||||
running: Boolean(this.inFlight),
|
||||
lastRunSummary: this.lastRunSummary
|
||||
};
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.nextRunAt = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.refreshSchedule({ runStartupCheck: true });
|
||||
}
|
||||
|
||||
async handleSettingsChanged(changedKeys = []) {
|
||||
const normalizedKeys = Array.isArray(changedKeys)
|
||||
? changedKeys.map((key) => String(key || '').trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
if (
|
||||
normalizedKeys.length > 0
|
||||
&& !normalizedKeys.includes(COVERART_RECOVERY_ENABLED_KEY)
|
||||
&& !normalizedKeys.includes(COVERART_RECOVERY_INTERVAL_HOURS_KEY)
|
||||
) {
|
||||
return this.getStatus();
|
||||
}
|
||||
return this.refreshSchedule({ runStartupCheck: false });
|
||||
}
|
||||
|
||||
async refreshSchedule(options = {}) {
|
||||
const runStartupCheck = options?.runStartupCheck !== false;
|
||||
this.stop();
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
this.schedulerEnabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
|
||||
this.intervalHours = normalizeIntervalHours(settings?.[COVERART_RECOVERY_INTERVAL_HOURS_KEY]);
|
||||
|
||||
logger.info('scheduler:refresh', {
|
||||
enabled: this.schedulerEnabled,
|
||||
intervalHours: this.intervalHours,
|
||||
runStartupCheck
|
||||
});
|
||||
|
||||
if (this.schedulerEnabled && runStartupCheck) {
|
||||
this.runNow({ trigger: 'startup' }).catch((error) => {
|
||||
logger.warn('scheduler:startup-run-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.schedulerEnabled) {
|
||||
this.scheduleNextAutoRun();
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
scheduleNextAutoRun() {
|
||||
this.stop();
|
||||
if (!this.schedulerEnabled) {
|
||||
return;
|
||||
}
|
||||
const delayMs = this.intervalHours * 60 * 60 * 1000;
|
||||
this.nextRunAt = new Date(Date.now() + delayMs).toISOString();
|
||||
this.timer = setTimeout(() => {
|
||||
this.runNow({ trigger: 'auto' })
|
||||
.catch((error) => {
|
||||
logger.warn('scheduler:auto-run-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.scheduleNextAutoRun();
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async runNow(options = {}) {
|
||||
if (this.inFlight) {
|
||||
return this.inFlight;
|
||||
}
|
||||
let promise = null;
|
||||
promise = this._runNowInternal(options)
|
||||
.finally(() => {
|
||||
if (this.inFlight === promise) {
|
||||
this.inFlight = null;
|
||||
}
|
||||
});
|
||||
this.inFlight = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
async _runNowInternal(options = {}) {
|
||||
const trigger = String(options?.trigger || 'manual').trim().toLowerCase() || 'manual';
|
||||
const force = Boolean(options?.force);
|
||||
const logFailures = options?.logFailures !== false;
|
||||
const startedMs = Date.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const enabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
|
||||
|
||||
if (!enabled && !force) {
|
||||
const skipped = {
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 0,
|
||||
skipped: true,
|
||||
reason: 'disabled'
|
||||
};
|
||||
this.lastRunSummary = skipped;
|
||||
return skipped;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
detected_title,
|
||||
status,
|
||||
poster_url,
|
||||
imdb_id,
|
||||
makemkv_info_json
|
||||
FROM jobs
|
||||
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
|
||||
`
|
||||
);
|
||||
|
||||
const summary = {
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt: null,
|
||||
durationMs: 0,
|
||||
scannedJobs: Array.isArray(rows) ? rows.length : 0,
|
||||
runningSkipped: 0,
|
||||
alreadyLocal: 0,
|
||||
noCandidate: 0,
|
||||
recovered: 0,
|
||||
failed: 0,
|
||||
failedJobs: []
|
||||
};
|
||||
|
||||
for (const row of rows || []) {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = String(row?.status || '').trim().toUpperCase();
|
||||
if (RUNNING_JOB_STATUSES.has(status)) {
|
||||
summary.runningSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPosterUrl = String(row?.poster_url || '').trim();
|
||||
if (
|
||||
currentPosterUrl
|
||||
&& thumbnailService.isLocalUrl(currentPosterUrl)
|
||||
&& thumbnailService.localThumbnailUrlExists(currentPosterUrl)
|
||||
) {
|
||||
summary.alreadyLocal += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidates = collectCoverCandidates(row);
|
||||
if (candidates.length === 0) {
|
||||
summary.noCandidate += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let recovered = false;
|
||||
let lastError = null;
|
||||
for (const candidate of candidates) {
|
||||
const result = await historyService.cacheAndPromoteExternalPoster(jobId, candidate.url, {
|
||||
source: 'Coverart',
|
||||
logFailures: false
|
||||
});
|
||||
if (result?.ok && result?.localUrl) {
|
||||
recovered = true;
|
||||
summary.recovered += 1;
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Coverart nachgeladen (${trigger}): ${candidate.url}${candidate?.source ? ` [${candidate.source}]` : ''}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
lastError = String(result?.error || result?.reason || 'download_failed');
|
||||
}
|
||||
|
||||
if (!recovered) {
|
||||
summary.failed += 1;
|
||||
const failedInfo = {
|
||||
jobId,
|
||||
title: String(row?.title || row?.detected_title || `Job #${jobId}`),
|
||||
attemptedUrls: candidates.map((item) => item.url),
|
||||
error: lastError
|
||||
};
|
||||
summary.failedJobs.push(failedInfo);
|
||||
if (logFailures && trigger !== 'auto') {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Coverart-Download fehlgeschlagen (${trigger}). Versuchte Links: ${failedInfo.attemptedUrls.join(', ')}${lastError ? ` | Fehler: ${lastError}` : ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const durationMs = Math.max(0, Date.now() - startedMs);
|
||||
const result = {
|
||||
...summary,
|
||||
finishedAt,
|
||||
durationMs
|
||||
};
|
||||
this.lastRunSummary = result;
|
||||
logger.info('recovery:done', {
|
||||
trigger: result.trigger,
|
||||
scannedJobs: result.scannedJobs,
|
||||
recovered: result.recovered,
|
||||
failed: result.failed,
|
||||
alreadyLocal: result.alreadyLocal,
|
||||
noCandidate: result.noCandidate,
|
||||
runningSkipped: result.runningSkipped,
|
||||
durationMs: result.durationMs
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CoverArtRecoveryService();
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* cronService.js
|
||||
* Verwaltet und führt Cronjobs aus (Skripte oder Skriptketten).
|
||||
* Kein externer Package nötig – eigener Cron-Expression-Parser.
|
||||
*/
|
||||
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('CRON');
|
||||
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)
|
||||
const MAX_OUTPUT_CHARS = 100000;
|
||||
// Maximale Log-Einträge pro Cron-Job (ältere werden gelöscht)
|
||||
const MAX_LOGS_PER_JOB = 50;
|
||||
|
||||
// ─── Cron-Expression-Parser ────────────────────────────────────────────────
|
||||
|
||||
// Parst ein einzelnes Cron-Feld (z.B. "* /5", "1,3,5", "1-5", "*") und gibt
|
||||
// alle erlaubten Werte als Set zurück.
|
||||
function parseCronField(field, min, max) {
|
||||
const values = new Set();
|
||||
|
||||
for (const part of field.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '*') {
|
||||
for (let i = min; i <= max; i++) values.add(i);
|
||||
} else if (trimmed.startsWith('*/')) {
|
||||
const step = parseInt(trimmed.slice(2), 10);
|
||||
if (!Number.isFinite(step) || step < 1) throw new Error(`Ungültiges Step: ${trimmed}`);
|
||||
for (let i = min; i <= max; i += step) values.add(i);
|
||||
} else if (trimmed.includes('-')) {
|
||||
const [startStr, endStr] = trimmed.split('-');
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = parseInt(endStr, 10);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Ungültiger Bereich: ${trimmed}`);
|
||||
for (let i = Math.max(min, start); i <= Math.min(max, end); i++) values.add(i);
|
||||
} else {
|
||||
const num = parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(num) || num < min || num > max) throw new Error(`Ungültiger Wert: ${trimmed}`);
|
||||
values.add(num);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert eine Cron-Expression (5 Felder: minute hour day month weekday).
|
||||
* Gibt { valid: true } oder { valid: false, error: string } zurück.
|
||||
*/
|
||||
function validateCronExpression(expr) {
|
||||
try {
|
||||
const parts = String(expr || '').trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return { valid: false, error: 'Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag).' };
|
||||
}
|
||||
parseCronField(parts[0], 0, 59); // minute
|
||||
parseCronField(parts[1], 0, 23); // hour
|
||||
parseCronField(parts[2], 1, 31); // day of month
|
||||
parseCronField(parts[3], 1, 12); // month
|
||||
parseCronField(parts[4], 0, 7); // weekday (0 und 7 = Sonntag)
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den nächsten Ausführungszeitpunkt nach einem Datum.
|
||||
* Gibt ein Date-Objekt zurück oder null bei Fehler.
|
||||
*/
|
||||
function getNextRunTime(expr, fromDate = new Date()) {
|
||||
try {
|
||||
const parts = String(expr || '').trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const minutes = parseCronField(parts[0], 0, 59);
|
||||
const hours = parseCronField(parts[1], 0, 23);
|
||||
const days = parseCronField(parts[2], 1, 31);
|
||||
const months = parseCronField(parts[3], 1, 12);
|
||||
const weekdays = parseCronField(parts[4], 0, 7);
|
||||
|
||||
// Normalisiere Wochentag: 7 → 0 (beide = Sonntag)
|
||||
if (weekdays.has(7)) weekdays.add(0);
|
||||
|
||||
// Suche ab der nächsten Minute
|
||||
const candidate = new Date(fromDate);
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
|
||||
// Maximal 2 Jahre in die Zukunft suchen
|
||||
const limit = new Date(fromDate);
|
||||
limit.setFullYear(limit.getFullYear() + 2);
|
||||
|
||||
while (candidate < limit) {
|
||||
const month = candidate.getMonth() + 1; // 1-12
|
||||
const day = candidate.getDate();
|
||||
const hour = candidate.getHours();
|
||||
const minute = candidate.getMinutes();
|
||||
const weekday = candidate.getDay(); // 0 = Sonntag
|
||||
|
||||
if (!months.has(month)) {
|
||||
candidate.setMonth(candidate.getMonth() + 1, 1);
|
||||
candidate.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
if (!days.has(day) || !weekdays.has(weekday)) {
|
||||
candidate.setDate(candidate.getDate() + 1);
|
||||
candidate.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
if (!hours.has(hour)) {
|
||||
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
if (!minutes.has(minute)) {
|
||||
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DB-Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function mapJobRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
cronExpression: String(row.cron_expression || ''),
|
||||
sourceType: String(row.source_type || ''),
|
||||
sourceId: Number(row.source_id),
|
||||
sourceName: row.source_name != null ? String(row.source_name) : null,
|
||||
enabled: Boolean(row.enabled),
|
||||
pushoverEnabled: Boolean(row.pushover_enabled),
|
||||
lastRunAt: row.last_run_at || null,
|
||||
lastRunStatus: row.last_run_status || null,
|
||||
nextRunAt: row.next_run_at || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function mapLogRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
cronJobId: Number(row.cron_job_id),
|
||||
startedAt: row.started_at,
|
||||
finishedAt: row.finished_at || null,
|
||||
status: String(row.status || ''),
|
||||
output: row.output || null,
|
||||
errorMessage: row.error_message || null
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJobWithSource(db, id) {
|
||||
return db.get(
|
||||
`
|
||||
SELECT
|
||||
c.*,
|
||||
CASE c.source_type
|
||||
WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id)
|
||||
WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id)
|
||||
ELSE NULL
|
||||
END AS source_name
|
||||
FROM cron_jobs c
|
||||
WHERE c.id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAllJobsWithSource(db) {
|
||||
return db.all(
|
||||
`
|
||||
SELECT
|
||||
c.*,
|
||||
CASE c.source_type
|
||||
WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id)
|
||||
WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id)
|
||||
ELSE NULL
|
||||
END AS source_name
|
||||
FROM cron_jobs c
|
||||
ORDER BY c.id ASC
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ausführungslogik ──────────────────────────────────────────────────────
|
||||
|
||||
async function runCronJob(job) {
|
||||
const db = await getDb();
|
||||
const startedAt = new Date().toISOString();
|
||||
const cronActivityId = runtimeActivityService.startActivity('cron', {
|
||||
name: job?.name || `Cron #${job?.id || '?'}`,
|
||||
source: 'cron',
|
||||
cronJobId: job?.id || null,
|
||||
currentStep: 'Starte Cronjob'
|
||||
});
|
||||
|
||||
logger.info('cron:run:start', { cronJobId: job.id, name: job.name, sourceType: job.sourceType, sourceId: job.sourceId });
|
||||
|
||||
// Log-Eintrag anlegen (status = 'running')
|
||||
const insertResult = await db.run(
|
||||
`INSERT INTO cron_run_logs (cron_job_id, started_at, status) VALUES (?, ?, 'running')`,
|
||||
[job.id, startedAt]
|
||||
);
|
||||
const logId = insertResult.lastID;
|
||||
|
||||
// Job als laufend markieren
|
||||
await db.run(
|
||||
`UPDATE cron_jobs SET last_run_at = ?, last_run_status = 'running', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[startedAt, job.id]
|
||||
);
|
||||
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: 'running', lastRunAt: startedAt });
|
||||
|
||||
let output = '';
|
||||
let errorMessage = null;
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
if (job.sourceType === 'script') {
|
||||
const scriptService = require('./scriptService');
|
||||
const script = await scriptService.getScriptById(job.sourceId);
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: 'script',
|
||||
currentStep: `Skript: ${script.name}`,
|
||||
currentScriptName: script.name,
|
||||
scriptId: script.id
|
||||
});
|
||||
const scriptActivityId = runtimeActivityService.startActivity('script', {
|
||||
name: script.name,
|
||||
source: 'cron',
|
||||
scriptId: script.id,
|
||||
cronJobId: job.id,
|
||||
parentActivityId: cronActivityId,
|
||||
currentStep: `Cronjob: ${job.name}`
|
||||
});
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
|
||||
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 = [stdout, stderr].filter(Boolean).join('\n');
|
||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||
success = exitCode === 0;
|
||||
if (!success) errorMessage = `Exit-Code ${exitCode}`;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode,
|
||||
message: success ? null : errorMessage,
|
||||
output: output || null,
|
||||
stdout: stdout || null,
|
||||
stderr: stderr || null,
|
||||
stdoutTruncated,
|
||||
stderrTruncated,
|
||||
errorMessage: success ? null : (errorMessage || null)
|
||||
});
|
||||
} catch (error) {
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: error?.message || 'Skriptfehler',
|
||||
errorMessage: error?.message || 'Skriptfehler'
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (prepared?.cleanup) {
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
} else if (job.sourceType === 'chain') {
|
||||
const scriptChainService = require('./scriptChainService');
|
||||
const logLines = [];
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: 'chain',
|
||||
currentStep: `Kette: ${job.sourceName || `#${job.sourceId}`}`,
|
||||
currentScriptName: null,
|
||||
chainId: job.sourceId
|
||||
});
|
||||
const result = await scriptChainService.executeChain(
|
||||
job.sourceId,
|
||||
{
|
||||
source: 'cron',
|
||||
cronJobId: job.id,
|
||||
runtimeParentActivityId: cronActivityId,
|
||||
onRuntimeStep: (payload = {}) => {
|
||||
const currentScriptName = payload?.stepType === 'script'
|
||||
? (payload?.scriptName || payload?.currentScriptName || null)
|
||||
: null;
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: payload?.stepType || 'chain',
|
||||
currentStep: payload?.currentStep || null,
|
||||
currentScriptName,
|
||||
scriptId: payload?.scriptId || null
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
appendLog: async (_source, line) => {
|
||||
logLines.push(line);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
output = logLines.join('\n');
|
||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||
success = result && typeof result === 'object'
|
||||
? !(Boolean(result.aborted) || Number(result.failed || 0) > 0)
|
||||
: Boolean(result);
|
||||
if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.';
|
||||
} else {
|
||||
throw new Error(`Unbekannter source_type: ${job.sourceType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
success = false;
|
||||
errorMessage = error.message || String(error);
|
||||
logger.error('cron:run:error', { cronJobId: job.id, error: errorToMeta(error) });
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const status = success ? 'success' : 'error';
|
||||
const nextRunAt = getNextRunTime(job.cronExpression)?.toISOString() || null;
|
||||
|
||||
// Log-Eintrag abschließen
|
||||
await db.run(
|
||||
`UPDATE cron_run_logs SET finished_at = ?, status = ?, output = ?, error_message = ? WHERE id = ?`,
|
||||
[finishedAt, status, output || null, errorMessage, logId]
|
||||
);
|
||||
|
||||
// Job-Status aktualisieren
|
||||
await db.run(
|
||||
`UPDATE cron_jobs SET last_run_status = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[status, nextRunAt, job.id]
|
||||
);
|
||||
|
||||
// Alte Logs trimmen
|
||||
await db.run(
|
||||
`
|
||||
DELETE FROM cron_run_logs
|
||||
WHERE cron_job_id = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?
|
||||
)
|
||||
`,
|
||||
[job.id, job.id, MAX_LOGS_PER_JOB]
|
||||
);
|
||||
|
||||
logger.info('cron:run:done', { cronJobId: job.id, status, durationMs: new Date(finishedAt) - new Date(startedAt) });
|
||||
runtimeActivityService.completeActivity(cronActivityId, {
|
||||
status,
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
finishedAt,
|
||||
currentStep: null,
|
||||
currentScriptName: null,
|
||||
message: success ? 'Cronjob abgeschlossen' : (errorMessage || 'Cronjob fehlgeschlagen'),
|
||||
output: output || null,
|
||||
errorMessage: success ? null : (errorMessage || null)
|
||||
});
|
||||
|
||||
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: status, lastRunAt: finishedAt, nextRunAt });
|
||||
|
||||
// Pushover-Benachrichtigung (nur wenn am Cron aktiviert UND global aktiviert)
|
||||
if (job.pushoverEnabled) {
|
||||
try {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const eventKey = success ? 'cron_success' : 'cron_error';
|
||||
const title = `Ripster Cron: ${job.name}`;
|
||||
const message = success
|
||||
? `Cronjob "${job.name}" erfolgreich ausgeführt.`
|
||||
: `Cronjob "${job.name}" fehlgeschlagen: ${errorMessage || 'Unbekannter Fehler'}`;
|
||||
|
||||
await notificationService.notifyWithSettings(settings, eventKey, { title, message });
|
||||
} catch (notifyError) {
|
||||
logger.warn('cron:run:notify-failed', { cronJobId: job.id, error: errorToMeta(notifyError) });
|
||||
}
|
||||
}
|
||||
|
||||
return { success, status, output, errorMessage, finishedAt, nextRunAt };
|
||||
}
|
||||
|
||||
// ─── Scheduler ─────────────────────────────────────────────────────────────
|
||||
|
||||
class CronService {
|
||||
constructor() {
|
||||
this._timer = null;
|
||||
this._running = new Set(); // IDs aktuell laufender Jobs
|
||||
}
|
||||
|
||||
async init() {
|
||||
logger.info('cron:scheduler:init');
|
||||
// Beim Start next_run_at für alle enabled Jobs neu berechnen
|
||||
await this._recalcNextRuns();
|
||||
this._scheduleNextTick();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
logger.info('cron:scheduler:stopped');
|
||||
}
|
||||
|
||||
_scheduleNextTick() {
|
||||
// Auf den Beginn der nächsten vollen Minute warten
|
||||
const now = new Date();
|
||||
const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 500;
|
||||
this._timer = setTimeout(() => this._tick(), msUntilNextMinute);
|
||||
}
|
||||
|
||||
async _tick() {
|
||||
try {
|
||||
await this._checkAndRunDueJobs();
|
||||
} catch (error) {
|
||||
logger.error('cron:scheduler:tick-error', { error: errorToMeta(error) });
|
||||
}
|
||||
this._scheduleNextTick();
|
||||
}
|
||||
|
||||
async _recalcNextRuns() {
|
||||
const db = await getDb();
|
||||
const jobs = await db.all(`SELECT id, cron_expression FROM cron_jobs WHERE enabled = 1`);
|
||||
for (const job of jobs) {
|
||||
const nextRunAt = getNextRunTime(job.cron_expression)?.toISOString() || null;
|
||||
await db.run(`UPDATE cron_jobs SET next_run_at = ? WHERE id = ?`, [nextRunAt, job.id]);
|
||||
}
|
||||
}
|
||||
|
||||
async _checkAndRunDueJobs() {
|
||||
const db = await getDb();
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
// Jobs, deren next_run_at <= jetzt ist und die nicht gerade laufen
|
||||
const dueJobs = await db.all(
|
||||
`SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?`,
|
||||
[nowIso]
|
||||
);
|
||||
|
||||
for (const jobRow of dueJobs) {
|
||||
const id = Number(jobRow.id);
|
||||
if (this._running.has(id)) {
|
||||
logger.warn('cron:scheduler:skip-still-running', { cronJobId: id });
|
||||
continue;
|
||||
}
|
||||
|
||||
const job = mapJobRow(jobRow);
|
||||
this._running.add(id);
|
||||
|
||||
// Asynchron ausführen, damit der Scheduler nicht blockiert
|
||||
runCronJob(job)
|
||||
.catch((error) => {
|
||||
logger.error('cron:run:unhandled-error', { cronJobId: id, error: errorToMeta(error) });
|
||||
})
|
||||
.finally(() => {
|
||||
this._running.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
async listJobs() {
|
||||
const db = await getDb();
|
||||
const rows = await fetchAllJobsWithSource(db);
|
||||
return rows.map(mapJobRow);
|
||||
}
|
||||
|
||||
async getJobById(id) {
|
||||
const db = await getDb();
|
||||
const row = await fetchJobWithSource(db, id);
|
||||
if (!row) {
|
||||
const error = new Error(`Cronjob #${id} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return mapJobRow(row);
|
||||
}
|
||||
|
||||
async createJob(payload) {
|
||||
const { name, cronExpression, sourceType, sourceId, enabled = true, pushoverEnabled = true } = payload || {};
|
||||
|
||||
const trimmedName = String(name || '').trim();
|
||||
const trimmedExpr = String(cronExpression || '').trim();
|
||||
|
||||
if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 });
|
||||
if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 });
|
||||
|
||||
const validation = validateCronExpression(trimmedExpr);
|
||||
if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 });
|
||||
|
||||
if (!['script', 'chain'].includes(sourceType)) {
|
||||
throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const normalizedSourceId = Number(sourceId);
|
||||
if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) {
|
||||
throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const nextRunAt = getNextRunTime(trimmedExpr)?.toISOString() || null;
|
||||
const db = await getDb();
|
||||
|
||||
const result = await db.run(
|
||||
`
|
||||
INSERT INTO cron_jobs (name, cron_expression, source_type, source_id, enabled, pushover_enabled, next_run_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[trimmedName, trimmedExpr, sourceType, normalizedSourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt]
|
||||
);
|
||||
|
||||
logger.info('cron:create', { cronJobId: result.lastID, name: trimmedName, cronExpression: trimmedExpr });
|
||||
return this.getJobById(result.lastID);
|
||||
}
|
||||
|
||||
async updateJob(id, payload) {
|
||||
const db = await getDb();
|
||||
const existing = await this.getJobById(id);
|
||||
|
||||
const trimmedName = Object.prototype.hasOwnProperty.call(payload, 'name')
|
||||
? String(payload.name || '').trim()
|
||||
: existing.name;
|
||||
const trimmedExpr = Object.prototype.hasOwnProperty.call(payload, 'cronExpression')
|
||||
? String(payload.cronExpression || '').trim()
|
||||
: existing.cronExpression;
|
||||
|
||||
if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 });
|
||||
if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 });
|
||||
|
||||
const validation = validateCronExpression(trimmedExpr);
|
||||
if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 });
|
||||
|
||||
const sourceType = Object.prototype.hasOwnProperty.call(payload, 'sourceType') ? payload.sourceType : existing.sourceType;
|
||||
const sourceId = Object.prototype.hasOwnProperty.call(payload, 'sourceId') ? Number(payload.sourceId) : existing.sourceId;
|
||||
const enabled = Object.prototype.hasOwnProperty.call(payload, 'enabled') ? Boolean(payload.enabled) : existing.enabled;
|
||||
const pushoverEnabled = Object.prototype.hasOwnProperty.call(payload, 'pushoverEnabled') ? Boolean(payload.pushoverEnabled) : existing.pushoverEnabled;
|
||||
|
||||
if (!['script', 'chain'].includes(sourceType)) {
|
||||
throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 });
|
||||
}
|
||||
if (!Number.isFinite(sourceId) || sourceId <= 0) {
|
||||
throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const nextRunAt = enabled ? (getNextRunTime(trimmedExpr)?.toISOString() || null) : null;
|
||||
|
||||
await db.run(
|
||||
`
|
||||
UPDATE cron_jobs
|
||||
SET name = ?, cron_expression = ?, source_type = ?, source_id = ?,
|
||||
enabled = ?, pushover_enabled = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[trimmedName, trimmedExpr, sourceType, sourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt, id]
|
||||
);
|
||||
|
||||
logger.info('cron:update', { cronJobId: id });
|
||||
return this.getJobById(id);
|
||||
}
|
||||
|
||||
async deleteJob(id) {
|
||||
const db = await getDb();
|
||||
const job = await this.getJobById(id);
|
||||
await db.run(`DELETE FROM cron_jobs WHERE id = ?`, [id]);
|
||||
logger.info('cron:delete', { cronJobId: id });
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJobLogs(id, limit = 20) {
|
||||
await this.getJobById(id); // Existenz prüfen
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`SELECT * FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?`,
|
||||
[id, Math.min(Number(limit) || 20, 100)]
|
||||
);
|
||||
return rows.map(mapLogRow);
|
||||
}
|
||||
|
||||
async triggerJobManually(id) {
|
||||
const job = await this.getJobById(id);
|
||||
if (this._running.has(id)) {
|
||||
throw Object.assign(new Error('Cronjob läuft bereits.'), { statusCode: 409 });
|
||||
}
|
||||
this._running.add(id);
|
||||
logger.info('cron:manual-trigger', { cronJobId: id });
|
||||
|
||||
// Asynchron starten
|
||||
runCronJob(job)
|
||||
.catch((error) => {
|
||||
logger.error('cron:manual-trigger:error', { cronJobId: id, error: errorToMeta(error) });
|
||||
})
|
||||
.finally(() => {
|
||||
this._running.delete(id);
|
||||
});
|
||||
|
||||
return { triggered: true, cronJobId: id };
|
||||
}
|
||||
|
||||
validateExpression(expr) {
|
||||
return validateCronExpression(expr);
|
||||
}
|
||||
|
||||
getNextRunTime(expr) {
|
||||
const next = getNextRunTime(expr);
|
||||
return next ? next.toISOString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CronService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,572 @@
|
||||
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();
|
||||
const pendingResumeIds = [];
|
||||
|
||||
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') {
|
||||
const archiveExists = await this._pathExists(item.archivePath);
|
||||
if (archiveExists) {
|
||||
const archiveStat = await fs.promises.stat(item.archivePath).catch(() => null);
|
||||
item.status = 'ready';
|
||||
item.errorMessage = null;
|
||||
item.finishedAt = item.finishedAt || nowIso;
|
||||
item.sizeBytes = Number.isFinite(Number(archiveStat?.size))
|
||||
? archiveStat.size
|
||||
: item.sizeBytes;
|
||||
changed = true;
|
||||
logger.warn('download:init:recovered-ready-archive', {
|
||||
id: item.id,
|
||||
archiveName: item.archiveName
|
||||
});
|
||||
} else {
|
||||
item.status = 'queued';
|
||||
item.startedAt = null;
|
||||
item.finishedAt = null;
|
||||
item.errorMessage = null;
|
||||
item.sizeBytes = null;
|
||||
changed = true;
|
||||
pendingResumeIds.push(item.id);
|
||||
await this._safeUnlink(item.partialPath);
|
||||
logger.warn('download:init:requeue-interrupted-job', {
|
||||
id: item.id,
|
||||
archiveName: item.archiveName
|
||||
});
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingResumeIds.length > 0) {
|
||||
logger.warn('download:init:resume-pending-jobs', {
|
||||
count: pendingResumeIds.length
|
||||
});
|
||||
setImmediate(() => {
|
||||
for (const id of pendingResumeIds) {
|
||||
void this._startArchiveJob(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_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, options = {}) {
|
||||
await this.init();
|
||||
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target, options);
|
||||
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();
|
||||
@@ -0,0 +1,528 @@
|
||||
'use strict';
|
||||
|
||||
const TITLE_KIND = Object.freeze({
|
||||
EPISODE_CANDIDATE: 'episode_candidate',
|
||||
PLAY_ALL: 'play_all',
|
||||
EXTRA: 'extra',
|
||||
DUPLICATE: 'duplicate',
|
||||
SHORT: 'short',
|
||||
UNKNOWN: 'unknown'
|
||||
});
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
minEpisodeMinutes: 18,
|
||||
maxEpisodeMinutes: 75,
|
||||
minChapterCount: 3,
|
||||
shortTitleMinutes: 5
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(rawValue) {
|
||||
const text = String(rawValue || '').trim();
|
||||
const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]);
|
||||
}
|
||||
|
||||
function roundToStep(value, step) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || step <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(num / step) * step;
|
||||
}
|
||||
|
||||
function median(values = []) {
|
||||
const nums = (Array.isArray(values) ? values : [])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.sort((left, right) => left - right);
|
||||
if (nums.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const middle = Math.floor(nums.length / 2);
|
||||
if (nums.length % 2 === 1) {
|
||||
return nums[middle];
|
||||
}
|
||||
return (nums[middle - 1] + nums[middle]) / 2;
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(rawCode, fallbackLabel = null) {
|
||||
const raw = String(rawCode || '').trim().toLowerCase();
|
||||
if (raw && raw.length === 3) {
|
||||
return raw;
|
||||
}
|
||||
const label = String(fallbackLabel || '').trim().toLowerCase();
|
||||
if (label.startsWith('de')) return 'deu';
|
||||
if (label.startsWith('en')) return 'eng';
|
||||
if (label.startsWith('fr')) return 'fra';
|
||||
if (label.startsWith('es')) return 'spa';
|
||||
if (label.startsWith('nl')) return 'nld';
|
||||
return raw || 'und';
|
||||
}
|
||||
|
||||
function normalizeSeriesLookupTitle(rawValue) {
|
||||
return String(rawValue || '')
|
||||
.replace(/[_./]+/g, ' ')
|
||||
.replace(/\bs\d{1,2}\s*d\d{1,2}\b/gi, ' ')
|
||||
.replace(/\bs(?:taffel|eason)?\s*\d{1,2}\s*(?:disc|dvd|cd|d)\s*\d{1,2}\b/gi, ' ')
|
||||
.replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b\d{1,2}x\b/gi, ' ')
|
||||
.replace(/\b(?:complete|collection|boxset)\b/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function deriveSeriesLookupHint(inputs = []) {
|
||||
const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs])
|
||||
.map((entry) => {
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
return {
|
||||
value: String(entry.value || '').trim(),
|
||||
source: String(entry.source || '').trim() || 'unknown'
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: String(entry || '').trim(),
|
||||
source: 'unknown'
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value);
|
||||
|
||||
for (const entry of normalizedInputs) {
|
||||
const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!compactValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const compactSeasonDiscMatch = compactValue.match(
|
||||
/(?:^|\s)s(?:taffel|eason)?\s*0?(\d{1,2})\s*(?:d|disc|dvd|cd)\s*0?(\d{1,2})(?=\s|$)/i
|
||||
);
|
||||
const seasonMatch = compactSeasonDiscMatch
|
||||
|| compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i)
|
||||
|| compactValue.match(/(?:^|\s)s\s*0?(\d{1,2})(?=\s|$)/i)
|
||||
|| compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i);
|
||||
if (!seasonMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonNumber = Number(seasonMatch[1] || 0);
|
||||
if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discMatch = compactSeasonDiscMatch
|
||||
|| compactValue.match(/(?:^|\s)(?:disc|dvd|cd|d)\s*0?(\d{1,2})(?=\s|$)/i);
|
||||
const compactDiscToken = compactSeasonDiscMatch ? compactSeasonDiscMatch[2] : null;
|
||||
const discNumber = discMatch
|
||||
? Number(compactDiscToken || discMatch[1] || 0) || null
|
||||
: null;
|
||||
|
||||
const baseTitle = normalizeSeriesLookupTitle(compactValue);
|
||||
if (!baseTitle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
query: baseTitle,
|
||||
seriesTitle: baseTitle,
|
||||
seasonNumber,
|
||||
discNumber,
|
||||
source: entry.source,
|
||||
sourceLabel: entry.value,
|
||||
confidence: discNumber ? 'high' : 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTitleRecord(title = {}) {
|
||||
const durationSeconds = Number(title.durationSeconds || 0);
|
||||
const chapterCount = Number(title.chapterCount || 0);
|
||||
const roundedDurationBucket = roundToStep(durationSeconds, 30);
|
||||
const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
index: Number(title.index || 0),
|
||||
durationSeconds,
|
||||
durationLabel: String(title.durationLabel || '').trim() || null,
|
||||
chapterCount,
|
||||
chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [],
|
||||
aspectRatio: String(title.aspectRatio || '').trim() || null,
|
||||
audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [],
|
||||
subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [],
|
||||
flags: Array.isArray(title.flags) ? title.flags : [],
|
||||
roundedDurationBucket,
|
||||
signature: JSON.stringify({
|
||||
duration: roundedDurationBucket,
|
||||
chapterCount,
|
||||
audioLanguages,
|
||||
subtitleLanguages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function buildBestEpisodeCluster(titles = [], options = {}) {
|
||||
const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60));
|
||||
const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60));
|
||||
const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount));
|
||||
const candidates = titles.filter((title) =>
|
||||
title.durationSeconds >= minEpisodeSeconds
|
||||
&& title.durationSeconds <= maxEpisodeSeconds
|
||||
&& title.chapterCount >= minChapterCount
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
titles: [],
|
||||
medianDurationSeconds: 0,
|
||||
medianChapterCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
let bestCluster = [];
|
||||
for (const anchor of candidates) {
|
||||
const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12));
|
||||
const cluster = candidates.filter((candidate) => {
|
||||
const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds);
|
||||
const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount);
|
||||
return durationGap <= durationTolerance && chapterGap <= 2;
|
||||
});
|
||||
|
||||
if (cluster.length > bestCluster.length) {
|
||||
bestCluster = cluster;
|
||||
continue;
|
||||
}
|
||||
if (cluster.length === bestCluster.length) {
|
||||
const clusterMedian = median(cluster.map((item) => item.durationSeconds));
|
||||
const bestMedian = median(bestCluster.map((item) => item.durationSeconds));
|
||||
if (clusterMedian > bestMedian) {
|
||||
bestCluster = cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
titles: bestCluster,
|
||||
medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)),
|
||||
medianChapterCount: median(bestCluster.map((item) => item.chapterCount))
|
||||
};
|
||||
}
|
||||
|
||||
function classifyTitles(titles = [], cluster = {}, options = {}) {
|
||||
const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60));
|
||||
const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index)));
|
||||
const duplicateFirstBySignature = new Map();
|
||||
|
||||
for (const title of titles) {
|
||||
if (!duplicateFirstBySignature.has(title.signature)) {
|
||||
duplicateFirstBySignature.set(title.signature, title.index);
|
||||
}
|
||||
}
|
||||
|
||||
const playAllCandidate = (() => {
|
||||
if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) {
|
||||
return null;
|
||||
}
|
||||
const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1);
|
||||
return titles
|
||||
.filter((title) =>
|
||||
!clusterTitleIds.has(Number(title.index))
|
||||
&& title.durationSeconds >= minPlayAllSeconds * 0.75
|
||||
&& title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6))
|
||||
)
|
||||
.sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null;
|
||||
})();
|
||||
|
||||
return titles.map((title) => {
|
||||
const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index;
|
||||
const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds;
|
||||
const isEpisodeCandidate = clusterTitleIds.has(Number(title.index));
|
||||
const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index);
|
||||
const kind = isPlayAll
|
||||
? TITLE_KIND.PLAY_ALL
|
||||
: isEpisodeCandidate
|
||||
? TITLE_KIND.EPISODE_CANDIDATE
|
||||
: !isFirstWithSignature
|
||||
? TITLE_KIND.DUPLICATE
|
||||
: isShort
|
||||
? TITLE_KIND.SHORT
|
||||
: (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN);
|
||||
|
||||
let confidence = 0.2;
|
||||
if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8;
|
||||
if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95;
|
||||
if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85;
|
||||
if (kind === TITLE_KIND.SHORT) confidence = 0.9;
|
||||
if (kind === TITLE_KIND.EXTRA) confidence = 0.55;
|
||||
|
||||
return {
|
||||
...title,
|
||||
kind,
|
||||
confidence,
|
||||
duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE
|
||||
? duplicateFirstBySignature.get(title.signature)
|
||||
: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiscSignature(parsedScan = {}) {
|
||||
return JSON.stringify({
|
||||
discTitle: String(parsedScan.discTitle || '').trim() || null,
|
||||
discSerial: String(parsedScan.discSerial || '').trim() || null,
|
||||
titleCount: Number(parsedScan.titleCount || 0),
|
||||
durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : [])
|
||||
.map((title) => ({
|
||||
index: Number(title.index || 0),
|
||||
durationBucket: roundToStep(title.durationSeconds || 0, 30),
|
||||
chapterCount: Number(title.chapterCount || 0)
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) {
|
||||
const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length;
|
||||
const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length;
|
||||
const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length;
|
||||
const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length;
|
||||
|
||||
const reasons = [];
|
||||
let confidence = 'low';
|
||||
let seriesLike = false;
|
||||
|
||||
if (episodeCount >= 3) {
|
||||
seriesLike = true;
|
||||
confidence = playAllCount > 0 ? 'high' : 'medium';
|
||||
reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`);
|
||||
} else if (episodeCount === 2) {
|
||||
seriesLike = true;
|
||||
confidence = 'medium';
|
||||
reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.');
|
||||
}
|
||||
|
||||
if (playAllCount > 0) {
|
||||
reasons.push('Ein langer Play-All-Titel wurde erkannt.');
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`);
|
||||
}
|
||||
if (cluster.medianDurationSeconds > 0) {
|
||||
reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`);
|
||||
}
|
||||
if (extrasCount > 0) {
|
||||
reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`);
|
||||
}
|
||||
|
||||
return {
|
||||
seriesLike,
|
||||
confidence,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
function parseHandBrakeScanText(rawOutput) {
|
||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||
const parsed = {
|
||||
source: 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: null,
|
||||
discSerial: null,
|
||||
titleCount: 0,
|
||||
rawLineCount: lines.length,
|
||||
titles: []
|
||||
};
|
||||
|
||||
let currentTitle = null;
|
||||
const ensureCurrentTitle = (index) => {
|
||||
const normalizedIndex = Number(index);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (currentTitle && Number(currentTitle.index) === normalizedIndex) {
|
||||
return currentTitle;
|
||||
}
|
||||
const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex);
|
||||
if (existing) {
|
||||
currentTitle = existing;
|
||||
return currentTitle;
|
||||
}
|
||||
currentTitle = {
|
||||
index: normalizedIndex,
|
||||
durationLabel: null,
|
||||
durationSeconds: 0,
|
||||
chapterCount: 0,
|
||||
chapterDurationsMs: [],
|
||||
audioTracks: [],
|
||||
subtitleTracks: [],
|
||||
aspectRatio: null,
|
||||
flags: []
|
||||
};
|
||||
parsed.titles.push(currentTitle);
|
||||
return currentTitle;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discTitle = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discSerial = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i);
|
||||
if (match) {
|
||||
parsed.titleCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/scan:\s+(?:BD|Blu-?ray)\s+has\s+(\d+)\s+title/i);
|
||||
if (match) {
|
||||
parsed.titleCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+scanning title\s+(\d+)/i);
|
||||
if (match) {
|
||||
ensureCurrentTitle(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+title\s+(\d+):/i);
|
||||
if (match) {
|
||||
ensureCurrentTitle(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.durationLabel = match[1];
|
||||
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+duration:\s+(\d{1,2}:\d{2}:\d{2})/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.durationLabel = match[1];
|
||||
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i);
|
||||
if (match) {
|
||||
const title = ensureCurrentTitle(match[1]);
|
||||
if (title) {
|
||||
title.chapterCount = Number(match[2] || 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s*\+\s+chapters:\s+(\d+)/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.chapterCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.chapterDurationsMs.push(Number(match[1] || 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i);
|
||||
if (match && currentTitle) {
|
||||
const track = {
|
||||
languageLabel: String(match[1] || '').trim() || null,
|
||||
languageCode: normalizeLanguageCode(match[2], match[1])
|
||||
};
|
||||
if (/\[VOBSUB\]/i.test(line)) {
|
||||
currentTitle.subtitleTracks.push(track);
|
||||
} else {
|
||||
currentTitle.audioTracks.push(track);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.aspectRatio = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+ignoring title \(too short\)/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.flags.push('ignored_too_short');
|
||||
}
|
||||
}
|
||||
|
||||
parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0));
|
||||
if (!parsed.titleCount) {
|
||||
parsed.titleCount = parsed.titles.length;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function analyzeParsedScan(parsedScan = {}, options = {}) {
|
||||
const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord);
|
||||
const cluster = buildBestEpisodeCluster(titles, options);
|
||||
const classifiedTitles = classifyTitles(titles, cluster, options);
|
||||
const summary = summarizeSeriesLikelihood(classifiedTitles, cluster);
|
||||
|
||||
return {
|
||||
source: parsedScan.source || 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: parsedScan.discTitle || null,
|
||||
discSerial: parsedScan.discSerial || null,
|
||||
titleCount: Number(parsedScan.titleCount || titles.length || 0),
|
||||
discSignature: buildDiscSignature({
|
||||
discTitle: parsedScan.discTitle,
|
||||
discSerial: parsedScan.discSerial,
|
||||
titleCount: parsedScan.titleCount,
|
||||
titles
|
||||
}),
|
||||
summary: {
|
||||
...summary,
|
||||
titleCount: classifiedTitles.length,
|
||||
episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length,
|
||||
playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length,
|
||||
duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length,
|
||||
extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length,
|
||||
typicalEpisodeMinutes: cluster.medianDurationSeconds
|
||||
? Number((cluster.medianDurationSeconds / 60).toFixed(2))
|
||||
: 0
|
||||
},
|
||||
titles: classifiedTitles
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeHandBrakeScan(rawOutput, options = {}) {
|
||||
const parsed = parseHandBrakeScanText(rawOutput);
|
||||
const analysis = analyzeParsedScan(parsed, options);
|
||||
return {
|
||||
parsed,
|
||||
analysis
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TITLE_KIND,
|
||||
parseHandBrakeScanText,
|
||||
analyzeParsedScan,
|
||||
analyzeHandBrakeScan,
|
||||
deriveSeriesLookupHint
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
const path = require('path');
|
||||
const { logDir: fallbackLogDir } = require('../config');
|
||||
|
||||
function normalizeDir(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
|
||||
}
|
||||
|
||||
function getFallbackLogRootDir() {
|
||||
return path.resolve(fallbackLogDir);
|
||||
}
|
||||
|
||||
function resolveLogRootDir(value) {
|
||||
return normalizeDir(value) || getFallbackLogRootDir();
|
||||
}
|
||||
|
||||
let runtimeLogRootDir = getFallbackLogRootDir();
|
||||
|
||||
function setLogRootDir(value) {
|
||||
runtimeLogRootDir = resolveLogRootDir(value);
|
||||
return runtimeLogRootDir;
|
||||
}
|
||||
|
||||
function getLogRootDir() {
|
||||
return runtimeLogRootDir || getFallbackLogRootDir();
|
||||
}
|
||||
|
||||
function getBackendLogDir() {
|
||||
return path.join(getLogRootDir(), 'backend');
|
||||
}
|
||||
|
||||
function getJobLogDir() {
|
||||
return getLogRootDir();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFallbackLogRootDir,
|
||||
resolveLogRootDir,
|
||||
setLogRootDir,
|
||||
getLogRootDir,
|
||||
getBackendLogDir,
|
||||
getJobLogDir
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { logLevel } = require('../config');
|
||||
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
|
||||
const { getServerTimestamp } = require('../utils/serverTime');
|
||||
|
||||
const LEVELS = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40
|
||||
};
|
||||
|
||||
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
|
||||
let consoleConfig = {
|
||||
enabled: true,
|
||||
levels: {
|
||||
debug: true,
|
||||
info: true,
|
||||
warn: true,
|
||||
error: true
|
||||
},
|
||||
http: true
|
||||
};
|
||||
|
||||
function normalizeBoolean(value, fallback = true) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function ensureLogDir(logDirPath) {
|
||||
try {
|
||||
fs.mkdirSync(logDirPath, { recursive: true });
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWritableBackendLogDir() {
|
||||
const preferred = getBackendLogDir();
|
||||
if (ensureLogDir(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const fallback = path.join(getFallbackLogRootDir(), 'backend');
|
||||
if (fallback !== preferred && ensureLogDir(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDailyFileName() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `backend-${y}-${m}-${day}.log`;
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (error) {
|
||||
return JSON.stringify({ serializationError: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
function truncateString(value, maxLen = 3000) {
|
||||
const str = String(value);
|
||||
if (str.length <= maxLen) {
|
||||
return str;
|
||||
}
|
||||
return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`;
|
||||
}
|
||||
|
||||
function sanitizeMeta(meta) {
|
||||
if (!meta || typeof meta !== 'object') {
|
||||
return meta;
|
||||
}
|
||||
|
||||
const out = Array.isArray(meta) ? [] : {};
|
||||
|
||||
for (const [key, val] of Object.entries(meta)) {
|
||||
if (val instanceof Error) {
|
||||
out[key] = {
|
||||
name: val.name,
|
||||
message: val.message,
|
||||
stack: val.stack
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof val === 'string') {
|
||||
out[key] = truncateString(val, 5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
out[key] = val;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeLine(line) {
|
||||
const backendLogDir = resolveWritableBackendLogDir();
|
||||
if (!backendLogDir) {
|
||||
return;
|
||||
}
|
||||
const daily = path.join(backendLogDir, getDailyFileName());
|
||||
const latest = path.join(backendLogDir, 'backend-latest.log');
|
||||
|
||||
fs.appendFile(daily, `${line}\n`, (_error) => null);
|
||||
fs.appendFile(latest, `${line}\n`, (_error) => null);
|
||||
}
|
||||
|
||||
function emit(level, scope, message, meta = null) {
|
||||
const normLevel = String(level || 'info').toLowerCase();
|
||||
const lvl = LEVELS[normLevel] || LEVELS.info;
|
||||
if (lvl < ACTIVE_LEVEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = getServerTimestamp();
|
||||
const payload = {
|
||||
timestamp,
|
||||
level: normLevel,
|
||||
scope,
|
||||
message,
|
||||
meta: sanitizeMeta(meta)
|
||||
};
|
||||
|
||||
const line = safeJson(payload);
|
||||
writeLine(line);
|
||||
|
||||
if (!shouldEmitToConsole(normLevel, scope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
|
||||
if (normLevel === 'error') {
|
||||
console.error(print, payload.meta ? payload.meta : '');
|
||||
} else if (normLevel === 'warn') {
|
||||
console.warn(print, payload.meta ? payload.meta : '');
|
||||
} else {
|
||||
console.log(print, payload.meta ? payload.meta : '');
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEmitToConsole(level, scope) {
|
||||
if (!consoleConfig.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedLevel = String(level || 'info').toLowerCase();
|
||||
if (consoleConfig.levels[normalizedLevel] === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedScope = String(scope || '').trim().toUpperCase();
|
||||
if (normalizedScope === 'HTTP' && !consoleConfig.http) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function child(scope) {
|
||||
return {
|
||||
debug(message, meta) {
|
||||
emit('debug', scope, message, meta);
|
||||
},
|
||||
info(message, meta) {
|
||||
emit('info', scope, message, meta);
|
||||
},
|
||||
warn(message, meta) {
|
||||
emit('warn', scope, message, meta);
|
||||
},
|
||||
error(message, meta) {
|
||||
emit('error', scope, message, meta);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setConsoleOutputEnabled(enabled) {
|
||||
consoleConfig.enabled = Boolean(enabled);
|
||||
return consoleConfig.enabled;
|
||||
}
|
||||
|
||||
function isConsoleOutputEnabled() {
|
||||
return consoleConfig.enabled;
|
||||
}
|
||||
|
||||
function configureConsoleOutput(options = {}) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
if (Object.prototype.hasOwnProperty.call(opts, 'enabled')) {
|
||||
consoleConfig.enabled = normalizeBoolean(opts.enabled, true);
|
||||
}
|
||||
|
||||
if (opts.levels && typeof opts.levels === 'object') {
|
||||
const sourceLevels = opts.levels;
|
||||
for (const level of Object.keys(LEVELS)) {
|
||||
if (Object.prototype.hasOwnProperty.call(sourceLevels, level)) {
|
||||
consoleConfig.levels[level] = normalizeBoolean(sourceLevels[level], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(opts, 'http')) {
|
||||
consoleConfig.http = normalizeBoolean(opts.http, true);
|
||||
}
|
||||
|
||||
return getConsoleOutputConfig();
|
||||
}
|
||||
|
||||
function getConsoleOutputConfig() {
|
||||
return {
|
||||
enabled: Boolean(consoleConfig.enabled),
|
||||
levels: {
|
||||
debug: Boolean(consoleConfig.levels.debug),
|
||||
info: Boolean(consoleConfig.levels.info),
|
||||
warn: Boolean(consoleConfig.levels.warn),
|
||||
error: Boolean(consoleConfig.levels.error)
|
||||
},
|
||||
http: Boolean(consoleConfig.http)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
child,
|
||||
emit,
|
||||
setConsoleOutputEnabled,
|
||||
isConsoleOutputEnabled,
|
||||
configureConsoleOutput,
|
||||
getConsoleOutputConfig
|
||||
};
|
||||
@@ -0,0 +1,423 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('MAKEMKV_KEY');
|
||||
const backendPackage = require('../../package.json');
|
||||
|
||||
const MAKEMKV_BETA_KEY_API_URL = 'https://cable.ayra.ch/makemkv/api.php?json';
|
||||
const MAKEMKV_BETA_KEY_CACHE_PREF_KEY = 'makemkv_beta_key_cache_v1';
|
||||
const MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS = 1000;
|
||||
const MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS = Math.max(
|
||||
1,
|
||||
Number(process.env.MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS || 24)
|
||||
);
|
||||
|
||||
const RIPSTER_VERSION = String(backendPackage?.version || 'dev').trim() || 'dev';
|
||||
const DEFAULT_USER_AGENT = `Ripster/MakeMKVKey-${RIPSTER_VERSION} (linux/manual; Node/manual) +https://github.com/mboehmlaender/ripster`;
|
||||
const MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT
|
||||
|| DEFAULT_USER_AGENT;
|
||||
const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max(
|
||||
1000,
|
||||
Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000)
|
||||
);
|
||||
const MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS = Math.max(
|
||||
30 * 1000,
|
||||
Number(process.env.MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS || (15 * 60 * 1000))
|
||||
);
|
||||
|
||||
let cachedBetaKeyEntry = null;
|
||||
let blockedUntilMs = 0;
|
||||
|
||||
function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ungültig.') {
|
||||
const betaKey = String(value || '').trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{15,}$/.test(betaKey)) {
|
||||
const error = new Error(invalidMessage);
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
return betaKey;
|
||||
}
|
||||
|
||||
|
||||
function normalizeCacheEntry(rawEntry) {
|
||||
if (!rawEntry || typeof rawEntry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let key = '';
|
||||
try {
|
||||
key = parseBetaKeyCandidate(rawEntry.key || rawEntry.betaKey || '', 'Betakey-Cache ist ungültig.');
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceUrl = String(rawEntry.sourceUrl || MAKEMKV_BETA_KEY_API_URL).trim() || MAKEMKV_BETA_KEY_API_URL;
|
||||
const validUntilIsoRaw = String(rawEntry.validUntil || rawEntry.validUntilIso || '').trim();
|
||||
const validUntilMs = Date.parse(validUntilIsoRaw);
|
||||
|
||||
if (!Number.isFinite(validUntilMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetchedAtIsoRaw = String(rawEntry.fetchedAt || rawEntry.fetchedAtIso || '').trim();
|
||||
const fetchedAtMs = Date.parse(fetchedAtIsoRaw);
|
||||
|
||||
return {
|
||||
key,
|
||||
sourceUrl,
|
||||
validUntil: new Date(validUntilMs).toISOString(),
|
||||
validUntilMs,
|
||||
fetchedAt: Number.isFinite(fetchedAtMs) ? new Date(fetchedAtMs).toISOString() : null,
|
||||
fetchedAtMs: Number.isFinite(fetchedAtMs) ? fetchedAtMs : null
|
||||
};
|
||||
}
|
||||
|
||||
function isCacheEntryValid(entry, nowMs = Date.now()) {
|
||||
return Boolean(
|
||||
entry
|
||||
&& typeof entry === 'object'
|
||||
&& typeof entry.key === 'string'
|
||||
&& entry.key.length >= 16
|
||||
&& Number.isFinite(entry.validUntilMs)
|
||||
&& entry.validUntilMs > (nowMs + MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS)
|
||||
);
|
||||
}
|
||||
|
||||
function buildPublicResult(entry, options = {}) {
|
||||
return {
|
||||
key: entry.key,
|
||||
sourceUrl: entry.sourceUrl,
|
||||
validUntil: entry.validUntil,
|
||||
fetchedAt: entry.fetchedAt || null,
|
||||
cached: options.cached === true,
|
||||
stale: options.stale === true
|
||||
};
|
||||
}
|
||||
|
||||
function setInMemoryCache(entry) {
|
||||
cachedBetaKeyEntry = normalizeCacheEntry(entry);
|
||||
}
|
||||
|
||||
async function readPersistentCacheEntry() {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const row = await db.get('SELECT value FROM user_prefs WHERE key = ? LIMIT 1', [MAKEMKV_BETA_KEY_CACHE_PREF_KEY]);
|
||||
if (!row?.value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(String(row.value));
|
||||
return normalizeCacheEntry(parsed);
|
||||
} catch (error) {
|
||||
logger.warn('beta-key:cache-db-read-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistCacheEntry(entry) {
|
||||
const normalized = normalizeCacheEntry(entry);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
key: normalized.key,
|
||||
sourceUrl: normalized.sourceUrl,
|
||||
validUntil: normalized.validUntil,
|
||||
fetchedAt: normalized.fetchedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
`INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
[MAKEMKV_BETA_KEY_CACHE_PREF_KEY, payload]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('beta-key:cache-db-write-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tryUsePersistentCache(nowMs = Date.now()) {
|
||||
const persistent = await readPersistentCacheEntry();
|
||||
if (!isCacheEntryValid(persistent, nowMs)) {
|
||||
return null;
|
||||
}
|
||||
setInMemoryCache(persistent);
|
||||
return buildPublicResult(persistent, { cached: true, stale: false });
|
||||
}
|
||||
|
||||
async function getCachedBetaKey(options = {}) {
|
||||
const nowMs = Date.now();
|
||||
const allowExpired = options?.allowExpired !== false;
|
||||
|
||||
if (cachedBetaKeyEntry) {
|
||||
const stale = !isCacheEntryValid(cachedBetaKeyEntry, nowMs);
|
||||
if (!stale || allowExpired) {
|
||||
return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale });
|
||||
}
|
||||
}
|
||||
|
||||
const persistent = await readPersistentCacheEntry();
|
||||
if (!persistent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setInMemoryCache(persistent);
|
||||
const stale = !isCacheEntryValid(persistent, nowMs);
|
||||
if (stale && !allowExpired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildPublicResult(persistent, { cached: true, stale });
|
||||
}
|
||||
|
||||
function createRateLimitError(retryAfterMs = 0) {
|
||||
const parsedRetryMs = Number(retryAfterMs || 0);
|
||||
const waitMs = parsedRetryMs > 0 ? parsedRetryMs : MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS;
|
||||
blockedUntilMs = Math.max(blockedUntilMs, Date.now() + waitMs);
|
||||
|
||||
const retryAtIso = new Date(blockedUntilMs).toISOString();
|
||||
const error = new Error(`Betakey-API limitiert Anfragen. Neuer Versuch nach ${retryAtIso}.`);
|
||||
error.statusCode = 429;
|
||||
error.retryAt = retryAtIso;
|
||||
error.retryAfterMs = Math.max(1000, blockedUntilMs - Date.now());
|
||||
return error;
|
||||
}
|
||||
|
||||
function buildExistingBackoffError() {
|
||||
const retryAtMs = Math.max(Date.now() + 1000, Number(blockedUntilMs || 0));
|
||||
const retryAtIso = new Date(retryAtMs).toISOString();
|
||||
const error = new Error(`Betakey-API limitiert Anfragen. Neuer Versuch nach ${retryAtIso}.`);
|
||||
error.statusCode = 429;
|
||||
error.retryAt = retryAtIso;
|
||||
error.retryAfterMs = Math.max(1000, retryAtMs - Date.now());
|
||||
return error;
|
||||
}
|
||||
|
||||
function resolveValidityOrFallback(validUntilIso, sourceUrl) {
|
||||
const parsedMs = Date.parse(String(validUntilIso || '').trim());
|
||||
if (Number.isFinite(parsedMs)) {
|
||||
return new Date(parsedMs).toISOString();
|
||||
}
|
||||
|
||||
const fallbackIso = new Date(Date.now() + MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS * 60 * 60 * 1000).toISOString();
|
||||
logger.warn('beta-key:validity-parse-fallback', {
|
||||
sourceUrl: sourceUrl || null,
|
||||
fallbackIso
|
||||
});
|
||||
return fallbackIso;
|
||||
}
|
||||
|
||||
function normalizeRegistrationKey(rawValue) {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
|
||||
function getMakeMKVConfigDir(homeDir = os.homedir()) {
|
||||
return path.join(String(homeDir || '').trim() || os.homedir(), '.MakeMKV');
|
||||
}
|
||||
|
||||
function getMakeMKVSettingsFilePath(homeDir = os.homedir()) {
|
||||
return path.join(getMakeMKVConfigDir(homeDir), 'settings.conf');
|
||||
}
|
||||
|
||||
function escapeKeyForSettingsFile(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function buildUpdatedSettingsContent(currentContent, registrationKey) {
|
||||
const existingLines = String(currentContent || '')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !/^\s*app_Key\s*=/.test(line));
|
||||
const normalizedKey = normalizeRegistrationKey(registrationKey);
|
||||
|
||||
while (existingLines.length > 0 && existingLines[existingLines.length - 1] === '') {
|
||||
existingLines.pop();
|
||||
}
|
||||
|
||||
if (normalizedKey) {
|
||||
existingLines.push(`app_Key = "${escapeKeyForSettingsFile(normalizedKey)}"`);
|
||||
}
|
||||
|
||||
return existingLines.length > 0 ? `${existingLines.join('\n')}\n` : '';
|
||||
}
|
||||
|
||||
async function syncRegistrationKeyToConfig(rawValue, options = {}) {
|
||||
const normalizedKey = normalizeRegistrationKey(rawValue);
|
||||
const homeDir = String(options?.homeDir || '').trim() || os.homedir();
|
||||
const configDir = getMakeMKVConfigDir(homeDir);
|
||||
const settingsFilePath = getMakeMKVSettingsFilePath(homeDir);
|
||||
const fileExists = fs.existsSync(settingsFilePath);
|
||||
const currentContent = fileExists
|
||||
? await fs.promises.readFile(settingsFilePath, 'utf8')
|
||||
: '';
|
||||
const nextContent = buildUpdatedSettingsContent(currentContent, normalizedKey);
|
||||
|
||||
if (!normalizedKey && !fileExists) {
|
||||
return {
|
||||
changed: false,
|
||||
path: settingsFilePath,
|
||||
hasKey: false
|
||||
};
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(configDir, { recursive: true });
|
||||
|
||||
if (nextContent) {
|
||||
await fs.promises.writeFile(settingsFilePath, nextContent, 'utf8');
|
||||
await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {});
|
||||
logger.info('settings-conf:key-synced', {
|
||||
path: settingsFilePath,
|
||||
hasKey: true
|
||||
});
|
||||
} else {
|
||||
await fs.promises.writeFile(settingsFilePath, '', 'utf8');
|
||||
await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {});
|
||||
logger.info('settings-conf:key-cleared', {
|
||||
path: settingsFilePath,
|
||||
hasKey: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
changed: currentContent !== nextContent,
|
||||
path: settingsFilePath,
|
||||
hasKey: Boolean(normalizedKey)
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCurrentBetaKey(options = {}) {
|
||||
const forceRefresh = options?.forceRefresh === true;
|
||||
const now = Date.now();
|
||||
|
||||
if (!forceRefresh && isCacheEntryValid(cachedBetaKeyEntry, now)) {
|
||||
return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale: false });
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
const persistentCacheHit = await tryUsePersistentCache(now);
|
||||
if (persistentCacheHit) {
|
||||
return persistentCacheHit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!forceRefresh && blockedUntilMs > now) {
|
||||
throw buildExistingBackoffError();
|
||||
}
|
||||
|
||||
try {
|
||||
const apiResult = await fetchCurrentBetaKeyViaCurl();
|
||||
const normalized = normalizeCacheEntry({
|
||||
...apiResult,
|
||||
fetchedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!normalized) {
|
||||
const error = new Error('API-curl-Betakey konnte nicht normalisiert werden.');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
blockedUntilMs = 0;
|
||||
setInMemoryCache(normalized);
|
||||
await persistCacheEntry(normalized);
|
||||
return buildPublicResult(normalized, { cached: false, stale: false });
|
||||
} catch (error) {
|
||||
logger.warn('beta-key:fetch-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
if (Number(error?.httpStatus || error?.statusCode || error?.status || 0) === 429) {
|
||||
throw createRateLimitError(Number(error?.retryAfterMs || 0));
|
||||
}
|
||||
const resolved = new Error(`Betakey konnte nicht geladen werden. Details: ${error?.message || String(error)}`);
|
||||
resolved.statusCode = Number(error?.httpStatus || error?.statusCode || error?.status || 502) || 502;
|
||||
throw resolved;
|
||||
}
|
||||
}
|
||||
|
||||
function parseBetaKeyResponse(rawBody) {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(String(rawBody || '{}'));
|
||||
} catch (_error) {
|
||||
const error = new Error('Betakey-Antwort ist kein gültiges JSON.');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const betaKey = parseBetaKeyCandidate(String(payload?.key || '').trim(), 'Betakey-Antwort ist ungültig.');
|
||||
|
||||
let validUntil = null;
|
||||
const unixExpiry = Number(payload?.keydate || payload?.expires || payload?.valid_until_unix || 0);
|
||||
if (Number.isFinite(unixExpiry) && unixExpiry > 0) {
|
||||
validUntil = new Date(unixExpiry * 1000).toISOString();
|
||||
}
|
||||
|
||||
const resolvedValidUntil = resolveValidityOrFallback(validUntil, MAKEMKV_BETA_KEY_API_URL);
|
||||
|
||||
return {
|
||||
key: betaKey,
|
||||
sourceUrl: MAKEMKV_BETA_KEY_API_URL,
|
||||
validUntil: resolvedValidUntil
|
||||
};
|
||||
}
|
||||
|
||||
function fetchCurrentBetaKeyViaCurl() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const curlArgs = [
|
||||
'-fsSL',
|
||||
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
|
||||
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
|
||||
MAKEMKV_BETA_KEY_API_URL
|
||||
];
|
||||
|
||||
execFile(
|
||||
'curl',
|
||||
curlArgs,
|
||||
{
|
||||
timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const resolvedError = new Error(
|
||||
`curl fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
|
||||
);
|
||||
resolvedError.statusCode = 502;
|
||||
if (/\b429\b/.test(String(stderr || ''))) {
|
||||
resolvedError.httpStatus = 429;
|
||||
}
|
||||
if (/\b403\b/.test(String(stderr || ''))) {
|
||||
resolvedError.httpStatus = 403;
|
||||
}
|
||||
reject(resolvedError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseBetaKeyResponse(stdout));
|
||||
} catch (parseError) {
|
||||
reject(parseError);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAKEMKV_BETA_KEY_API_URL,
|
||||
fetchCurrentBetaKey,
|
||||
getCachedBetaKey,
|
||||
getMakeMKVConfigDir,
|
||||
getMakeMKVSettingsFilePath,
|
||||
normalizeRegistrationKey,
|
||||
syncRegistrationKeyToConfig
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
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
|
||||
};
|
||||
});
|
||||
const rawReleaseTrackCount = Number(release['track-count']);
|
||||
const mediaTrackCount = media.reduce((sum, medium) => {
|
||||
const mediumCount = Number(medium?.['track-count'] ?? medium?.trackCount);
|
||||
if (!Number.isFinite(mediumCount) || mediumCount <= 0) {
|
||||
return sum;
|
||||
}
|
||||
return sum + Math.trunc(mediumCount);
|
||||
}, 0);
|
||||
const trackCount = Number.isFinite(rawReleaseTrackCount) && rawReleaseTrackCount > 0
|
||||
? Math.trunc(rawReleaseTrackCount)
|
||||
: (mediaTrackCount > 0 ? mediaTrackCount : normalizedTracks.length);
|
||||
|
||||
// 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,
|
||||
trackCount
|
||||
};
|
||||
}
|
||||
|
||||
class MusicBrainzService {
|
||||
async searchByTitle(query, options = {}) {
|
||||
const q = String(query || '').trim();
|
||||
if (!q) {
|
||||
return [];
|
||||
}
|
||||
const expectedTrackCountRaw = Number(options?.trackCount);
|
||||
const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0
|
||||
? Math.trunc(expectedTrackCountRaw)
|
||||
: null;
|
||||
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 normalizedReleases = releases.map(normalizeRelease).filter(Boolean);
|
||||
const results = expectedTrackCount
|
||||
? normalizedReleases.filter((release) => Number(release?.trackCount || 0) === expectedTrackCount)
|
||||
: normalizedReleases;
|
||||
logger.info('search:done', {
|
||||
query: q,
|
||||
expectedTrackCount,
|
||||
sourceCount: normalizedReleases.length,
|
||||
count: results.length
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.warn('search:failed', {
|
||||
query: q,
|
||||
expectedTrackCount,
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,165 @@
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('PUSHOVER');
|
||||
const { toBoolean } = require('../utils/validators');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const PUSHOVER_API_URL = 'https://api.pushover.net/1/messages.json';
|
||||
|
||||
const EVENT_TOGGLE_KEYS = {
|
||||
metadata_ready: 'pushover_notify_metadata_ready',
|
||||
rip_started: 'pushover_notify_rip_started',
|
||||
encoding_started: 'pushover_notify_encoding_started',
|
||||
job_finished: 'pushover_notify_job_finished',
|
||||
job_error: 'pushover_notify_job_error',
|
||||
job_cancelled: 'pushover_notify_job_cancelled',
|
||||
reencode_started: 'pushover_notify_reencode_started',
|
||||
reencode_finished: 'pushover_notify_reencode_finished'
|
||||
};
|
||||
|
||||
function truncate(value, maxLen = 1024) {
|
||||
const text = String(value || '').trim();
|
||||
if (text.length <= maxLen) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, maxLen - 20)}...[truncated]`;
|
||||
}
|
||||
|
||||
function normalizePriority(raw) {
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) {
|
||||
return 0;
|
||||
}
|
||||
if (n < -2) {
|
||||
return -2;
|
||||
}
|
||||
if (n > 2) {
|
||||
return 2;
|
||||
}
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
async notify(eventKey, payload = {}) {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return this.notifyWithSettings(settings, eventKey, payload);
|
||||
}
|
||||
|
||||
async sendTest({ title, message } = {}) {
|
||||
return this.notify('test', {
|
||||
title: title || 'Ripster Test',
|
||||
message: message || 'PushOver Testnachricht von Ripster.'
|
||||
});
|
||||
}
|
||||
|
||||
async notifyWithSettings(settings, eventKey, payload = {}) {
|
||||
const enabled = toBoolean(settings.pushover_enabled);
|
||||
if (!enabled) {
|
||||
logger.debug('notify:skip:disabled', { eventKey });
|
||||
return { sent: false, reason: 'disabled', eventKey };
|
||||
}
|
||||
|
||||
const toggleKey = EVENT_TOGGLE_KEYS[eventKey];
|
||||
if (toggleKey && !toBoolean(settings[toggleKey])) {
|
||||
logger.debug('notify:skip:event-disabled', { eventKey, toggleKey });
|
||||
return { sent: false, reason: 'event-disabled', eventKey };
|
||||
}
|
||||
|
||||
const token = String(settings.pushover_token || '').trim();
|
||||
const user = String(settings.pushover_user || '').trim();
|
||||
if (!token || !user) {
|
||||
logger.warn('notify:skip:missing-credentials', {
|
||||
eventKey,
|
||||
hasToken: Boolean(token),
|
||||
hasUser: Boolean(user)
|
||||
});
|
||||
return { sent: false, reason: 'missing-credentials', eventKey };
|
||||
}
|
||||
|
||||
const prefix = String(settings.pushover_title_prefix || 'Ripster').trim();
|
||||
const title = truncate(payload.title || `${prefix} - ${eventKey}`, 120);
|
||||
const message = truncate(payload.message || eventKey, 1024);
|
||||
const priority = normalizePriority(
|
||||
payload.priority !== undefined ? payload.priority : settings.pushover_priority
|
||||
);
|
||||
const timeoutMs = Math.max(1000, Number(settings.pushover_timeout_ms || 7000));
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set('token', token);
|
||||
form.set('user', user);
|
||||
form.set('title', title);
|
||||
form.set('message', message);
|
||||
form.set('priority', String(priority));
|
||||
|
||||
const device = String(settings.pushover_device || '').trim();
|
||||
if (device) {
|
||||
form.set('device', device);
|
||||
}
|
||||
|
||||
if (payload.url) {
|
||||
form.set('url', String(payload.url));
|
||||
}
|
||||
if (payload.urlTitle) {
|
||||
form.set('url_title', String(payload.urlTitle));
|
||||
}
|
||||
if (payload.sound) {
|
||||
form.set('sound', String(payload.sound));
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(PUSHOVER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: form.toString(),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = rawText ? JSON.parse(rawText) : null;
|
||||
} catch (error) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const messageText = data?.errors?.join(', ') || data?.error || rawText || `HTTP ${response.status}`;
|
||||
const error = new Error(`PushOver HTTP ${response.status}: ${messageText}`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data && data.status !== 1) {
|
||||
const messageText = data.errors?.join(', ') || data.error || 'Unbekannte PushOver Antwort.';
|
||||
throw new Error(`PushOver Fehler: ${messageText}`);
|
||||
}
|
||||
|
||||
logger.info('notify:sent', {
|
||||
eventKey,
|
||||
title,
|
||||
priority,
|
||||
requestId: data?.request || null
|
||||
});
|
||||
return {
|
||||
sent: true,
|
||||
eventKey,
|
||||
requestId: data?.request || null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('notify:failed', {
|
||||
eventKey,
|
||||
title,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NotificationService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
const { spawn } = require('child_process');
|
||||
const logger = require('./logger').child('PROCESS');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
function streamLines(stream, onLine) {
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const parts = buffer.split(/\r\n|\n|\r/);
|
||||
buffer = parts.pop() ?? '';
|
||||
|
||||
for (const line of parts) {
|
||||
if (line.length > 0) {
|
||||
onLine(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (buffer.length > 0) {
|
||||
onLine(buffer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function spawnTrackedProcess({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
onStdoutLine,
|
||||
onStderrLine,
|
||||
onStart,
|
||||
context = {}
|
||||
}) {
|
||||
logger.info('spawn:start', { cmd, args, cwd, context });
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true
|
||||
});
|
||||
|
||||
if (onStart) {
|
||||
onStart(child);
|
||||
}
|
||||
|
||||
if (child.stdout && onStdoutLine) {
|
||||
streamLines(child.stdout, onStdoutLine);
|
||||
}
|
||||
|
||||
if (child.stderr && onStderrLine) {
|
||||
streamLines(child.stderr, onStderrLine);
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
child.on('error', (error) => {
|
||||
logger.error('spawn:error', { cmd, args, context, error: errorToMeta(error) });
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
logger.info('spawn:close', { cmd, args, code, signal, context });
|
||||
if (code === 0) {
|
||||
resolve({ code, signal });
|
||||
} else {
|
||||
const error = new Error(`Prozess ${cmd} beendet mit Code ${code ?? 'null'} (Signal ${signal ?? 'none'}).`);
|
||||
error.code = code;
|
||||
error.signal = signal;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let cancelCalled = false;
|
||||
const killProcessTree = (signal) => {
|
||||
const pid = Number(child.pid);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
// fallback below
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const cancel = () => {
|
||||
if (cancelCalled) {
|
||||
return;
|
||||
}
|
||||
cancelCalled = true;
|
||||
|
||||
logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid });
|
||||
// Instant cancel by user request.
|
||||
killProcessTree('SIGKILL');
|
||||
};
|
||||
|
||||
return {
|
||||
child,
|
||||
promise,
|
||||
cancel
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
spawnTrackedProcess,
|
||||
streamLines
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
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();
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS } = {}) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
let text = String(value);
|
||||
if (trim) {
|
||||
text = text.trim();
|
||||
}
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
if (text.length > maxChars) {
|
||||
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 });
|
||||
return {
|
||||
id: normalizeNumber(source.id),
|
||||
type: String(source.type || '').trim().toLowerCase() || 'task',
|
||||
name: String(source.name || '').trim() || null,
|
||||
status: String(source.status || '').trim().toLowerCase() || 'running',
|
||||
source: String(source.source || '').trim() || null,
|
||||
message: String(source.message || '').trim() || null,
|
||||
currentStep: String(source.currentStep || '').trim() || null,
|
||||
currentStepType: String(source.currentStepType || '').trim() || null,
|
||||
currentScriptName: String(source.currentScriptName || '').trim() || null,
|
||||
stepIndex: normalizeNumber(source.stepIndex),
|
||||
stepTotal: normalizeNumber(source.stepTotal),
|
||||
parentActivityId: normalizeNumber(source.parentActivityId),
|
||||
jobId: normalizeNumber(source.jobId),
|
||||
cronJobId: normalizeNumber(source.cronJobId),
|
||||
chainId: normalizeNumber(source.chainId),
|
||||
scriptId: normalizeNumber(source.scriptId),
|
||||
canCancel: Boolean(source.canCancel),
|
||||
canNextStep: Boolean(source.canNextStep),
|
||||
outcome: normalizedOutcome ? String(normalizedOutcome).toLowerCase() : null,
|
||||
errorMessage: normalizeText(source.errorMessage, { trim: true, maxChars: MAX_ACTIVITY_TEXT_CHARS }),
|
||||
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(),
|
||||
finishedAt: source.finishedAt || null,
|
||||
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
|
||||
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
|
||||
success: source.success === null || source.success === undefined ? null : Boolean(source.success)
|
||||
};
|
||||
}
|
||||
|
||||
class RuntimeActivityService {
|
||||
constructor() {
|
||||
this.nextId = 1;
|
||||
this.active = new Map();
|
||||
this.recent = [];
|
||||
this.controls = new Map();
|
||||
this.outputBroadcastTimer = null;
|
||||
}
|
||||
|
||||
buildSnapshot() {
|
||||
const active = Array.from(this.active.values())
|
||||
.sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
|
||||
const recent = [...this.recent]
|
||||
.sort((a, b) => String(b.finishedAt || b.startedAt || '').localeCompare(String(a.finishedAt || a.startedAt || '')));
|
||||
return {
|
||||
active,
|
||||
recent,
|
||||
updatedAt: nowIso()
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
const activity = sanitizeActivity({
|
||||
...payload,
|
||||
id,
|
||||
type,
|
||||
status: 'running',
|
||||
outcome: 'running',
|
||||
startedAt: payload?.startedAt || nowIso(),
|
||||
finishedAt: null,
|
||||
durationMs: null,
|
||||
canCancel: Boolean(payload?.canCancel),
|
||||
canNextStep: Boolean(payload?.canNextStep)
|
||||
});
|
||||
this.active.set(id, activity);
|
||||
this.broadcastSnapshot();
|
||||
return id;
|
||||
}
|
||||
|
||||
updateActivity(activityId, patch = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const current = this.active.get(id);
|
||||
const next = sanitizeActivity({
|
||||
...current,
|
||||
...patch,
|
||||
id: current.id,
|
||||
type: current.type,
|
||||
status: current.status === 'running' ? (patch?.status || current.status) : current.status,
|
||||
startedAt: current.startedAt
|
||||
});
|
||||
this.active.set(id, next);
|
||||
this.broadcastSnapshot();
|
||||
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)) {
|
||||
return null;
|
||||
}
|
||||
const current = this.active.get(id);
|
||||
const finishedAt = payload?.finishedAt || nowIso();
|
||||
const startedAtDate = new Date(current.startedAt);
|
||||
const finishedAtDate = new Date(finishedAt);
|
||||
const durationMs = Number.isFinite(startedAtDate.getTime()) && Number.isFinite(finishedAtDate.getTime())
|
||||
? Math.max(0, finishedAtDate.getTime() - startedAtDate.getTime())
|
||||
: null;
|
||||
const status = String(payload?.status || '').trim().toLowerCase() || (payload?.success === false ? 'error' : 'success');
|
||||
let outcome = String(payload?.outcome || '').trim().toLowerCase();
|
||||
if (!outcome) {
|
||||
if (Boolean(payload?.cancelled)) {
|
||||
outcome = 'cancelled';
|
||||
} else if (Boolean(payload?.skipped)) {
|
||||
outcome = 'skipped';
|
||||
} else {
|
||||
outcome = status === 'success' ? 'success' : 'error';
|
||||
}
|
||||
}
|
||||
const finalized = sanitizeActivity({
|
||||
...current,
|
||||
...payload,
|
||||
id: current.id,
|
||||
type: current.type,
|
||||
status,
|
||||
outcome,
|
||||
canCancel: false,
|
||||
canNextStep: false,
|
||||
finishedAt,
|
||||
durationMs
|
||||
});
|
||||
this.active.delete(id);
|
||||
this.controls.delete(id);
|
||||
this.recent.unshift(finalized);
|
||||
if (this.recent.length > MAX_RECENT_ACTIVITIES) {
|
||||
this.recent = this.recent.slice(0, MAX_RECENT_ACTIVITIES);
|
||||
}
|
||||
this.broadcastSnapshot();
|
||||
return finalized;
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
clearRecent() {
|
||||
const removed = this.recent.length;
|
||||
if (removed === 0) {
|
||||
return { removed: 0, snapshot: this.buildSnapshot() };
|
||||
}
|
||||
this.recent = [];
|
||||
this.broadcastSnapshot();
|
||||
return {
|
||||
removed,
|
||||
snapshot: this.buildSnapshot()
|
||||
};
|
||||
}
|
||||
|
||||
setControls(activityId, handlers = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const safeHandlers = {
|
||||
cancel: typeof handlers?.cancel === 'function' ? handlers.cancel : null,
|
||||
nextStep: typeof handlers?.nextStep === 'function' ? handlers.nextStep : null
|
||||
};
|
||||
this.controls.set(id, safeHandlers);
|
||||
return this.updateActivity(id, {
|
||||
canCancel: Boolean(safeHandlers.cancel),
|
||||
canNextStep: Boolean(safeHandlers.nextStep)
|
||||
});
|
||||
}
|
||||
|
||||
async invokeControl(activityId, control, payload = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Aktivität nicht gefunden oder bereits abgeschlossen.'
|
||||
};
|
||||
}
|
||||
const handlers = this.controls.get(id) || {};
|
||||
const key = control === 'nextStep' ? 'nextStep' : 'cancel';
|
||||
const fn = handlers[key];
|
||||
if (typeof fn !== 'function') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'UNSUPPORTED',
|
||||
message: key === 'nextStep'
|
||||
? 'Nächster-Schritt ist für diese Aktivität nicht verfügbar.'
|
||||
: 'Abbrechen ist für diese Aktivität nicht verfügbar.'
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await fn(payload);
|
||||
return {
|
||||
ok: true,
|
||||
code: 'OK',
|
||||
result: result && typeof result === 'object' ? result : null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'FAILED',
|
||||
message: error?.message || 'Aktion fehlgeschlagen.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async requestCancel(activityId, payload = {}) {
|
||||
return this.invokeControl(activityId, 'cancel', payload);
|
||||
}
|
||||
|
||||
async requestNextStep(activityId, payload = {}) {
|
||||
return this.invokeControl(activityId, 'nextStep', payload);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RuntimeActivityService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,730 @@
|
||||
const fs = require('fs');
|
||||
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 { tempDir } = require('../config');
|
||||
|
||||
const SCRIPT_NAME_MAX_LENGTH = 120;
|
||||
const SCRIPT_BODY_MAX_LENGTH = 200000;
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
function normalizeScriptIdList(rawList) {
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const item of list) {
|
||||
const normalized = normalizeScriptId(item);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeScriptName(rawValue) {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
|
||||
function normalizeScriptBody(rawValue) {
|
||||
return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
}
|
||||
|
||||
function normalizeFavoriteFlag(rawValue, fallback = 0) {
|
||||
if (typeof rawValue === 'boolean') {
|
||||
return rawValue ? 1 : 0;
|
||||
}
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed !== 0 ? 1 : 0;
|
||||
}
|
||||
const normalized = String(rawValue || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback ? 1 : 0;
|
||||
}
|
||||
if (['true', 'yes', 'on', '1'].includes(normalized)) {
|
||||
return 1;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(normalized)) {
|
||||
return 0;
|
||||
}
|
||||
return fallback ? 1 : 0;
|
||||
}
|
||||
|
||||
function createValidationError(message, details = null) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
if (details) {
|
||||
error.details = details;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function validateScriptPayload(payload, { partial = false } = {}) {
|
||||
const body = payload && typeof payload === 'object' ? payload : {};
|
||||
const hasName = Object.prototype.hasOwnProperty.call(body, 'name');
|
||||
const hasScriptBody = Object.prototype.hasOwnProperty.call(body, 'scriptBody');
|
||||
const normalized = {};
|
||||
const errors = [];
|
||||
|
||||
if (!partial || hasName) {
|
||||
const name = normalizeScriptName(body.name);
|
||||
if (!name) {
|
||||
errors.push({ field: 'name', message: 'Name darf nicht leer sein.' });
|
||||
} else if (name.length > SCRIPT_NAME_MAX_LENGTH) {
|
||||
errors.push({ field: 'name', message: `Name darf maximal ${SCRIPT_NAME_MAX_LENGTH} Zeichen enthalten.` });
|
||||
} else {
|
||||
normalized.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!partial || hasScriptBody) {
|
||||
const scriptBody = normalizeScriptBody(body.scriptBody);
|
||||
if (!scriptBody.trim()) {
|
||||
errors.push({ field: 'scriptBody', message: 'Skript darf nicht leer sein.' });
|
||||
} else if (scriptBody.length > SCRIPT_BODY_MAX_LENGTH) {
|
||||
errors.push({ field: 'scriptBody', message: `Skript darf maximal ${SCRIPT_BODY_MAX_LENGTH} Zeichen enthalten.` });
|
||||
} else {
|
||||
normalized.scriptBody = scriptBody;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw createValidationError('Skript ist ungültig.', errors);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapScriptRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
scriptBody: String(row.script_body || ''),
|
||||
isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1,
|
||||
orderIndex: Number(row.order_index || 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function quoteForBashSingle(value) {
|
||||
return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function buildScriptEnvironment(context = {}) {
|
||||
const now = new Date().toISOString();
|
||||
const entries = {
|
||||
RIPSTER_SCRIPT_RUN_AT: now,
|
||||
RIPSTER_JOB_ID: context?.jobId ?? '',
|
||||
RIPSTER_JOB_TITLE: context?.jobTitle ?? '',
|
||||
RIPSTER_MODE: context?.mode ?? '',
|
||||
RIPSTER_INPUT_PATH: context?.inputPath ?? '',
|
||||
RIPSTER_OUTPUT_PATH: context?.outputPath ?? '',
|
||||
RIPSTER_RAW_PATH: context?.rawPath ?? '',
|
||||
RIPSTER_SCRIPT_ID: context?.scriptId ?? '',
|
||||
RIPSTER_SCRIPT_NAME: context?.scriptName ?? '',
|
||||
RIPSTER_SCRIPT_SOURCE: context?.source ?? ''
|
||||
};
|
||||
|
||||
const output = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
output[key] = String(value ?? '');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildScriptWrapper(scriptBody, context = {}) {
|
||||
const envVars = buildScriptEnvironment(context);
|
||||
const exportLines = Object.entries(envVars)
|
||||
.map(([key, value]) => `export ${key}=${quoteForBashSingle(value)}`)
|
||||
.join('\n');
|
||||
// Wait for potential background jobs started by the script before returning.
|
||||
return `${exportLines}\n\n${String(scriptBody || '')}\n\nwait\n`;
|
||||
}
|
||||
|
||||
function appendWithCap(current, chunk, maxChars) {
|
||||
const value = String(chunk || '');
|
||||
if (!value) {
|
||||
return { value: current, truncated: false };
|
||||
}
|
||||
const currentText = String(current || '');
|
||||
if (currentText.length >= maxChars) {
|
||||
return { value: currentText, truncated: true };
|
||||
}
|
||||
const available = maxChars - currentText.length;
|
||||
if (value.length <= available) {
|
||||
return { value: `${currentText}${value}`, truncated: false };
|
||||
}
|
||||
return {
|
||||
value: `${currentText}${value.slice(0, available)}`,
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function killChildProcessTree(child, signal = 'SIGTERM') {
|
||||
if (!child) {
|
||||
return false;
|
||||
}
|
||||
const pid = Number(child.pid);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
try {
|
||||
// If spawned as detached=true this targets the full process group.
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
// Fallback below.
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill(signal);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true
|
||||
});
|
||||
if (typeof onChild === 'function') {
|
||||
try {
|
||||
onChild(child);
|
||||
} catch (_error) {
|
||||
// ignore observer errors
|
||||
}
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
let timedOut = false;
|
||||
|
||||
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') {
|
||||
const next = appendWithCap(stdout, chunk, SCRIPT_OUTPUT_MAX_CHARS);
|
||||
stdout = next.value;
|
||||
stdoutTruncated = stdoutTruncated || next.truncated;
|
||||
} else {
|
||||
const next = appendWithCap(stderr, chunk, SCRIPT_OUTPUT_MAX_CHARS);
|
||||
stderr = next.value;
|
||||
stderrTruncated = stderrTruncated || next.truncated;
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
ended = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const endedAt = Date.now();
|
||||
resolve({
|
||||
code: Number.isFinite(Number(code)) ? Number(code) : null,
|
||||
signal: signal || null,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
stdoutTruncated,
|
||||
stderrTruncated
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
ORDER BY order_index ASC, id ASC
|
||||
`
|
||||
);
|
||||
return rows.map(mapScriptRow);
|
||||
}
|
||||
|
||||
async getScriptById(scriptId) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`
|
||||
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
WHERE id = ?
|
||||
`,
|
||||
[normalizedId]
|
||||
);
|
||||
if (!row) {
|
||||
const error = new Error(`Skript #${normalizedId} wurde nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return mapScriptRow(row);
|
||||
}
|
||||
|
||||
async createScript(payload = {}) {
|
||||
const normalized = validateScriptPayload(payload, { partial: false });
|
||||
const db = await getDb();
|
||||
try {
|
||||
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||
const result = await db.run(
|
||||
`
|
||||
INSERT INTO scripts (name, script_body, is_favorite, order_index, created_at, updated_at)
|
||||
VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
[normalized.name, normalized.scriptBody, nextOrderIndex]
|
||||
);
|
||||
return this.getScriptById(result.lastID);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
|
||||
{ field: 'name', message: 'Name muss eindeutig sein.' }
|
||||
]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateScript(scriptId, payload = {}) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const normalized = validateScriptPayload(payload, { partial: false });
|
||||
const db = await getDb();
|
||||
await this.getScriptById(normalizedId);
|
||||
try {
|
||||
await db.run(
|
||||
`
|
||||
UPDATE scripts
|
||||
SET
|
||||
name = ?,
|
||||
script_body = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[normalized.name, normalized.scriptBody, normalizedId]
|
||||
);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
|
||||
{ field: 'name', message: 'Name muss eindeutig sein.' }
|
||||
]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.getScriptById(normalizedId);
|
||||
}
|
||||
|
||||
async deleteScript(scriptId) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const db = await getDb();
|
||||
const existing = await this.getScriptById(normalizedId);
|
||||
await db.run('DELETE FROM scripts WHERE id = ?', [normalizedId]);
|
||||
return existing;
|
||||
}
|
||||
|
||||
async setScriptFavorite(scriptId, isFavorite) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0);
|
||||
const db = await getDb();
|
||||
await this.getScriptById(normalizedId);
|
||||
await db.run(
|
||||
`
|
||||
UPDATE scripts
|
||||
SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[favoriteFlag, normalizedId]
|
||||
);
|
||||
return this.getScriptById(normalizedId);
|
||||
}
|
||||
|
||||
async getScriptsByIds(rawIds = []) {
|
||||
const ids = normalizeScriptIdList(rawIds);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const db = await getDb();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||
FROM scripts
|
||||
WHERE id IN (${placeholders})
|
||||
`,
|
||||
ids
|
||||
);
|
||||
const byId = new Map(rows.map((row) => [Number(row.id), mapScriptRow(row)]));
|
||||
return ids.map((id) => byId.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
async resolveScriptsByIds(rawIds = [], options = {}) {
|
||||
const ids = normalizeScriptIdList(rawIds);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const strict = options?.strict !== false;
|
||||
const scripts = await this.getScriptsByIds(ids);
|
||||
if (!strict) {
|
||||
return scripts;
|
||||
}
|
||||
const foundIds = new Set(scripts.map((item) => Number(item.id)));
|
||||
const missing = ids.filter((id) => !foundIds.has(Number(id)));
|
||||
if (missing.length > 0) {
|
||||
throw createValidationError(`Skript(e) nicht gefunden: ${missing.join(', ')}`, [
|
||||
{ field: 'selectedPostEncodeScriptIds', message: `Nicht gefunden: ${missing.join(', ')}` }
|
||||
]);
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
|
||||
async reorderScripts(orderedIds = []) {
|
||||
const db = await getDb();
|
||||
const providedIds = normalizeScriptIdList(orderedIds);
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id
|
||||
FROM scripts
|
||||
ORDER BY order_index ASC, id ASC
|
||||
`
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0);
|
||||
const existingSet = new Set(existingIds);
|
||||
const used = new Set();
|
||||
const nextOrder = [];
|
||||
|
||||
for (const id of providedIds) {
|
||||
if (!existingSet.has(id) || used.has(id)) {
|
||||
continue;
|
||||
}
|
||||
used.add(id);
|
||||
nextOrder.push(id);
|
||||
}
|
||||
|
||||
for (const id of existingIds) {
|
||||
if (used.has(id)) {
|
||||
continue;
|
||||
}
|
||||
used.add(id);
|
||||
nextOrder.push(id);
|
||||
}
|
||||
|
||||
await db.exec('BEGIN');
|
||||
try {
|
||||
for (let i = 0; i < nextOrder.length; i += 1) {
|
||||
await db.run(
|
||||
`
|
||||
UPDATE scripts
|
||||
SET order_index = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[i + 1, nextOrder[i]]
|
||||
);
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.listScripts();
|
||||
}
|
||||
|
||||
async _getNextOrderIndex(db) {
|
||||
const row = await db.get(
|
||||
`
|
||||
SELECT COALESCE(MAX(order_index), 0) AS max_order_index
|
||||
FROM scripts
|
||||
`
|
||||
);
|
||||
const maxOrder = Number(row?.max_order_index || 0);
|
||||
if (!Number.isFinite(maxOrder) || maxOrder < 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.trunc(maxOrder) + 1;
|
||||
}
|
||||
|
||||
async createExecutableScriptFile(script, context = {}) {
|
||||
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
|
||||
const scriptBody = normalizeScriptBody(script?.scriptBody);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
const scriptTempDir = await fs.promises.mkdtemp(path.join(tempDir, 'ripster-script-'));
|
||||
const scriptPath = path.join(scriptTempDir, 'script.sh');
|
||||
const wrapped = buildScriptWrapper(scriptBody, {
|
||||
...context,
|
||||
scriptId: script?.id ?? context?.scriptId ?? '',
|
||||
scriptName: name,
|
||||
source: context?.source || 'post_encode'
|
||||
});
|
||||
|
||||
await fs.promises.writeFile(scriptPath, wrapped, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o700
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await fs.promises.rm(scriptTempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
logger.warn('script:temp-cleanup-failed', {
|
||||
scriptId: script?.id ?? null,
|
||||
scriptName: name,
|
||||
tempDir: scriptTempDir,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
tempDir: scriptTempDir,
|
||||
scriptPath,
|
||||
cmd: '/usr/bin/env',
|
||||
args: ['bash', scriptPath],
|
||||
argsForLog: ['bash', `<script:${name}>`],
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
|
||||
async testScript(scriptId, options = {}) {
|
||||
const script = await this.getScriptById(scriptId);
|
||||
const effectiveTimeoutMs = await resolveScriptTestTimeoutMs(options);
|
||||
const prepared = await this.createExecutableScriptFile(script, {
|
||||
source: 'settings_test',
|
||||
mode: 'test'
|
||||
});
|
||||
const activityId = runtimeActivityService.startActivity('script', {
|
||||
name: script.name,
|
||||
source: 'settings_test',
|
||||
scriptId: script.id,
|
||||
currentStep: 'Skript-Test läuft'
|
||||
});
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
cancelReason: null,
|
||||
child: null,
|
||||
cancelSignalSent: false
|
||||
};
|
||||
runtimeActivityService.setControls(activityId, {
|
||||
cancel: async (payload = {}) => {
|
||||
if (controlState.cancelRequested) {
|
||||
return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' };
|
||||
}
|
||||
controlState.cancelRequested = true;
|
||||
controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen';
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Abbruch angefordert'
|
||||
});
|
||||
if (controlState.child) {
|
||||
// User cancel should stop instantly.
|
||||
controlState.cancelSignalSent = killChildProcessTree(controlState.child, 'SIGKILL') || controlState.cancelSignalSent;
|
||||
}
|
||||
return { accepted: true, message: 'Abbruch angefordert.' };
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const run = await runProcessCapture({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
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;
|
||||
const finishedSuccessfully = exitCode === 0 && !run.timedOut;
|
||||
const cancelledByUser = Boolean(controlState.cancelRequested)
|
||||
&& (Boolean(controlState.cancelSignalSent) || !finishedSuccessfully);
|
||||
const success = finishedSuccessfully;
|
||||
const message = cancelledByUser
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (run.timedOut
|
||||
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
|
||||
: (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
|
||||
const errorMessage = success
|
||||
? null
|
||||
: (cancelledByUser
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (run.timedOut
|
||||
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
|
||||
: `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'),
|
||||
cancelled: cancelledByUser,
|
||||
exitCode,
|
||||
stdout: run.stdout || null,
|
||||
stderr: run.stderr || null,
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated),
|
||||
errorMessage,
|
||||
message
|
||||
});
|
||||
return {
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success,
|
||||
exitCode: run.code,
|
||||
signal: run.signal,
|
||||
timedOut: run.timedOut,
|
||||
durationMs: run.durationMs,
|
||||
stdout: run.stdout,
|
||||
stderr: run.stderr,
|
||||
stdoutTruncated: run.stdoutTruncated,
|
||||
stderrTruncated: run.stderrTruncated
|
||||
};
|
||||
} catch (error) {
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: controlState.cancelRequested ? 'cancelled' : 'error',
|
||||
cancelled: Boolean(controlState.cancelRequested),
|
||||
errorMessage: controlState.cancelRequested
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (error?.message || 'Skript-Test Fehler'),
|
||||
message: controlState.cancelRequested
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (error?.message || 'Skript-Test Fehler')
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
controlState.child = null;
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ScriptService();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('./logger').child('TEMP_CLEANUP');
|
||||
const { tempDir } = require('../config');
|
||||
|
||||
const TMP_ROOT = tempDir;
|
||||
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
const TOP_LEVEL_PREFIXES = [
|
||||
'ripster-merge-',
|
||||
'ripster-script-',
|
||||
'ripster-export-'
|
||||
];
|
||||
|
||||
const MANAGED_UPLOAD_DIRS = [
|
||||
'ripster-converter-uploads',
|
||||
'ripster-audiobook-uploads'
|
||||
];
|
||||
|
||||
function getEntryAgeMs(stats) {
|
||||
const referenceTime = Math.max(
|
||||
Number(stats?.mtimeMs) || 0,
|
||||
Number(stats?.ctimeMs) || 0,
|
||||
Number(stats?.birthtimeMs) || 0
|
||||
);
|
||||
return Date.now() - referenceTime;
|
||||
}
|
||||
|
||||
function isStale(stats, minAgeMs = STALE_ENTRY_MIN_AGE_MS) {
|
||||
return getEntryAgeMs(stats) >= minAgeMs;
|
||||
}
|
||||
|
||||
function removeEntry(targetPath, stats) {
|
||||
const isDirectory = stats?.isDirectory?.() || false;
|
||||
fs.rmSync(targetPath, {
|
||||
recursive: isDirectory,
|
||||
force: true
|
||||
});
|
||||
}
|
||||
|
||||
class TempCleanupService {
|
||||
constructor() {
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.runSweep('startup');
|
||||
|
||||
if (!this.interval) {
|
||||
this.interval = setInterval(() => {
|
||||
this.runSweep('interval').catch((error) => {
|
||||
logger.warn('temp:sweep:interval-failed', { error: error?.message || String(error) });
|
||||
});
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
this.interval.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async runSweep(reason = 'manual') {
|
||||
fs.mkdirSync(TMP_ROOT, { recursive: true });
|
||||
const deleted = [];
|
||||
const skipped = [];
|
||||
const failed = [];
|
||||
|
||||
let topLevelEntries = [];
|
||||
try {
|
||||
topLevelEntries = fs.readdirSync(TMP_ROOT, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
logger.warn('temp:sweep:readdir-failed', {
|
||||
reason,
|
||||
tmpRoot: TMP_ROOT,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return { deleted, skipped, failed };
|
||||
}
|
||||
|
||||
for (const entry of topLevelEntries) {
|
||||
const entryName = String(entry?.name || '').trim();
|
||||
if (!entryName || !TOP_LEVEL_PREFIXES.some((prefix) => entryName.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
const targetPath = path.join(TMP_ROOT, entryName);
|
||||
try {
|
||||
const stats = fs.lstatSync(targetPath);
|
||||
if (!isStale(stats)) {
|
||||
skipped.push({ path: targetPath, reason: 'fresh-top-level-entry' });
|
||||
continue;
|
||||
}
|
||||
removeEntry(targetPath, stats);
|
||||
deleted.push(targetPath);
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: targetPath,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dirName of MANAGED_UPLOAD_DIRS) {
|
||||
const uploadDir = path.join(TMP_ROOT, dirName);
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uploadEntries = [];
|
||||
try {
|
||||
uploadEntries = fs.readdirSync(uploadDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: uploadDir,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of uploadEntries) {
|
||||
const targetPath = path.join(uploadDir, entry.name);
|
||||
try {
|
||||
const stats = fs.lstatSync(targetPath);
|
||||
if (!isStale(stats)) {
|
||||
skipped.push({ path: targetPath, reason: 'fresh-upload-entry' });
|
||||
continue;
|
||||
}
|
||||
removeEntry(targetPath, stats);
|
||||
deleted.push(targetPath);
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
path: targetPath,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('temp:sweep:completed', {
|
||||
reason,
|
||||
tmpRoot: TMP_ROOT,
|
||||
deletedCount: deleted.length,
|
||||
skippedCount: skipped.length,
|
||||
failedCount: failed.length,
|
||||
deleted: deleted.slice(0, 50),
|
||||
failed: failed.slice(0, 20)
|
||||
});
|
||||
|
||||
return { deleted, skipped, failed };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TempCleanupService();
|
||||
@@ -0,0 +1,316 @@
|
||||
'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 resolveLocalThumbnailPath(url) {
|
||||
const raw = String(url || '').trim();
|
||||
const match = raw.match(/^\/api\/thumbnails\/job-(\d+)\.jpg$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const jobId = Number(match[1]);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return null;
|
||||
}
|
||||
return persistentFilePath(Math.trunc(jobId));
|
||||
}
|
||||
|
||||
function localThumbnailUrlExists(url) {
|
||||
const targetPath = resolveLocalThumbnailPath(url);
|
||||
if (!targetPath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(targetPath);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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 cacheJobThumbnailDetailed(jobId, posterUrl) {
|
||||
const normalizedPosterUrl = String(posterUrl || '').trim();
|
||||
if (!normalizedPosterUrl || isLocalUrl(normalizedPosterUrl)) {
|
||||
return {
|
||||
ok: false,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl || null,
|
||||
cachedPath: null,
|
||||
error: 'Kein externer Poster-Link vorhanden.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDirs();
|
||||
const dest = cacheFilePath(jobId);
|
||||
await downloadImage(normalizedPosterUrl, dest);
|
||||
logger.info('thumbnail:cached', { jobId, posterUrl: normalizedPosterUrl, dest });
|
||||
return {
|
||||
ok: true,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl,
|
||||
cachedPath: dest,
|
||||
error: null
|
||||
};
|
||||
} catch (err) {
|
||||
const message = String(err?.message || err || 'Bild-Download fehlgeschlagen');
|
||||
logger.warn('thumbnail:cache:failed', { jobId, posterUrl: normalizedPosterUrl, error: message });
|
||||
return {
|
||||
ok: false,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl,
|
||||
cachedPath: null,
|
||||
error: message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||
const result = await cacheJobThumbnailDetailed(jobId, posterUrl);
|
||||
return result?.ok ? result.cachedPath : 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,
|
||||
cacheJobThumbnailDetailed,
|
||||
promoteJobThumbnail,
|
||||
copyThumbnail,
|
||||
storeLocalThumbnail,
|
||||
deleteThumbnail,
|
||||
getThumbnailsDir,
|
||||
migrateExistingThumbnails,
|
||||
isLocalUrl,
|
||||
resolveLocalThumbnailPath,
|
||||
localThumbnailUrlExists
|
||||
};
|
||||
@@ -0,0 +1,871 @@
|
||||
'use strict';
|
||||
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('TMDB');
|
||||
|
||||
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
|
||||
const TMDB_TIMEOUT_MS = 15000;
|
||||
|
||||
class TmdbService {
|
||||
hasCreditsPayload(details = null) {
|
||||
const credits = details?.credits && typeof details.credits === 'object'
|
||||
? details.credits
|
||||
: null;
|
||||
if (!credits) {
|
||||
return false;
|
||||
}
|
||||
const crew = Array.isArray(credits.crew) ? credits.crew : [];
|
||||
const cast = Array.isArray(credits.cast) ? credits.cast : [];
|
||||
return crew.length > 0 || cast.length > 0;
|
||||
}
|
||||
|
||||
mergeCreditsIntoDetails(details = null, credits = null) {
|
||||
const sourceDetails = details && typeof details === 'object' ? details : {};
|
||||
const sourceCredits = credits && typeof credits === 'object' ? credits : null;
|
||||
if (!sourceCredits) {
|
||||
return sourceDetails;
|
||||
}
|
||||
return {
|
||||
...sourceDetails,
|
||||
credits: {
|
||||
...(sourceDetails?.credits && typeof sourceDetails.credits === 'object' ? sourceDetails.credits : {}),
|
||||
...(sourceCredits && typeof sourceCredits === 'object' ? sourceCredits : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
extractDirectorNames(crewValues = []) {
|
||||
const crew = Array.isArray(crewValues) ? crewValues : [];
|
||||
const byJob = this.normalizeNameList(
|
||||
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
|
||||
{ maxItems: 5 }
|
||||
);
|
||||
if (byJob.length > 0) {
|
||||
return byJob;
|
||||
}
|
||||
// Fallback for edge cases where "Director" job is missing in localized/partial payloads.
|
||||
return this.normalizeNameList(
|
||||
crew.filter((member) => String(member?.department || '').trim().toLowerCase() === 'directing'),
|
||||
{ maxItems: 5 }
|
||||
);
|
||||
}
|
||||
|
||||
isAbortError(error) {
|
||||
const name = String(error?.name || '').trim().toLowerCase();
|
||||
const message = String(error?.message || '').trim().toLowerCase();
|
||||
return name === 'aborterror' || message.includes('aborted');
|
||||
}
|
||||
|
||||
classifyRequestError(error) {
|
||||
if (this.isAbortError(error)) {
|
||||
return 'timeout';
|
||||
}
|
||||
const statusCode = Number(error?.statusCode || 0) || null;
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return 'auth';
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return 'upstream';
|
||||
}
|
||||
if (statusCode >= 400) {
|
||||
return 'request_failed';
|
||||
}
|
||||
return 'network';
|
||||
}
|
||||
|
||||
readFailureCode(rows) {
|
||||
const value = rows && typeof rows.tmdbFailureCode === 'string'
|
||||
? rows.tmdbFailureCode
|
||||
: '';
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
attachFailureCode(rows, failureCode = null) {
|
||||
const output = Array.isArray(rows) ? rows : [];
|
||||
const normalized = String(failureCode || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return output;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(output, 'tmdbFailureCode', {
|
||||
value: normalized,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
} catch (_error) {
|
||||
output.tmdbFailureCode = normalized;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
normalizeNameList(values = [], options = {}) {
|
||||
const maxItems = Math.max(1, Number(options.maxItems || 10));
|
||||
const source = Array.isArray(values) ? values : [];
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
for (const item of source) {
|
||||
const name = String(item?.name || item || '').trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(name);
|
||||
if (output.length >= maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
formatRuntimeLabel(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => Number(entry))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||
.map((entry) => Math.trunc(entry));
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
return min === max ? `${min} min` : `${min}-${max} min`;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return `${Math.trunc(numeric)} min`;
|
||||
}
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const fallbackLanguages = this.parseLanguageList(settings.dvd_series_fallback_languages);
|
||||
return {
|
||||
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
|
||||
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE',
|
||||
fallbackLanguages
|
||||
};
|
||||
}
|
||||
|
||||
async isConfigured() {
|
||||
const config = await this.getConfig();
|
||||
return Boolean(config.readAccessToken);
|
||||
}
|
||||
|
||||
async resolveLanguage(explicitLanguage = null) {
|
||||
const config = await this.getConfig();
|
||||
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
|
||||
}
|
||||
|
||||
parseLanguageList(value) {
|
||||
const source = Array.isArray(value)
|
||||
? value
|
||||
: String(value || '').split(',');
|
||||
const normalized = [];
|
||||
const seen = new Set();
|
||||
for (const item of source) {
|
||||
const lang = String(item || '').trim();
|
||||
if (!lang) {
|
||||
continue;
|
||||
}
|
||||
const dedupeKey = lang.toLowerCase();
|
||||
if (seen.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
normalized.push(lang);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
buildLanguageCandidates(primaryLanguage = null, fallbackLanguages = []) {
|
||||
const preferred = String(primaryLanguage || '').trim() || 'de-DE';
|
||||
const fallback = this.parseLanguageList(fallbackLanguages);
|
||||
const combined = [preferred, ...fallback];
|
||||
return this.parseLanguageList(combined);
|
||||
}
|
||||
|
||||
async resolveLanguageCandidates(options = {}) {
|
||||
const config = await this.getConfig();
|
||||
const explicitLanguage = String(options.language || '').trim() || null;
|
||||
const explicitFallbackLanguages = options.fallbackLanguages !== undefined
|
||||
? this.parseLanguageList(options.fallbackLanguages)
|
||||
: null;
|
||||
const primaryLanguage = explicitLanguage || String(config.language || '').trim() || 'de-DE';
|
||||
const fallbackLanguages = explicitFallbackLanguages ?? config.fallbackLanguages;
|
||||
return this.buildLanguageCandidates(primaryLanguage, fallbackLanguages);
|
||||
}
|
||||
|
||||
async request(pathName, options = {}) {
|
||||
const config = await this.getConfig();
|
||||
if (!config.readAccessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const normalizedPath = String(pathName || '').replace(/^\/+/, '');
|
||||
const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`);
|
||||
if (options.query && typeof options.query === 'object') {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Authorization': `Bearer ${config.readAccessToken}`,
|
||||
'User-Agent': 'Ripster/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`TMDb request failed (${response.status})`);
|
||||
error.statusCode = response.status;
|
||||
error.url = url.toString();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
buildImageUrl(imagePath, size = 'w342') {
|
||||
const normalizedPath = String(imagePath || '').trim();
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
let lastFailureCode = null;
|
||||
let lastFailureStatusCode = null;
|
||||
let lastFailureMessage = null;
|
||||
|
||||
for (const language of languageCandidates) {
|
||||
let data = null;
|
||||
try {
|
||||
data = await this.request('/search/tv', {
|
||||
query: {
|
||||
query: normalizedQuery,
|
||||
first_air_date_year: options.year || undefined,
|
||||
language,
|
||||
page: options.page || 1,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const failureCode = this.classifyRequestError(error);
|
||||
lastFailureCode = failureCode;
|
||||
lastFailureStatusCode = Number(error?.statusCode || 0) || null;
|
||||
lastFailureMessage = error?.message || String(error);
|
||||
logger.warn('search:failed', {
|
||||
query: normalizedQuery,
|
||||
language,
|
||||
error: lastFailureMessage,
|
||||
failureCode,
|
||||
statusCode: lastFailureStatusCode
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data?.results) ? data.results : [];
|
||||
const normalizedRows = rows
|
||||
.map((row) => ({
|
||||
id: Number(row?.id || 0) || null,
|
||||
title: String(row?.name || row?.original_name || '').trim() || null,
|
||||
originalTitle: String(row?.original_name || '').trim() || null,
|
||||
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
|
||||
overview: String(row?.overview || '').trim() || null,
|
||||
posterPath: String(row?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(row?.poster_path, 'w342'),
|
||||
backdropPath: String(row?.backdrop_path || '').trim() || null,
|
||||
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
|
||||
originalLanguage: String(row?.original_language || '').trim() || null,
|
||||
popularity: Number(row?.popularity || 0) || 0
|
||||
}))
|
||||
.filter((row) => row.id && row.title);
|
||||
if (normalizedRows.length > 0) {
|
||||
return this.attachFailureCode(normalizedRows, null);
|
||||
}
|
||||
}
|
||||
if (lastFailureCode) {
|
||||
logger.warn('search:failed:all-languages', {
|
||||
query: normalizedQuery,
|
||||
error: lastFailureMessage,
|
||||
failureCode: lastFailureCode,
|
||||
statusCode: lastFailureStatusCode
|
||||
});
|
||||
}
|
||||
return this.attachFailureCode([], lastFailureCode);
|
||||
}
|
||||
|
||||
async searchMovies(query, options = {}) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
let lastFailureCode = null;
|
||||
let lastFailureStatusCode = null;
|
||||
let lastFailureMessage = null;
|
||||
|
||||
for (const language of languageCandidates) {
|
||||
let data = null;
|
||||
try {
|
||||
data = await this.request('/search/movie', {
|
||||
query: {
|
||||
query: normalizedQuery,
|
||||
year: options.year || undefined,
|
||||
language,
|
||||
page: options.page || 1,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const failureCode = this.classifyRequestError(error);
|
||||
lastFailureCode = failureCode;
|
||||
lastFailureStatusCode = Number(error?.statusCode || 0) || null;
|
||||
lastFailureMessage = error?.message || String(error);
|
||||
logger.warn('movie:search:failed', {
|
||||
query: normalizedQuery,
|
||||
language,
|
||||
error: lastFailureMessage,
|
||||
failureCode,
|
||||
statusCode: lastFailureStatusCode
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data?.results) ? data.results : [];
|
||||
const normalizedRows = rows
|
||||
.map((row) => ({
|
||||
id: Number(row?.id || 0) || null,
|
||||
title: String(row?.title || row?.original_title || '').trim() || null,
|
||||
originalTitle: String(row?.original_title || '').trim() || null,
|
||||
year: Number(String(row?.release_date || '').slice(0, 4)) || null,
|
||||
overview: String(row?.overview || '').trim() || null,
|
||||
posterPath: String(row?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(row?.poster_path, 'w342'),
|
||||
backdropPath: String(row?.backdrop_path || '').trim() || null,
|
||||
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
|
||||
originalLanguage: String(row?.original_language || '').trim() || null,
|
||||
popularity: Number(row?.popularity || 0) || 0
|
||||
}))
|
||||
.filter((row) => row.id && row.title);
|
||||
if (normalizedRows.length > 0) {
|
||||
return this.attachFailureCode(normalizedRows, null);
|
||||
}
|
||||
}
|
||||
if (lastFailureCode) {
|
||||
logger.warn('movie:search:failed:all-languages', {
|
||||
query: normalizedQuery,
|
||||
error: lastFailureMessage,
|
||||
failureCode: lastFailureCode,
|
||||
statusCode: lastFailureStatusCode
|
||||
});
|
||||
}
|
||||
return this.attachFailureCode([], lastFailureCode);
|
||||
}
|
||||
|
||||
async getSeriesDetails(seriesId, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
const appendToResponse = Array.isArray(options.appendToResponse)
|
||||
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
let fallbackDetails = null;
|
||||
for (const language of languageCandidates) {
|
||||
const details = await this.request(`/tv/${Math.trunc(id)}`, {
|
||||
query: {
|
||||
language,
|
||||
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('series:details:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
language,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
const hasLocalizedPayload = Boolean(
|
||||
String(details?.name || '').trim()
|
||||
|| String(details?.overview || '').trim()
|
||||
);
|
||||
if (hasLocalizedPayload) {
|
||||
return details;
|
||||
}
|
||||
fallbackDetails = fallbackDetails || details;
|
||||
}
|
||||
return fallbackDetails;
|
||||
}
|
||||
|
||||
async getMovieDetails(movieId, options = {}) {
|
||||
const id = Number(movieId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
const appendToResponse = Array.isArray(options.appendToResponse)
|
||||
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
let fallbackDetails = null;
|
||||
for (const language of languageCandidates) {
|
||||
const details = await this.request(`/movie/${Math.trunc(id)}`, {
|
||||
query: {
|
||||
language,
|
||||
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('movie:details:failed', {
|
||||
movieId: Math.trunc(id),
|
||||
language,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
const hasLocalizedPayload = Boolean(
|
||||
String(details?.title || '').trim()
|
||||
|| String(details?.overview || '').trim()
|
||||
);
|
||||
if (hasLocalizedPayload) {
|
||||
return details;
|
||||
}
|
||||
fallbackDetails = fallbackDetails || details;
|
||||
}
|
||||
return fallbackDetails;
|
||||
}
|
||||
|
||||
async getMovieCredits(movieId, options = {}) {
|
||||
const id = Number(movieId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/movie/${Math.trunc(id)}/credits`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('movie:credits:failed', {
|
||||
movieId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async getMovieDetailsWithCredits(movieId, options = {}) {
|
||||
const details = await this.getMovieDetails(movieId, options);
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
const ensureCredits = options?.ensureCredits === true;
|
||||
if (!ensureCredits || this.hasCreditsPayload(details)) {
|
||||
return details;
|
||||
}
|
||||
const credits = await this.getMovieCredits(movieId, options);
|
||||
return this.mergeCreditsIntoDetails(details, credits);
|
||||
}
|
||||
|
||||
async getEpisodeGroups(seriesId) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => {
|
||||
logger.warn('series:episode-groups:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
}
|
||||
|
||||
async getEpisodeGroupDetails(groupId, options = {}) {
|
||||
const normalizedId = String(groupId || '').trim();
|
||||
if (!normalizedId || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
let fallbackDetails = null;
|
||||
for (const language of languageCandidates) {
|
||||
const details = await this.request(`/tv/episode_group/${normalizedId}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('episode-group:details:failed', {
|
||||
groupId: normalizedId,
|
||||
language,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
const hasLocalizedPayload = Boolean(String(details?.name || '').trim() || String(details?.description || '').trim());
|
||||
if (hasLocalizedPayload) {
|
||||
return details;
|
||||
}
|
||||
fallbackDetails = fallbackDetails || details;
|
||||
}
|
||||
return fallbackDetails;
|
||||
}
|
||||
|
||||
async getSeasonDetails(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const languageCandidates = await this.resolveLanguageCandidates(options);
|
||||
let fallbackDetails = null;
|
||||
for (const language of languageCandidates) {
|
||||
const details = await this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch(() => null);
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
const hasLocalizedPayload = Boolean(
|
||||
String(details?.name || '').trim()
|
||||
|| String(details?.overview || '').trim()
|
||||
|| (Array.isArray(details?.episodes) && details.episodes.length > 0)
|
||||
);
|
||||
if (hasLocalizedPayload) {
|
||||
return details;
|
||||
}
|
||||
fallbackDetails = fallbackDetails || details;
|
||||
}
|
||||
return fallbackDetails;
|
||||
}
|
||||
|
||||
async getSeasonCredits(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('season:credits:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
seasonNumber: Math.trunc(season),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
buildSeasonSummary(seasonDetails = null) {
|
||||
const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {};
|
||||
const episodes = Array.isArray(details.episodes) ? details.episodes : [];
|
||||
return {
|
||||
seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null,
|
||||
name: String(details.name || '').trim() || null,
|
||||
overview: String(details.overview || '').trim() || null,
|
||||
posterPath: String(details.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(details.poster_path, 'w342'),
|
||||
episodeCount: episodes.length,
|
||||
episodes: episodes.map((episode) => ({
|
||||
id: Number(episode?.id || 0) || null,
|
||||
number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null,
|
||||
seasonNumber: Number(episode?.season_number || details.season_number || 0) || null,
|
||||
name: String(episode?.name || '').trim() || null,
|
||||
overview: String(episode?.overview || '').trim() || null,
|
||||
runtime: Number(episode?.runtime || 0) || null,
|
||||
airDate: String(episode?.air_date || '').trim() || null,
|
||||
stillPath: String(episode?.still_path || '').trim() || null,
|
||||
still: this.buildImageUrl(episode?.still_path, 'w300')
|
||||
})).filter((episode) => episode.id && episode.number)
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesDetailsSummary(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
|
||||
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
|
||||
const creators = Array.isArray(details?.created_by) ? details.created_by : [];
|
||||
const genres = Array.isArray(details?.genres) ? details.genres : [];
|
||||
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
|
||||
const directorNames = this.extractDirectorNames(crew);
|
||||
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
|
||||
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
|
||||
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
|
||||
const voteAverageRaw = Number(details?.vote_average || 0);
|
||||
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
|
||||
? Number(voteAverageRaw.toFixed(1))
|
||||
: null;
|
||||
const voteCount = Number(details?.vote_count || 0);
|
||||
const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null;
|
||||
|
||||
return {
|
||||
director: directorNames.length > 0
|
||||
? directorNames.join(', ')
|
||||
: (creatorNames.length > 0 ? creatorNames.join(', ') : null),
|
||||
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
|
||||
runtime: runtimeLabel,
|
||||
runtimeLabel,
|
||||
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
|
||||
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
|
||||
voteAverage,
|
||||
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
|
||||
rottenTomatoes: null,
|
||||
imdbId,
|
||||
tmdbId: Number(details?.id || 0) || null,
|
||||
firstAirDate: String(details?.first_air_date || '').trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
buildMovieDetailsSummary(movieDetails = null) {
|
||||
const details = movieDetails && typeof movieDetails === 'object' ? movieDetails : {};
|
||||
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
|
||||
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
|
||||
const genres = Array.isArray(details?.genres) ? details.genres : [];
|
||||
const directorNames = this.extractDirectorNames(crew);
|
||||
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
|
||||
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
|
||||
const voteAverageRaw = Number(details?.vote_average || 0);
|
||||
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
|
||||
? Number(voteAverageRaw.toFixed(1))
|
||||
: null;
|
||||
const voteCount = Number(details?.vote_count || 0);
|
||||
const imdbId = String(details?.external_ids?.imdb_id || details?.imdb_id || '').trim() || null;
|
||||
const releaseDate = String(details?.release_date || '').trim() || null;
|
||||
const runtime = this.formatRuntimeLabel(details?.runtime);
|
||||
|
||||
return {
|
||||
director: directorNames.length > 0 ? directorNames.join(', ') : null,
|
||||
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
|
||||
runtime,
|
||||
runtimeLabel: runtime,
|
||||
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
|
||||
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
|
||||
voteAverage,
|
||||
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
|
||||
rottenTomatoes: null,
|
||||
imdbId,
|
||||
tmdbId: Number(details?.id || 0) || null,
|
||||
releaseDate
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesMetadataCandidate(series = {}, options = {}) {
|
||||
const season = series?.season && typeof series.season === 'object'
|
||||
? series.season
|
||||
: null;
|
||||
const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null;
|
||||
const providerId = seasonNumber
|
||||
? `tmdb:${series.id}:season:${seasonNumber}`
|
||||
: `tmdb:${series.id}`;
|
||||
|
||||
return {
|
||||
provider: 'tmdb',
|
||||
providerId,
|
||||
metadataKind: seasonNumber ? 'season' : 'series',
|
||||
tmdbId: Number(series?.id || 0) || null,
|
||||
title: String(series?.title || '').trim() || null,
|
||||
originalTitle: String(series?.originalTitle || '').trim() || null,
|
||||
year: Number(series?.year || 0) || null,
|
||||
overview: String(series?.overview || '').trim() || null,
|
||||
poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'),
|
||||
backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'),
|
||||
seasonNumber,
|
||||
seasonName: String(season?.name || '').trim() || null,
|
||||
seasonOverview: String(season?.overview || '').trim() || null,
|
||||
seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'),
|
||||
episodeCount: Number(season?.episodeCount || 0) || 0,
|
||||
episodes: Array.isArray(season?.episodes) ? season.episodes : []
|
||||
};
|
||||
}
|
||||
|
||||
buildMovieMetadataCandidate(movie = {}, options = {}) {
|
||||
const detailsSummary = options?.detailsSummary && typeof options.detailsSummary === 'object'
|
||||
? options.detailsSummary
|
||||
: null;
|
||||
const tmdbId = Number(movie?.id || movie?.tmdbId || 0) || null;
|
||||
const releaseDate = String(movie?.releaseDate || detailsSummary?.releaseDate || '').trim() || null;
|
||||
const year = Number(movie?.year || Number(String(releaseDate || '').slice(0, 4)) || 0) || null;
|
||||
const imdbId = String(movie?.imdbId || detailsSummary?.imdbId || '').trim() || null;
|
||||
const providerId = tmdbId !== null ? `tmdb:${tmdbId}` : null;
|
||||
|
||||
return {
|
||||
provider: 'tmdb',
|
||||
providerId,
|
||||
metadataKind: 'movie',
|
||||
workflowKind: 'film',
|
||||
tmdbId,
|
||||
imdbId,
|
||||
title: String(movie?.title || movie?.originalTitle || '').trim() || null,
|
||||
originalTitle: String(movie?.originalTitle || '').trim() || null,
|
||||
year,
|
||||
overview: String(movie?.overview || '').trim() || null,
|
||||
poster: movie?.poster || this.buildImageUrl(movie?.posterPath, 'w342'),
|
||||
backdrop: movie?.backdrop || this.buildImageUrl(movie?.backdropPath, 'w780'),
|
||||
runtime: detailsSummary?.runtime || null,
|
||||
genre: detailsSummary?.genre || null,
|
||||
voteAverage: detailsSummary?.voteAverage ?? null
|
||||
};
|
||||
}
|
||||
|
||||
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const seasons = Array.isArray(details.seasons) ? details.seasons : [];
|
||||
return seasons
|
||||
.map((season) => ({
|
||||
seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null,
|
||||
name: String(season?.name || '').trim() || null,
|
||||
overview: String(season?.overview || '').trim() || null,
|
||||
posterPath: String(season?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(season?.poster_path, 'w342'),
|
||||
episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0,
|
||||
episodes: []
|
||||
}))
|
||||
.filter((season) => season.seasonNumber !== null && season.episodeCount > 0);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeasons(query, options = {}) {
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
|
||||
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const details = await this.getSeriesDetails(candidate.id, options);
|
||||
const seasons = this.buildSeasonSummariesFromSeriesDetails(details);
|
||||
if (seasons.length === 0) {
|
||||
return [this.buildSeriesMetadataCandidate(candidate)];
|
||||
}
|
||||
return seasons.map((season) => this.buildSeriesMetadataCandidate({
|
||||
...candidate,
|
||||
season
|
||||
}, {
|
||||
seasonNumber: season.seasonNumber
|
||||
}));
|
||||
}));
|
||||
|
||||
const normalized = expanded.flat().filter((item) => item?.tmdbId && item?.title);
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
const normalizedSeason = Number(seasonNumber);
|
||||
if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options);
|
||||
if (!seasonDetails) {
|
||||
return {
|
||||
...candidate,
|
||||
season: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
...candidate,
|
||||
season: this.buildSeasonSummary(seasonDetails)
|
||||
};
|
||||
}));
|
||||
|
||||
const normalized = withSeasons
|
||||
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
|
||||
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
|
||||
seasonNumber: normalizedSeason
|
||||
}));
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
|
||||
async searchMoviesWithDetails(query, options = {}) {
|
||||
const candidates = await this.searchMovies(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(15, Number(options.limit || 8)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
|
||||
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const details = await this.getMovieDetailsWithCredits(candidate.id, {
|
||||
language,
|
||||
appendToResponse: ['credits', 'external_ids'],
|
||||
ensureCredits: true
|
||||
});
|
||||
const summary = this.buildMovieDetailsSummary(details);
|
||||
const normalized = this.buildMovieMetadataCandidate({
|
||||
...candidate,
|
||||
releaseDate: details?.release_date || null,
|
||||
imdbId: summary?.imdbId || null,
|
||||
posterPath: details?.poster_path || candidate?.posterPath || null
|
||||
}, {
|
||||
detailsSummary: summary
|
||||
});
|
||||
return {
|
||||
...normalized,
|
||||
tmdbDetails: summary && typeof summary === 'object' ? summary : null
|
||||
};
|
||||
}));
|
||||
|
||||
const normalized = expanded.filter((item) => item?.tmdbId && item?.title);
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TmdbService();
|
||||
@@ -0,0 +1,134 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('USER_PRESET_DEFAULTS');
|
||||
|
||||
const SLOT_KEYS = Object.freeze([
|
||||
'bluray_movie',
|
||||
'bluray_series',
|
||||
'dvd_movie',
|
||||
'dvd_series'
|
||||
]);
|
||||
const SLOT_KEY_SET = new Set(SLOT_KEYS);
|
||||
const SLOT_ALLOWED_MEDIA_TYPES = Object.freeze({
|
||||
bluray_movie: new Set(['bluray', 'all']),
|
||||
bluray_series: new Set(['bluray', 'all']),
|
||||
dvd_movie: new Set(['dvd', 'all']),
|
||||
dvd_series: new Set(['dvd', 'all'])
|
||||
});
|
||||
|
||||
function normalizePresetId(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
function buildDefaultsMap(rows = []) {
|
||||
const map = Object.fromEntries(SLOT_KEYS.map((key) => [key, null]));
|
||||
for (const row of rows) {
|
||||
const slotKey = String(row?.slot_key || '').trim();
|
||||
if (!SLOT_KEY_SET.has(slotKey)) {
|
||||
continue;
|
||||
}
|
||||
const presetId = normalizePresetId(row?.preset_id);
|
||||
map[slotKey] = presetId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function listDefaults() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT slot_key, preset_id
|
||||
FROM user_preset_defaults
|
||||
WHERE slot_key IN (${SLOT_KEYS.map(() => '?').join(', ')})
|
||||
ORDER BY slot_key ASC
|
||||
`,
|
||||
SLOT_KEYS
|
||||
);
|
||||
return buildDefaultsMap(rows);
|
||||
}
|
||||
|
||||
async function updateDefaults(payload = {}) {
|
||||
const defaults = payload && typeof payload === 'object'
|
||||
? (payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : payload)
|
||||
: {};
|
||||
const updates = [];
|
||||
|
||||
for (const [slotKey, rawPresetId] of Object.entries(defaults)) {
|
||||
if (!SLOT_KEY_SET.has(slotKey)) {
|
||||
const error = new Error(`Ungültiger Slot: ${slotKey}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
updates.push({ slotKey, presetId: normalizePresetId(rawPresetId) });
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return listDefaults();
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
for (const update of updates) {
|
||||
if (update.presetId === null) {
|
||||
continue;
|
||||
}
|
||||
const exists = await db.get(
|
||||
`SELECT id, media_type FROM user_presets WHERE id = ? LIMIT 1`,
|
||||
[update.presetId]
|
||||
);
|
||||
if (!exists) {
|
||||
const error = new Error(`Preset ${update.presetId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const mediaType = String(exists.media_type || '').trim().toLowerCase();
|
||||
const allowedMediaTypes = SLOT_ALLOWED_MEDIA_TYPES[update.slotKey] || new Set(['all']);
|
||||
if (!allowedMediaTypes.has(mediaType)) {
|
||||
const error = new Error(
|
||||
`Preset ${update.presetId} (${mediaType || 'unbekannt'}) ist für ${update.slotKey} nicht zulässig.`
|
||||
);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await db.exec('BEGIN');
|
||||
try {
|
||||
for (const update of updates) {
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO user_preset_defaults (slot_key, preset_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(slot_key) DO UPDATE SET preset_id = excluded.preset_id
|
||||
`,
|
||||
[update.slotKey, update.presetId]
|
||||
);
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('update', {
|
||||
updates: updates.map((entry) => ({
|
||||
slotKey: entry.slotKey,
|
||||
presetId: entry.presetId
|
||||
}))
|
||||
});
|
||||
return listDefaults();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SLOT_KEYS,
|
||||
listDefaults,
|
||||
updateDefaults
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('USER_PRESET');
|
||||
|
||||
const VALID_MEDIA_TYPES = new Set(['bluray', 'dvd', 'other', 'all']);
|
||||
|
||||
function normalizeMediaType(value) {
|
||||
const v = String(value || '').trim().toLowerCase();
|
||||
return VALID_MEDIA_TYPES.has(v) ? v : 'all';
|
||||
}
|
||||
|
||||
function rowToPreset(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
mediaType: row.media_type,
|
||||
handbrakePreset: row.handbrake_preset || null,
|
||||
extraArgs: row.extra_args || null,
|
||||
description: row.description || null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
async function listPresets(mediaType = null) {
|
||||
const db = await getDb();
|
||||
let rows;
|
||||
if (mediaType && VALID_MEDIA_TYPES.has(mediaType)) {
|
||||
rows = await db.all(
|
||||
`SELECT * FROM user_presets WHERE media_type = ? OR media_type = 'all' ORDER BY name ASC`,
|
||||
[mediaType]
|
||||
);
|
||||
} else {
|
||||
rows = await db.all(`SELECT * FROM user_presets ORDER BY media_type ASC, name ASC`);
|
||||
}
|
||||
return rows.map(rowToPreset);
|
||||
}
|
||||
|
||||
async function getPresetById(id) {
|
||||
const db = await getDb();
|
||||
const row = await db.get(`SELECT * FROM user_presets WHERE id = ? LIMIT 1`, [id]);
|
||||
return rowToPreset(row);
|
||||
}
|
||||
|
||||
async function createPreset(payload) {
|
||||
const name = String(payload?.name || '').trim();
|
||||
if (!name) {
|
||||
const error = new Error('Preset-Name darf nicht leer sein.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mediaType = normalizeMediaType(payload?.mediaType);
|
||||
const handbrakePreset = String(payload?.handbrakePreset || '').trim() || null;
|
||||
const extraArgs = String(payload?.extraArgs || '').trim() || null;
|
||||
const description = String(payload?.description || '').trim() || null;
|
||||
|
||||
const db = await getDb();
|
||||
const result = await db.run(
|
||||
`INSERT INTO user_presets (name, media_type, handbrake_preset, extra_args, description)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[name, mediaType, handbrakePreset, extraArgs, description]
|
||||
);
|
||||
|
||||
const preset = await getPresetById(result.lastID);
|
||||
logger.info('create', { id: preset.id, name: preset.name, mediaType: preset.mediaType });
|
||||
return preset;
|
||||
}
|
||||
|
||||
async function updatePreset(id, payload) {
|
||||
const db = await getDb();
|
||||
const existing = await getPresetById(id);
|
||||
if (!existing) {
|
||||
const error = new Error(`Preset ${id} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const name = payload?.name !== undefined ? String(payload.name || '').trim() : existing.name;
|
||||
if (!name) {
|
||||
const error = new Error('Preset-Name darf nicht leer sein.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const mediaType = payload?.mediaType !== undefined
|
||||
? normalizeMediaType(payload.mediaType)
|
||||
: existing.mediaType;
|
||||
const handbrakePreset = payload?.handbrakePreset !== undefined
|
||||
? (String(payload.handbrakePreset || '').trim() || null)
|
||||
: existing.handbrakePreset;
|
||||
const extraArgs = payload?.extraArgs !== undefined
|
||||
? (String(payload.extraArgs || '').trim() || null)
|
||||
: existing.extraArgs;
|
||||
const description = payload?.description !== undefined
|
||||
? (String(payload.description || '').trim() || null)
|
||||
: existing.description;
|
||||
|
||||
await db.run(
|
||||
`UPDATE user_presets
|
||||
SET name = ?, media_type = ?, handbrake_preset = ?, extra_args = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[name, mediaType, handbrakePreset, extraArgs, description, id]
|
||||
);
|
||||
|
||||
const updated = await getPresetById(id);
|
||||
logger.info('update', { id: updated.id, name: updated.name });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function deletePreset(id) {
|
||||
const db = await getDb();
|
||||
const existing = await getPresetById(id);
|
||||
if (!existing) {
|
||||
const error = new Error(`Preset ${id} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db.run(`DELETE FROM user_presets WHERE id = ?`, [id]);
|
||||
logger.info('delete', { id: existing.id, name: existing.name });
|
||||
return existing;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listPresets,
|
||||
getPresetById,
|
||||
createPreset,
|
||||
updatePreset,
|
||||
deletePreset
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const logger = require('./logger').child('WS');
|
||||
|
||||
class WebSocketService {
|
||||
constructor() {
|
||||
this.wss = null;
|
||||
this.clients = new Set();
|
||||
}
|
||||
|
||||
_removeClient(socket, logLevel = 'info', event = 'client:removed') {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
const deleted = this.clients.delete(socket);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
logger[logLevel](event, { clients: this.clients.size });
|
||||
}
|
||||
|
||||
init(httpServer) {
|
||||
if (this.wss) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||
|
||||
this.wss.on('connection', (socket) => {
|
||||
this.clients.add(socket);
|
||||
logger.info('client:connected', { clients: this.clients.size });
|
||||
|
||||
try {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'WS_CONNECTED',
|
||||
payload: { connectedAt: new Date().toISOString() }
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('client:connected:initial-send-failed', {
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(socket, 'warn', 'client:connected:removed-after-send-failure');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on('close', () => {
|
||||
this._removeClient(socket, 'info', 'client:closed');
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logger.warn('client:error', {
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(socket, 'warn', 'client:error:removed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(type, payload) {
|
||||
if (!this.wss) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('broadcast', {
|
||||
type,
|
||||
clients: this.clients.size,
|
||||
payloadKeys: payload && typeof payload === 'object' ? Object.keys(payload) : []
|
||||
});
|
||||
|
||||
const message = JSON.stringify({
|
||||
type,
|
||||
payload,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
for (const client of this.clients) {
|
||||
if (!client || client.readyState !== client.OPEN) {
|
||||
if (client && client.readyState === client.CLOSED) {
|
||||
this._removeClient(client, 'info', 'client:pruned-closed');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
client.send(message, (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
logger.warn('broadcast:send-failed', {
|
||||
type,
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(client, 'warn', 'client:removed-after-send-failure');
|
||||
try {
|
||||
client.terminate();
|
||||
} catch (_error) {
|
||||
// noop
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('broadcast:send-threw', {
|
||||
type,
|
||||
clients: this.clients.size,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
this._removeClient(client, 'warn', 'client:removed-after-send-throw');
|
||||
try {
|
||||
client.terminate();
|
||||
} catch (_terminateError) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WebSocketService();
|
||||
@@ -0,0 +1,57 @@
|
||||
function splitArgs(input) {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const args = [];
|
||||
let current = '';
|
||||
let quote = null;
|
||||
let escaping = false;
|
||||
|
||||
for (const ch of input) {
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(ch)) {
|
||||
if (current.length > 0) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
splitArgs
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
function errorToMeta(error) {
|
||||
if (!error) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
signal: error.signal,
|
||||
statusCode: error.statusCode
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorToMeta
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function transliterateForFilename(input) {
|
||||
return String(input || '')
|
||||
// German — must come before NFD to get ae/oe/ue instead of just a/o/u
|
||||
.replace(/ä/g, 'ae').replace(/Ä/g, 'Ae')
|
||||
.replace(/ö/g, 'oe').replace(/Ö/g, 'Oe')
|
||||
.replace(/ü/g, 'ue').replace(/Ü/g, 'Ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
// Danish / Norwegian / Swedish
|
||||
.replace(/å/g, 'a').replace(/Å/g, 'A')
|
||||
.replace(/æ/g, 'ae').replace(/Æ/g, 'Ae')
|
||||
.replace(/ø/g, 'o').replace(/Ø/g, 'O')
|
||||
// Icelandic
|
||||
.replace(/ð/g, 'd').replace(/Ð/g, 'D')
|
||||
.replace(/þ/g, 'th').replace(/Þ/g, 'Th')
|
||||
// NFD decomposition + strip combining diacritical marks (handles é→e, ñ→n, ç→c, etc.)
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}+/gu, '')
|
||||
// Drop any remaining non-ASCII characters
|
||||
.replace(/[^\x00-\x7F]/g, '');
|
||||
}
|
||||
|
||||
function sanitizeFileName(input) {
|
||||
return transliterateForFilename(String(input || 'untitled'))
|
||||
.replace(/[^a-zA-Z0-9 ()[\]_+-]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
function sanitizeFileNameWithExtension(input) {
|
||||
const str = String(input || 'untitled');
|
||||
const ext = path.extname(str);
|
||||
const base = ext ? str.slice(0, -ext.length) : str;
|
||||
const cleanBase = sanitizeFileName(base);
|
||||
const cleanExt = ext.toLowerCase().replace(/[^a-z0-9.]/g, '');
|
||||
return (cleanBase || 'untitled') + cleanExt;
|
||||
}
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
const rawSource = String(template || '${title} (${year})');
|
||||
const parseToken = (rawToken) => {
|
||||
const token = String(rawToken || '').trim();
|
||||
if (!token) {
|
||||
return { format: '', key: '' };
|
||||
}
|
||||
const separatorIndex = token.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return { format: '', key: token };
|
||||
}
|
||||
return {
|
||||
format: token.slice(0, separatorIndex).trim(),
|
||||
key: token.slice(separatorIndex + 1).trim()
|
||||
};
|
||||
};
|
||||
const applyFormat = (value, format) => {
|
||||
const normalizedFormat = String(format || '').trim().toLowerCase();
|
||||
if (!normalizedFormat) {
|
||||
return String(value);
|
||||
}
|
||||
// {0:key} => number with leading zero (width 2), e.g. 2 -> 02
|
||||
if (normalizedFormat === '0') {
|
||||
const raw = String(value);
|
||||
const match = raw.match(/^(-?)(\d+)$/);
|
||||
if (match) {
|
||||
const sign = match[1] || '';
|
||||
const digits = String(match[2] || '');
|
||||
return `${sign}${digits.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
const resolveToken = (rawKey) => {
|
||||
const { format, key } = parseToken(rawKey);
|
||||
const val = values[key];
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
return applyFormat(val, format);
|
||||
};
|
||||
|
||||
const source = (
|
||||
/[{}]/.test(rawSource)
|
||||
? rawSource
|
||||
: rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}')
|
||||
);
|
||||
|
||||
// Support both ${key} and legacy {key} placeholders (+ recovered bare tokens).
|
||||
return source
|
||||
.replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key))
|
||||
.replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key));
|
||||
}
|
||||
|
||||
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {
|
||||
const files = findMediaFiles(dirPath, extensions);
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return files.reduce((largest, file) => (largest === null || file.size > largest.size ? file : largest), null);
|
||||
}
|
||||
|
||||
function findMediaFiles(dirPath, extensions = ['.mkv', '.mp4']) {
|
||||
const results = [];
|
||||
|
||||
function walk(current) {
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(abs);
|
||||
} else {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (!extensions.includes(ext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(abs);
|
||||
results.push({
|
||||
path: abs,
|
||||
size: stat.size
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(dirPath);
|
||||
results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureDir,
|
||||
transliterateForFilename,
|
||||
sanitizeFileName,
|
||||
sanitizeFileNameWithExtension,
|
||||
renderTemplate,
|
||||
findLargestMediaFile,
|
||||
findMediaFiles
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
function clampPercent(value) {
|
||||
if (Number.isNaN(value) || value === Infinity || value === -Infinity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Number(value.toFixed(2))));
|
||||
}
|
||||
|
||||
function parseGenericPercent(line) {
|
||||
const match = line.match(/(\d{1,3}(?:\.\d+)?)\s?%/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clampPercent(Number(match[1]));
|
||||
}
|
||||
|
||||
function parseEta(line) {
|
||||
const etaMatch = line.match(/ETA\s+([0-9:.hms-]+)/i);
|
||||
if (!etaMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = etaMatch[1].trim();
|
||||
if (!value || value.includes('--')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.replace(/[),.;]+$/, '');
|
||||
}
|
||||
|
||||
function parseMakeMkvProgress(line) {
|
||||
const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/);
|
||||
if (prgv) {
|
||||
// Format: PRGV:current,total,max (official makemkv docs)
|
||||
// current = per-file progress, total = overall progress across all files
|
||||
const total = Number(prgv[2]);
|
||||
const max = Number(prgv[3]);
|
||||
|
||||
if (max > 0) {
|
||||
return { percent: clampPercent((total / max) * 100), eta: null };
|
||||
}
|
||||
}
|
||||
|
||||
const percent = parseGenericPercent(line);
|
||||
if (percent !== null) {
|
||||
return { percent, eta: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHandBrakeProgress(line) {
|
||||
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
|
||||
const match = normalized.match(/Encoding:\s*(?:task\s+\d+\s+of\s+\d+,\s*)?(\d+(?:\.\d+)?)\s?%/i);
|
||||
if (match) {
|
||||
return {
|
||||
percent: clampPercent(Number(match[1])),
|
||||
eta: parseEta(normalized)
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function parseMkvmergeProgress(line) {
|
||||
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Common mkvmerge output examples:
|
||||
// "Progress: 42%"
|
||||
// "#GUI#progress 42%"
|
||||
const explicitMatch = normalized.match(/(?:^|\s)(?:progress:?|#GUI#progress)\s*(\d{1,3}(?:\.\d+)?)\s*%/i);
|
||||
if (explicitMatch) {
|
||||
return {
|
||||
percent: clampPercent(Number(explicitMatch[1])),
|
||||
eta: null
|
||||
};
|
||||
}
|
||||
|
||||
const percent = parseGenericPercent(normalized);
|
||||
if (percent !== null) {
|
||||
return { percent, eta: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseMakeMkvProgress,
|
||||
parseHandBrakeProgress,
|
||||
parseCdParanoiaProgress,
|
||||
parseMkvmergeProgress
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
function padNumber(value, length = 2) {
|
||||
return String(value).padStart(length, '0');
|
||||
}
|
||||
|
||||
function formatTimezoneOffset(offsetMinutes) {
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||
const absoluteMinutes = Math.abs(offsetMinutes);
|
||||
const hours = Math.floor(absoluteMinutes / 60);
|
||||
const minutes = absoluteMinutes % 60;
|
||||
return `${sign}${padNumber(hours)}:${padNumber(minutes)}`;
|
||||
}
|
||||
|
||||
function getServerTimestamp(input = new Date()) {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
|
||||
return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}`
|
||||
+ `T${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}`
|
||||
+ `.${padNumber(date.getMilliseconds(), 3)}${formatTimezoneOffset(-date.getTimezoneOffset())}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getServerTimestamp
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
function parseJson(value, fallback = null) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === 'true' || value === '1' || value === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === 'false' || value === '0' || value === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function normalizeValueByType(type, rawValue) {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return Number(rawValue);
|
||||
case 'boolean':
|
||||
return toBoolean(rawValue);
|
||||
case 'select':
|
||||
case 'string':
|
||||
case 'path':
|
||||
default:
|
||||
return String(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeValueByType(type, value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function validateSetting(schemaItem, value) {
|
||||
const errors = [];
|
||||
const normalized = normalizeValueByType(schemaItem.type, value);
|
||||
|
||||
if (schemaItem.required) {
|
||||
const emptyString = typeof normalized === 'string' && normalized.trim().length === 0;
|
||||
if (normalized === null || emptyString) {
|
||||
errors.push('Wert ist erforderlich.');
|
||||
}
|
||||
}
|
||||
|
||||
if (schemaItem.type === 'number' && normalized !== null) {
|
||||
if (Number.isNaN(normalized)) {
|
||||
errors.push('Ungültige Zahl.');
|
||||
} else {
|
||||
const rules = parseJson(schemaItem.validation_json, {});
|
||||
if (typeof rules.min === 'number' && normalized < rules.min) {
|
||||
errors.push(`Wert muss >= ${rules.min} sein.`);
|
||||
}
|
||||
if (typeof rules.max === 'number' && normalized > rules.max) {
|
||||
errors.push(`Wert muss <= ${rules.max} sein.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schemaItem.type === 'select' && normalized !== null) {
|
||||
const options = parseJson(schemaItem.options_json, []);
|
||||
const values = options.map((option) => option.value);
|
||||
if (!values.includes(normalized)) {
|
||||
errors.push('Ungültige Auswahl.');
|
||||
}
|
||||
}
|
||||
|
||||
if ((schemaItem.type === 'path' || schemaItem.type === 'string') && normalized !== null) {
|
||||
const rules = parseJson(schemaItem.validation_json, {});
|
||||
if (typeof rules.minLength === 'number' && normalized.length < rules.minLength) {
|
||||
errors.push(`Wert muss mindestens ${rules.minLength} Zeichen haben.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
normalized
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseJson,
|
||||
normalizeValueByType,
|
||||
serializeValueByType,
|
||||
validateSetting,
|
||||
toBoolean
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { analyzePlaylistObfuscation } = require('../src/utils/playlistAnalysis');
|
||||
|
||||
function buildTitleLines({
|
||||
titleId,
|
||||
playlistId,
|
||||
duration = '01:34:41',
|
||||
chapters = 20,
|
||||
audioTrackCount = 3,
|
||||
subtitleTrackCount = 2,
|
||||
segmentNumbers = []
|
||||
}) {
|
||||
const lines = [
|
||||
`MSG:3016,0,0,"${playlistId}.mpls","${titleId}"`,
|
||||
`TINFO:${titleId},16,0,"${playlistId}.mpls"`,
|
||||
`TINFO:${titleId},9,0,"${duration}"`,
|
||||
`TINFO:${titleId},8,0,"${chapters}"`,
|
||||
`TINFO:${titleId},26,0,"${segmentNumbers.join(', ')}"`
|
||||
];
|
||||
|
||||
for (let index = 0; index < audioTrackCount; index += 1) {
|
||||
lines.push(`SINFO:${titleId},${index},1,0,"Audio"`);
|
||||
lines.push(`SINFO:${titleId},${index},3,0,"eng"`);
|
||||
lines.push(`SINFO:${titleId},${index},4,0,"English"`);
|
||||
lines.push(`SINFO:${titleId},${index},6,0,"DTS-HD MA"`);
|
||||
lines.push(`SINFO:${titleId},${index},40,0,"5.1 ch"`);
|
||||
}
|
||||
|
||||
for (let index = 0; index < subtitleTrackCount; index += 1) {
|
||||
const streamIndex = audioTrackCount + index;
|
||||
lines.push(`SINFO:${titleId},${streamIndex},1,0,"Subtitle"`);
|
||||
lines.push(`SINFO:${titleId},${streamIndex},3,0,"eng"`);
|
||||
lines.push(`SINFO:${titleId},${streamIndex},4,0,"English"`);
|
||||
lines.push(`SINFO:${titleId},${streamIndex},6,0,"PGS"`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
test('branching cluster prefers the structurally simplest reference playlist', () => {
|
||||
const lines = [
|
||||
...buildTitleLines({
|
||||
titleId: 0,
|
||||
playlistId: '00802',
|
||||
segmentNumbers: [
|
||||
866, 868, 829, 848, 830, 849, 831, 850, 832, 851,
|
||||
833, 852, 834, 853, 835, 854, 869, 870, 872, 855,
|
||||
837, 856, 838, 857, 839, 858, 840, 859, 841, 860,
|
||||
842, 861, 843, 862, 844, 863, 845, 864, 846
|
||||
]
|
||||
}),
|
||||
...buildTitleLines({
|
||||
titleId: 1,
|
||||
playlistId: '00800',
|
||||
segmentNumbers: [
|
||||
847, 829, 848, 830, 849, 831, 850, 832, 851, 833,
|
||||
852, 834, 853, 835, 854, 836, 855, 837, 856, 838,
|
||||
857, 839, 858, 840, 859, 841, 860, 842, 861, 843,
|
||||
862, 844, 863, 845, 864, 846
|
||||
]
|
||||
}),
|
||||
...buildTitleLines({
|
||||
titleId: 3,
|
||||
playlistId: '00804',
|
||||
segmentNumbers: [
|
||||
867, 868, 829, 848, 830, 849, 831, 850, 832, 851,
|
||||
833, 852, 834, 853, 835, 854, 869, 871, 872, 855,
|
||||
837, 856, 838, 857, 839, 858, 840, 859, 841, 860,
|
||||
842, 861, 843, 862, 844, 863, 845, 864, 846
|
||||
]
|
||||
})
|
||||
];
|
||||
|
||||
const analysis = analyzePlaylistObfuscation(lines, 60, {
|
||||
durationSimilaritySeconds: 90
|
||||
});
|
||||
|
||||
assert.equal(analysis?.recommendation?.playlistId, '00800');
|
||||
assert.deepEqual(
|
||||
(analysis?.evaluatedCandidates || []).map((item) => item.playlistId),
|
||||
['00800', '00804', '00802']
|
||||
);
|
||||
assert.deepEqual(analysis?.candidatePlaylists || [], ['00800', '00804', '00802']);
|
||||
|
||||
const preferred = (analysis?.evaluatedCandidates || [])[0];
|
||||
assert.equal(preferred?.recommended, true);
|
||||
assert.equal(preferred?.evaluationLabel, 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster');
|
||||
|
||||
const variantSummary = (preferred?.variantSpans || []).map((span) => ({
|
||||
before: span.beforeSegment,
|
||||
after: span.afterSegment,
|
||||
segments: span.segments
|
||||
}));
|
||||
assert.deepEqual(variantSummary, [
|
||||
{ before: null, after: 829, segments: [847] },
|
||||
{ before: 854, after: 855, segments: [836] }
|
||||
]);
|
||||
});
|
||||
Executable
BIN
Binary file not shown.
+883
@@ -0,0 +1,883 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE settings_schema (
|
||||
key TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
required INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
default_value TEXT,
|
||||
options_json TEXT,
|
||||
validation_json TEXT,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
depends_on TEXT DEFAULT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE settings_values (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (key) REFERENCES settings_schema(key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_job_id INTEGER,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
imdb_id TEXT,
|
||||
poster_url TEXT,
|
||||
omdb_json TEXT,
|
||||
selected_from_omdb INTEGER DEFAULT 0,
|
||||
migrate_tmdb INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
status TEXT NOT NULL,
|
||||
output_path TEXT,
|
||||
disc_device TEXT,
|
||||
error_message TEXT,
|
||||
detected_title TEXT,
|
||||
last_state TEXT,
|
||||
raw_path TEXT,
|
||||
rip_successful INTEGER NOT NULL DEFAULT 0,
|
||||
makemkv_info_json TEXT,
|
||||
handbrake_info_json TEXT,
|
||||
mediainfo_info_json TEXT,
|
||||
encode_plan_json TEXT,
|
||||
encode_input_path TEXT,
|
||||
encode_review_confirmed INTEGER DEFAULT 0,
|
||||
aax_checksum TEXT,
|
||||
media_type TEXT,
|
||||
job_kind TEXT,
|
||||
is_multipart_movie INTEGER NOT NULL DEFAULT 0,
|
||||
disc_number INTEGER,
|
||||
metadata_fingerprint TEXT,
|
||||
created_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 INDEX idx_jobs_job_kind ON jobs(job_kind);
|
||||
CREATE INDEX idx_jobs_disc_number ON jobs(disc_number);
|
||||
CREATE INDEX idx_jobs_metadata_fingerprint ON jobs(metadata_fingerprint);
|
||||
|
||||
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 job_output_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
output_path TEXT NOT NULL,
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_job_output_folders_job_id ON job_output_folders(job_id);
|
||||
CREATE UNIQUE INDEX idx_job_output_folders_unique ON job_output_folders(job_id, output_path);
|
||||
|
||||
CREATE TABLE scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
script_body TEXT NOT NULL,
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scripts_name ON scripts(name);
|
||||
CREATE INDEX idx_scripts_order_index ON scripts(order_index, id);
|
||||
|
||||
CREATE TABLE script_chains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_script_chains_name ON script_chains(name);
|
||||
CREATE INDEX idx_script_chains_order_index ON script_chains(order_index, id);
|
||||
|
||||
CREATE TABLE script_chain_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chain_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
step_type TEXT NOT NULL,
|
||||
script_id INTEGER,
|
||||
wait_seconds INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chain_id) REFERENCES script_chains(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_script_chain_steps_chain ON script_chain_steps(chain_id, position);
|
||||
|
||||
CREATE TABLE hardware_metrics_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
captured_at TEXT NOT NULL,
|
||||
day_key TEXT NOT NULL,
|
||||
cpu_usage_percent REAL,
|
||||
cpu_temperature_c REAL,
|
||||
ram_usage_percent REAL,
|
||||
ram_used_bytes INTEGER,
|
||||
ram_total_bytes INTEGER,
|
||||
gpu_usage_percent REAL,
|
||||
gpu_temperature_c REAL,
|
||||
vram_used_bytes INTEGER,
|
||||
vram_total_bytes INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_hardware_metrics_history_captured_at ON hardware_metrics_history(captured_at);
|
||||
CREATE INDEX idx_hardware_metrics_history_day_key ON hardware_metrics_history(day_key);
|
||||
|
||||
CREATE TABLE pipeline_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
state TEXT NOT NULL,
|
||||
active_job_id INTEGER,
|
||||
progress REAL DEFAULT 0,
|
||||
eta TEXT,
|
||||
status_text TEXT,
|
||||
context_json TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (active_job_id) REFERENCES jobs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE cron_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
cron_expression TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
pushover_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_run_at TEXT,
|
||||
last_run_status TEXT,
|
||||
next_run_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cron_jobs_enabled ON cron_jobs(enabled);
|
||||
|
||||
CREATE TABLE cron_run_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cron_job_id INTEGER NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
output TEXT,
|
||||
error_message TEXT,
|
||||
FOREIGN KEY (cron_job_id) REFERENCES cron_jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cron_run_logs_job ON cron_run_logs(cron_job_id, id DESC);
|
||||
|
||||
CREATE TABLE user_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL DEFAULT 'all',
|
||||
handbrake_preset TEXT,
|
||||
extra_args TEXT,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_presets_media_type ON user_presets(media_type);
|
||||
|
||||
CREATE TABLE user_preset_defaults (
|
||||
slot_key TEXT PRIMARY KEY,
|
||||
preset_id INTEGER,
|
||||
FOREIGN KEY (preset_id) REFERENCES user_presets(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_preset_defaults_preset_id ON user_preset_defaults(preset_id);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('bluray_movie', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('bluray_series', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('dvd_movie', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('dvd_series', NULL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE converter_scan_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rel_path TEXT NOT NULL UNIQUE,
|
||||
entry_type TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
detected_media_type TEXT,
|
||||
detected_format TEXT,
|
||||
job_id INTEGER,
|
||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_converter_scan_entries_rel_path ON converter_scan_entries(rel_path);
|
||||
CREATE INDEX idx_converter_scan_entries_job_id ON converter_scan_entries(job_id);
|
||||
|
||||
CREATE TABLE dvd_series_disc_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
provider_series_name TEXT,
|
||||
season_number INTEGER,
|
||||
season_type TEXT NOT NULL DEFAULT 'dvd',
|
||||
disc_label TEXT,
|
||||
disc_serial TEXT,
|
||||
disc_number INTEGER,
|
||||
language TEXT,
|
||||
disc_signature TEXT NOT NULL,
|
||||
fingerprint_json TEXT,
|
||||
episode_map_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature);
|
||||
CREATE INDEX idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number);
|
||||
|
||||
CREATE TABLE dvd_series_title_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_job_id INTEGER NOT NULL,
|
||||
disc_profile_id INTEGER,
|
||||
title_index INTEGER NOT NULL,
|
||||
title_kind TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
selected INTEGER NOT NULL DEFAULT 1,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
season_number INTEGER,
|
||||
episode_start INTEGER,
|
||||
episode_end INTEGER,
|
||||
episode_ids_json TEXT,
|
||||
mapping_json TEXT,
|
||||
created_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 CASCADE,
|
||||
FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index);
|
||||
CREATE INDEX idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number);
|
||||
|
||||
-- =============================================================================
|
||||
-- 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 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_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_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 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_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_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 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 ('series_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_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 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 ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_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);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_mode', 'auto');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('drive_device', 'Laufwerk', 'Device Pfad', 'path', 0, 'Nur für expliziten Modus, z.B. /dev/sr0.', '/dev/sr0', '[]', '{}', 20);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_device', '/dev/sr0');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
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_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_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', 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-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_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', 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', '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 ('series_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_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-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 ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', 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');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]');
|
||||
|
||||
-- Monitoring
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('hardware_monitoring_enabled', 'Monitoring', 'Hardware Monitoring aktiviert', 'boolean', 1, 'Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates).', 'true', '[]', '{}', 130);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('hardware_monitoring_interval_ms', 'Monitoring', 'Hardware Monitoring Intervall (ms)', 'number', 1, 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.', '5000', '[]', '{"min":1000,"max":60000}', 140);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_interval_ms', '5000');
|
||||
|
||||
-- Logging
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true');
|
||||
|
||||
-- Tools
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_command', 'makemkvcon');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_registration_key', 'Tools', 'MakeMKV Key', 'string', 0, 'Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden.', NULL, '[]', '{}', 202);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_registration_key', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('mediainfo_command', 'Tools', 'Mediainfo Kommando', 'string', 1, 'Pfad oder Befehl für mediainfo.', 'mediainfo', '[]', '{"minLength":1}', 205);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_command', 'mediainfo');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_min_length_minutes', 'Tools', 'Minimale Titellänge (Minuten)', 'number', 1, 'Filtert kurze Titel beim Rip.', '60', '[]', '{"min":1,"max":1000}', 210);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_min_length_minutes', '60');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_command', 'Tools', 'HandBrake Kommando', 'string', 1, 'Pfad oder Befehl für HandBrakeCLI.', 'HandBrakeCLI', '[]', '{"minLength":1}', 215);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_command', 'HandBrakeCLI');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220);
|
||||
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', '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, '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);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_analyze_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_extra_args_bluray', 'Tools', 'MakeMKV Rip Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Rip (Blu-ray).', NULL, '[]', '{}', 315);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_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 ('handbrake_preset_bluray', 'Tools', 'HandBrake Preset', 'string', 0, 'Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet.', 'H.264 MKV 1080p30', '[]', '{}', 320);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_preset_bluray', 'H.264 MKV 1080p30');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_extra_args_bluray', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (Blu-ray).', NULL, '[]', '{}', 325);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_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 ('handbrake_review_audio_languages_bluray_movie', 'Tools', 'Review Audio-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 326);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_movie', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_subtitle_languages_bluray_movie', 'Tools', 'Review UT-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 327);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_movie', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_audio_languages_bluray_series', 'Tools', 'Review Audio-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 328);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_subtitle_languages_bluray_series', 'Tools', 'Review UT-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 329);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (Blu-ray).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 330);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv');
|
||||
|
||||
UPDATE settings_values
|
||||
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_bluray')
|
||||
WHERE key IN ('handbrake_review_audio_languages_bluray_movie', 'handbrake_review_audio_languages_bluray_series')
|
||||
AND (value IS NULL OR TRIM(value) = '')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM settings_values legacy
|
||||
WHERE legacy.key = 'handbrake_review_audio_languages_bluray'
|
||||
AND legacy.value IS NOT NULL
|
||||
AND TRIM(legacy.value) <> ''
|
||||
);
|
||||
|
||||
UPDATE settings_values
|
||||
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray')
|
||||
WHERE key IN ('handbrake_review_subtitle_languages_bluray_movie', 'handbrake_review_subtitle_languages_bluray_series')
|
||||
AND (value IS NULL OR TRIM(value) = '')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM settings_values legacy
|
||||
WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray'
|
||||
AND legacy.value IS NOT NULL
|
||||
AND TRIM(legacy.value) <> ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
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})');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})');
|
||||
|
||||
-- Tools – DVD
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('mediainfo_extra_args_dvd', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (DVD).', NULL, '[]', '{}', 500);
|
||||
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, '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);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_analyze_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_extra_args_dvd', 'Tools', 'MakeMKV Rip Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Rip (DVD).', NULL, '[]', '{}', 515);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_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 ('handbrake_preset_dvd', 'Tools', 'HandBrake Preset', 'string', 0, 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.', 'H.264 MKV 480p30', '[]', '{}', 520);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_preset_dvd', 'H.264 MKV 480p30');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_extra_args_dvd', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (DVD).', NULL, '[]', '{}', 525);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_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 ('handbrake_review_audio_languages_dvd_movie', 'Tools', 'Review Audio-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 526);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_movie', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_subtitle_languages_dvd_movie', 'Tools', 'Review UT-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 527);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_movie', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_audio_languages_dvd_series', 'Tools', 'Review Audio-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 528);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('handbrake_review_subtitle_languages_dvd_series', 'Tools', 'Review UT-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 529);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (DVD).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 530);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv');
|
||||
|
||||
UPDATE settings_values
|
||||
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_dvd')
|
||||
WHERE key IN ('handbrake_review_audio_languages_dvd_movie', 'handbrake_review_audio_languages_dvd_series')
|
||||
AND (value IS NULL OR TRIM(value) = '')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM settings_values legacy
|
||||
WHERE legacy.key = 'handbrake_review_audio_languages_dvd'
|
||||
AND legacy.value IS NOT NULL
|
||||
AND TRIM(legacy.value) <> ''
|
||||
);
|
||||
|
||||
UPDATE settings_values
|
||||
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd')
|
||||
WHERE key IN ('handbrake_review_subtitle_languages_dvd_movie', 'handbrake_review_subtitle_languages_dvd_series')
|
||||
AND (value IS NULL OR TRIM(value) = '')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM settings_values legacy
|
||||
WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd'
|
||||
AND legacy.value IS NOT NULL
|
||||
AND TRIM(legacy.value) <> ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
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})');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})');
|
||||
|
||||
-- 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 ('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)
|
||||
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_api_key', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group');
|
||||
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
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 ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false');
|
||||
|
||||
-- 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');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_token', 'Benachrichtigungen', 'PushOver Token', 'string', 0, 'Application Token für PushOver.', NULL, '[]', '{}', 510);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_token', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_user', 'Benachrichtigungen', 'PushOver User', 'string', 0, 'User-Key für PushOver.', NULL, '[]', '{}', 520);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_user', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_device', 'Benachrichtigungen', 'PushOver Device (optional)', 'string', 0, 'Optionales Ziel-Device in PushOver.', NULL, '[]', '{}', 530);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_device', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_title_prefix', 'Benachrichtigungen', 'PushOver Titel-Präfix', 'string', 1, 'Prefix im PushOver Titel.', 'Ripster', '[]', '{"minLength":1}', 540);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_title_prefix', 'Ripster');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_priority', 'Benachrichtigungen', 'PushOver Priority', 'number', 1, 'Priorität -2 bis 2.', '0', '[]', '{"min":-2,"max":2}', 550);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_priority', '0');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_timeout_ms', 'Benachrichtigungen', 'PushOver Timeout (ms)', 'number', 1, 'HTTP Timeout für PushOver Requests.', '7000', '[]', '{"min":1000,"max":60000}', 560);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_timeout_ms', '7000');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei manueller Auswahl senden', 'boolean', 1, 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).', 'true', '[]', '{}', 570);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_metadata_ready', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_rip_started', 'Benachrichtigungen', 'Bei Rip-Start senden', 'boolean', 1, 'Sendet beim Start des MakeMKV-Rips.', 'true', '[]', '{}', 580);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_rip_started', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_encoding_started', 'Benachrichtigungen', 'Bei Encode-Start senden', 'boolean', 1, 'Sendet beim Start von HandBrake.', 'true', '[]', '{}', 590);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_encoding_started', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_job_finished', 'Benachrichtigungen', 'Bei Erfolg senden', 'boolean', 1, 'Sendet bei erfolgreich abgeschlossenem Job.', 'true', '[]', '{}', 600);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_finished', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_job_error', 'Benachrichtigungen', 'Bei Fehler senden', 'boolean', 1, 'Sendet bei Fehlern in der Pipeline.', 'true', '[]', '{}', 610);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_error', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_job_cancelled', 'Benachrichtigungen', 'Bei Abbruch senden', 'boolean', 1, 'Sendet wenn Job manuell abgebrochen wurde.', 'true', '[]', '{}', 620);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_cancelled', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_reencode_started', 'Benachrichtigungen', 'Bei Re-Encode Start senden', 'boolean', 1, 'Sendet beim Start von RAW Re-Encode.', 'true', '[]', '{}', 630);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reencode_started', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_reencode_finished', 'Benachrichtigungen', 'Bei Re-Encode Erfolg senden', 'boolean', 1, 'Sendet bei erfolgreichem RAW Re-Encode.', 'true', '[]', '{}', 640);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reencode_finished', 'true');
|
||||
|
||||
-- =============================================================================
|
||||
-- Converter Settings
|
||||
-- =============================================================================
|
||||
|
||||
-- Converter – Pfade und Templates (Kategorie: Pfade)
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_raw_dir', 'Pfade', 'Converter Raw-Ordner', 'path', 0, 'Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw).', NULL, '[]', '{}', 800);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_movie_dir', 'Pfade', 'Converter Ausgabe (Video)', 'path', 0, 'Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies).', NULL, '[]', '{}', 801);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_audio_dir', 'Pfade', 'Converter Ausgabe (Audio)', 'path', 0, 'Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio).', NULL, '[]', '{}', 802);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_output_template_video', 'Pfade', 'Output-Template (Video)', 'string', 1, 'Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}.', '{title}', '[]', '{"minLength":1}', 810);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', '{title}');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_output_template_audio', 'Pfade', 'Output-Template (Audio)', 'string', 1, 'Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}.', '{artist} - {title}', '[]', '{"minLength":1}', 811);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', '{artist} - {title}');
|
||||
|
||||
-- Migration: Converter-Pfad-Settings in Kategorie 'Pfade' verschieben (falls bereits in DB vorhanden)
|
||||
UPDATE settings_schema SET category = 'Pfade' WHERE key IN ('converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio') AND category = 'Converter';
|
||||
|
||||
-- Converter – Scan
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_scan_extensions', 'Converter', 'Erlaubte Datei-Endungen*', 'string', 1, 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus', '[]', '{"minLength":1}', 820);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_scan_extensions', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus');
|
||||
UPDATE settings_schema
|
||||
SET
|
||||
label = 'Erlaubte Datei-Endungen*',
|
||||
description = 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.',
|
||||
default_value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus'
|
||||
WHERE key = 'converter_scan_extensions';
|
||||
UPDATE settings_values
|
||||
SET value = TRIM(
|
||||
REPLACE(
|
||||
REPLACE(
|
||||
REPLACE(',' || LOWER(COALESCE(value, '')) || ',', ',aac,', ','),
|
||||
',,', ','
|
||||
),
|
||||
',,', ','
|
||||
),
|
||||
','
|
||||
)
|
||||
WHERE key = 'converter_scan_extensions';
|
||||
UPDATE settings_values
|
||||
SET value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus'
|
||||
WHERE key = 'converter_scan_extensions' AND (value IS NULL OR TRIM(value) = '');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_polling_enabled', 'Converter', 'Auto-Scan (Polling)', 'boolean', 1, 'Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen.', 'false', '[]', '{}', 830);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_enabled', 'false');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('converter_polling_interval', 'Converter', 'Polling-Intervall (Sekunden)', 'number', 1, 'Intervall für automatischen Scan des Converter-Ordners.', '300', '[]', '{"min":30,"max":86400}', 840);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_interval', '300');
|
||||
@@ -0,0 +1,290 @@
|
||||
# Converter API
|
||||
|
||||
Endpunkte für den Datei-Converter: Datei-Explorer, Upload, Job-Verwaltung.
|
||||
|
||||
Basis-Pfad: `/api/converter`
|
||||
|
||||
---
|
||||
|
||||
## Scan & Explorer
|
||||
|
||||
### `GET /api/converter/tree`
|
||||
|
||||
Vollständiger Verzeichnisbaum des `converter_raw_dir` (FS-basiert, keine DB).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tree": [
|
||||
{
|
||||
"name": "filme",
|
||||
"relPath": "filme",
|
||||
"isDir": true,
|
||||
"children": [
|
||||
{
|
||||
"name": "film.mkv",
|
||||
"relPath": "filme/film.mkv",
|
||||
"isDir": false,
|
||||
"size": 10485760
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/converter/browse?parent=relPath`
|
||||
|
||||
DB-basierter Datei-Explorer. Gibt Einträge für einen Unterordner zurück.
|
||||
|
||||
**Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `parent` | string | Relativer Pfad zum Unterordner (leer = Root) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": 1,
|
||||
"rel_path": "filme/film.mkv",
|
||||
"is_dir": false,
|
||||
"size": 10485760,
|
||||
"job_id": null
|
||||
}
|
||||
],
|
||||
"rawDir": "/data/output/converter-raw"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/scan`
|
||||
|
||||
Manuellen Scan des `converter_raw_dir` auslösen. Aktualisiert `converter_scan_entries` in der DB.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "result": { "added": 3, "removed": 1 } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jobs erstellen
|
||||
|
||||
### `POST /api/converter/jobs/from-selection`
|
||||
|
||||
Jobs aus im Datei-Explorer ausgewählten Dateien erstellen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"relPaths": ["filme/film.mkv", "musik/track.flac"],
|
||||
"audioMode": "individual"
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `relPaths` | string[] | Relative Pfade der ausgewählten Dateien |
|
||||
| `audioMode` | string | `individual` (ein Job pro Datei) oder `shared` (ein gemeinsamer Job) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "jobs": [{ "id": 42, "status": "READY_TO_START" }] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/create-jobs`
|
||||
|
||||
Jobs aus DB-Scan-Einträgen erstellen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{ "relPath": "filme/film.mkv", "converterMediaType": "video" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/upload`
|
||||
|
||||
Dateien hochladen (Multipart, max. 50 Dateien).
|
||||
|
||||
**Form-Felder:**
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `files` | File[] | Hochzuladende Dateien |
|
||||
| `folderName` | string | (Optional) Ziel-Unterordner |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"folders": [{ "folderRelPath": "upload-2026-03-30", "fileCount": 2 }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Job-Verwaltung
|
||||
|
||||
### `GET /api/converter/jobs`
|
||||
|
||||
Alle Converter-Jobs zurückgeben.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "jobs": [{ "id": 42, "status": "READY_TO_START", "title": "film.mkv" }] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/converter/jobs/:jobId`
|
||||
|
||||
Einzelnen Converter-Job abrufen.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/config`
|
||||
|
||||
Konfigurationsentwurf für einen Job speichern (persistiert in `encode_plan_json`).
|
||||
|
||||
**Body:** Partielles Konfig-Objekt (Ausgabeformat, Presets, Metadaten, Track-Auswahl).
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/assign-files`
|
||||
|
||||
Dateien einem bestehenden (noch nicht gestarteten) Job hinzufügen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPaths": ["musik/track2.flac"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/remove-file`
|
||||
|
||||
Datei aus einem Job entfernen (per relativer Pfad).
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPath": "musik/track2.flac" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/remove-input`
|
||||
|
||||
Datei aus einem Job entfernen (per absolutem Eingabepfad).
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "inputPath": "/data/output/converter-raw/musik/track2.flac" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/start`
|
||||
|
||||
Job mit finaler Konfiguration starten.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"converterMediaType": "video",
|
||||
"outputFormat": "mkv",
|
||||
"userPreset": "H.264 MKV 1080p30",
|
||||
"trackSelection": {},
|
||||
"handBrakeTitleId": 1,
|
||||
"audioFormatOptions": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/jobs/:jobId/cancel`
|
||||
|
||||
Laufenden Job abbrechen.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/converter/jobs/:jobId`
|
||||
|
||||
Job aus der DB löschen.
|
||||
|
||||
---
|
||||
|
||||
## Datei-Operationen
|
||||
|
||||
Alle Datei-Operationen arbeiten direkt auf dem Dateisystem (ohne DB-Aktualisierung). Ein anschließender Scan-Aufruf synchronisiert die DB.
|
||||
|
||||
### `DELETE /api/converter/files`
|
||||
|
||||
Datei oder Ordner löschen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPath": "filme/film.mkv" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/files/rename`
|
||||
|
||||
Datei oder Ordner umbenennen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPath": "filme/film.mkv", "newName": "film-neu.mkv" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/files/move`
|
||||
|
||||
Datei oder Ordner verschieben.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "relPath": "filme/film.mkv", "targetParentRelPath": "archiv" }
|
||||
```
|
||||
|
||||
`targetParentRelPath = ""` verschiebt in das Root-Verzeichnis.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/converter/files/folder`
|
||||
|
||||
Neuen Ordner anlegen.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{ "parentRelPath": "filme", "name": "neu" }
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# Cron API
|
||||
|
||||
Ripster enthält ein eingebautes Cron-System für Skripte und Skript-Ketten (`sourceType: script|chain`).
|
||||
|
||||
---
|
||||
|
||||
## GET /api/crons
|
||||
|
||||
Listet alle Cron-Jobs.
|
||||
|
||||
```json
|
||||
{
|
||||
"jobs": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Nachtlauf Backup",
|
||||
"cronExpression": "0 2 * * *",
|
||||
"sourceType": "script",
|
||||
"sourceId": 3,
|
||||
"sourceName": "Backup-Skript",
|
||||
"enabled": true,
|
||||
"pushoverEnabled": true,
|
||||
"lastRunAt": "2026-03-10T02:00:00.000Z",
|
||||
"lastRunStatus": "success",
|
||||
"nextRunAt": "2026-03-11T02:00:00.000Z",
|
||||
"createdAt": "2026-03-01T10:00:00.000Z",
|
||||
"updatedAt": "2026-03-10T02:00:05.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/crons
|
||||
|
||||
Erstellt Cron-Job.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Nachtlauf Backup",
|
||||
"cronExpression": "0 2 * * *",
|
||||
"sourceType": "script",
|
||||
"sourceId": 3,
|
||||
"enabled": true,
|
||||
"pushoverEnabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Response: `201` mit `{ "job": { ... } }`
|
||||
|
||||
---
|
||||
|
||||
## GET /api/crons/:id
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "job": { "id": 1, "name": "..." } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/crons/:id
|
||||
|
||||
Aktualisiert Cron-Job. Felder wie bei `POST`.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "job": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/crons/:id
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "removed": { "id": 1, "name": "Nachtlauf Backup" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/crons/:id/logs
|
||||
|
||||
Liefert Ausführungs-Logs.
|
||||
|
||||
**Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Default | Beschreibung |
|
||||
|-----------|-----|---------|-------------|
|
||||
| `limit` | number | `20` | Anzahl Einträge, max. `100` |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"logs": [
|
||||
{
|
||||
"id": 42,
|
||||
"cronJobId": 1,
|
||||
"startedAt": "2026-03-10T02:00:01.000Z",
|
||||
"finishedAt": "2026-03-10T02:00:05.000Z",
|
||||
"status": "success",
|
||||
"output": "Backup abgeschlossen.",
|
||||
"errorMessage": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`status`: `running` | `success` | `error`
|
||||
|
||||
---
|
||||
|
||||
## POST /api/crons/:id/run
|
||||
|
||||
Triggert Job manuell (asynchron).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "triggered": true, "cronJobId": 1 }
|
||||
```
|
||||
|
||||
Wenn Job bereits läuft: `409`.
|
||||
|
||||
---
|
||||
|
||||
## POST /api/crons/validate-expression
|
||||
|
||||
Validiert 5-Felder-Cron-Ausdruck und berechnet nächsten Lauf.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "cronExpression": "*/15 * * * *" }
|
||||
```
|
||||
|
||||
**Gültige Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"nextRunAt": "2026-03-10T14:15:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ungültige Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"error": "Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag).",
|
||||
"nextRunAt": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cron-Format
|
||||
|
||||
Ripster unterstützt 5 Felder:
|
||||
|
||||
```text
|
||||
Minute Stunde Tag Monat Wochentag
|
||||
```
|
||||
|
||||
Beispiele:
|
||||
|
||||
- `0 2 * * *` täglich 02:00
|
||||
- `*/15 * * * *` alle 15 Minuten
|
||||
- `0 6 * * 1-5` Mo-Fr 06:00
|
||||
|
||||
---
|
||||
|
||||
## WebSocket-Events zu Cron
|
||||
|
||||
- `CRON_JOBS_UPDATED` bei Create/Update/Delete
|
||||
- `CRON_JOB_UPDATED` bei Laufzeitstatus (`running` -> `success|error`)
|
||||
@@ -0,0 +1,143 @@
|
||||
# Downloads API
|
||||
|
||||
Endpunkte für die Download-Queue. Ausgabedateien aus der Job-Historie können als ZIP heruntergeladen werden.
|
||||
|
||||
Basis-Pfad: `/api/downloads`
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/downloads`
|
||||
|
||||
Liste aller Download-Einträge plus Zusammenfassung.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "dl-42-raw",
|
||||
"jobId": 42,
|
||||
"target": "raw",
|
||||
"status": "ready",
|
||||
"archiveName": "Job_42_raw.zip",
|
||||
"outputPath": null,
|
||||
"createdAt": "2026-03-30T10:00:00.000Z",
|
||||
"updatedAt": "2026-03-30T10:01:00.000Z",
|
||||
"errorMessage": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"pending": 1,
|
||||
"processing": 0,
|
||||
"ready": 1,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status-Werte:**
|
||||
|
||||
| Status | Beschreibung |
|
||||
|---|---|
|
||||
| `pending` | In der Queue, noch nicht gestartet |
|
||||
| `processing` | ZIP wird gerade erstellt |
|
||||
| `ready` | ZIP fertig, Download verfügbar |
|
||||
| `failed` | Fehler bei der Erstellung |
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/downloads/summary`
|
||||
|
||||
Nur die Zusammenfassung der Download-Queue (ohne Item-Liste).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"pending": 1,
|
||||
"processing": 0,
|
||||
"ready": 1,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `POST /api/downloads/history/:jobId`
|
||||
|
||||
Job-Ausgabe in die Download-Queue einreihen.
|
||||
|
||||
**Parameter:**
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `jobId` | number | ID des Jobs in der Historie |
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "raw",
|
||||
"outputPath": null
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Typ | Default | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `target` | string | `raw` | `raw` (RAW-Dateien) oder `movie` (Ausgabedateien) |
|
||||
| `outputPath` | string | `null` | Optionaler expliziter Ausgabepfad |
|
||||
|
||||
**Response (201 Created):**
|
||||
|
||||
```json
|
||||
{
|
||||
"created": true,
|
||||
"id": "dl-42-raw",
|
||||
"status": "pending",
|
||||
"summary": { "total": 1, "pending": 1, "processing": 0, "ready": 0, "failed": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
Ist der Eintrag bereits vorhanden, wird `201` durch `200` ersetzt und `created: false` zurückgegeben.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/downloads/:id/file`
|
||||
|
||||
Fertige ZIP-Datei herunterladen.
|
||||
|
||||
**Parameter:**
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `id` | string | Download-ID (z. B. `dl-42-raw`) |
|
||||
|
||||
**Response:** Datei-Download (`Content-Disposition: attachment`).
|
||||
|
||||
Schlägt fehl mit `404`, wenn kein Download-Eintrag mit dieser ID existiert oder die Datei noch nicht bereit ist.
|
||||
|
||||
---
|
||||
|
||||
## `DELETE /api/downloads/:id`
|
||||
|
||||
Download-Eintrag löschen (auch fertige ZIP-Dateien werden vom Dateisystem entfernt).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"summary": { "total": 2, "pending": 0, "processing": 0, "ready": 2, "failed": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
Statusänderungen werden über `DOWNLOADS_UPDATED` in Echtzeit gemeldet. Vollständige Dokumentation: [WebSocket Events](websocket.md#downloads_updated).
|
||||
@@ -0,0 +1,229 @@
|
||||
# History API
|
||||
|
||||
Endpunkte für Job-Historie, Metadaten-Nachpflege, Orphan-Import und Löschoperationen.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/history
|
||||
|
||||
Liefert Jobs mit optionalen Filtern.
|
||||
|
||||
**Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|----------|-----|-------------|
|
||||
| `status` | string | Einzelstatus-Filter (legacy-kompatibel). |
|
||||
| `statuses` | string | Mehrfachstatus als CSV, z. B. `FINISHED,ERROR`. |
|
||||
| `search` | string | Suche in Titel-/IMDb-Feldern. |
|
||||
| `limit` | number | Maximale Anzahl Ergebnisse. |
|
||||
| `lite` | bool | Ohne Dateisystem-Checks (schneller). |
|
||||
| `includeChildren` | bool | Child-Jobs (z. B. Serien-/Multipart-Kinder) explizit einbeziehen. |
|
||||
|
||||
**Beispiel:**
|
||||
|
||||
```text
|
||||
GET /api/history?statuses=FINISHED,ERROR&search=Inception&limit=50&lite=1
|
||||
```
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"jobs": [
|
||||
{
|
||||
"id": 42,
|
||||
"status": "FINISHED",
|
||||
"title": "Inception",
|
||||
"job_kind": "bluray",
|
||||
"raw_path": "/mnt/raw/Inception - RAW - job-42",
|
||||
"output_path": "/mnt/movies/Inception (2010)/Inception (2010).mkv",
|
||||
"mediaType": "bluray",
|
||||
"ripSuccessful": true,
|
||||
"encodeSuccess": true,
|
||||
"created_at": "2026-03-10T08:00:00.000Z",
|
||||
"updated_at": "2026-03-10T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/history/:id
|
||||
|
||||
Liefert Job-Detail.
|
||||
|
||||
**Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Standard | Beschreibung |
|
||||
|----------|-----|---------|-------------|
|
||||
| `includeLogs` | bool | `false` | Prozesslog laden. |
|
||||
| `includeLiveLog` | bool | `false` | Live-/Tail-Log laden. |
|
||||
| `includeAllLogs` | bool | `false` | vollständiges Log statt Tail. |
|
||||
| `logTailLines` | number | `800` | Tail-Länge falls nicht `includeAllLogs`. |
|
||||
| `lite` | bool | `false` | Detailantwort ohne FS-Checks. |
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"job": {
|
||||
"id": 42,
|
||||
"status": "FINISHED",
|
||||
"makemkvInfo": {},
|
||||
"mediainfoInfo": {},
|
||||
"handbrakeInfo": {},
|
||||
"encodePlan": {},
|
||||
"log": "...",
|
||||
"log_count": 1,
|
||||
"logMeta": {
|
||||
"loaded": true,
|
||||
"total": 800,
|
||||
"returned": 800,
|
||||
"truncated": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/history/orphan-raw
|
||||
|
||||
Sucht RAW-Ordner ohne zugehörigen Job.
|
||||
|
||||
## POST /api/history/orphan-raw/import
|
||||
|
||||
Importiert einen RAW-Ordner als Job und triggert optional die Analyse des Imports.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "rawPath": "/mnt/raw/Inception (2010) [tt1375666] - RAW - job-99" }
|
||||
```
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"job": { "id": 77, "status": "FINISHED" },
|
||||
"activation": { "started": true },
|
||||
"activationError": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/history/:id/metadata/assign
|
||||
|
||||
Weist TMDb-Metadaten nachträglich zu oder aktualisiert bestehende Film-/Serien-Metadaten eines Jobs.
|
||||
|
||||
## POST /api/history/:id/cd/assign
|
||||
|
||||
Weist CD-Metadaten (z. B. MusicBrainz) nachträglich zu.
|
||||
|
||||
## POST /api/history/:id/error/ack
|
||||
|
||||
Quittiert einen Fehlerzustand im Historieneintrag.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/history/:id/delete-preview
|
||||
|
||||
Liefert eine Vorschau der löschbaren Pfade (inkl. Scope auf verknüpfte Jobs).
|
||||
|
||||
**Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Standard | Beschreibung |
|
||||
|----------|-----|---------|-------------|
|
||||
| `includeRelated` | bool | `true` | Verknüpfte Jobs in die Vorschau einbeziehen. |
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"preview": {
|
||||
"jobId": 42,
|
||||
"relatedJobs": [{ "id": 42 }, { "id": 73 }],
|
||||
"pathCandidates": {
|
||||
"raw": [{ "path": "/mnt/raw/...", "exists": true, "isDirectory": true }],
|
||||
"movie": [{ "path": "/mnt/movies/...", "exists": true, "isDirectory": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/history/:id/delete-files
|
||||
|
||||
Löscht Dateien eines Jobs, behält DB-Eintrag.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "both",
|
||||
"includeRelated": true,
|
||||
"selectedRawPaths": ["/mnt/raw/Inception - RAW - Disc1"],
|
||||
"selectedMoviePaths": ["/mnt/movies/Inception (2010)"]
|
||||
}
|
||||
```
|
||||
|
||||
`target`: `raw` | `movie` | `both`
|
||||
|
||||
`selectedRawPaths` / `selectedMoviePaths` sind optional und begrenzen die Löschung auf ausgewählte Pfade aus der Vorschau.
|
||||
|
||||
---
|
||||
|
||||
## POST /api/history/:id/delete
|
||||
|
||||
Löscht Job aus DB; optional auch Dateien.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "both",
|
||||
"includeRelated": true,
|
||||
"resetDriveState": false,
|
||||
"keepDetectedDevice": true,
|
||||
"preserveRawForImportJobs": false,
|
||||
"selectedRawPaths": ["/mnt/raw/Inception - RAW - Disc1"],
|
||||
"selectedMoviePaths": ["/mnt/movies/Inception (2010)"]
|
||||
}
|
||||
```
|
||||
|
||||
`target`: `none` | `raw` | `movie` | `both`
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": true,
|
||||
"jobId": 42,
|
||||
"fileTarget": "both",
|
||||
"fileSummary": {
|
||||
"target": "both",
|
||||
"raw": { "filesDeleted": 10 },
|
||||
"movie": { "filesDeleted": 1 }
|
||||
},
|
||||
"uiReset": {
|
||||
"reset": true,
|
||||
"state": "IDLE"
|
||||
},
|
||||
"safeguards": {
|
||||
"containsOrphanRawImportJob": false,
|
||||
"resetDriveStateApplied": false,
|
||||
"keepDetectedDeviceApplied": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Ein aktiver Pipeline-Job kann nicht gelöscht werden (`409`).
|
||||
- Löschoperationen sind irreversibel.
|
||||
- Bei Serien-/Multipart-Containern steuern `includeRelated` und Pfadselektionen den genauen Scope.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Anhang: API-Referenz
|
||||
|
||||
REST- und WebSocket-Schnittstellen für Integration, Automatisierung und Debugging.
|
||||
|
||||
## Basis-URL
|
||||
|
||||
```text
|
||||
http://localhost:3001
|
||||
```
|
||||
|
||||
API-Prefix: `/api`
|
||||
|
||||
## API-Gruppen
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-heart-pulse: **Health**
|
||||
|
||||
---
|
||||
|
||||
Service-Liveness.
|
||||
|
||||
`GET /api/health`
|
||||
|
||||
- :material-pipe: **Pipeline API**
|
||||
|
||||
---
|
||||
|
||||
Analyse, Start/Retry/Cancel, Queue, Re-Encode.
|
||||
|
||||
[:octicons-arrow-right-24: Pipeline API](pipeline.md)
|
||||
|
||||
- :material-cog: **Settings API**
|
||||
|
||||
---
|
||||
|
||||
Einstellungen, Skripte/Ketten, User-Presets.
|
||||
|
||||
[:octicons-arrow-right-24: Settings API](settings.md)
|
||||
|
||||
- :material-history: **History API**
|
||||
|
||||
---
|
||||
|
||||
Job-Historie, Orphan-Import, Löschoperationen.
|
||||
|
||||
[:octicons-arrow-right-24: History API](history.md)
|
||||
|
||||
- :material-clock-outline: **Cron API**
|
||||
|
||||
---
|
||||
|
||||
Zeitgesteuerte Skript-/Kettenausführung.
|
||||
|
||||
[:octicons-arrow-right-24: Cron API](crons.md)
|
||||
|
||||
- :material-swap-horizontal: **Converter API**
|
||||
|
||||
---
|
||||
|
||||
Datei-Explorer, Upload, Job-Verwaltung für den Converter.
|
||||
|
||||
[:octicons-arrow-right-24: Converter API](converter.md)
|
||||
|
||||
- :material-download: **Downloads API**
|
||||
|
||||
---
|
||||
|
||||
Download-Queue für Ausgabedateien aus der Job-Historie.
|
||||
|
||||
[:octicons-arrow-right-24: Downloads API](downloads.md)
|
||||
|
||||
- :material-lightning-bolt: **Runtime Activities API**
|
||||
|
||||
---
|
||||
|
||||
Laufende und abgeschlossene Aktivitäten (Skripte, Ketten, Cron, Tasks).
|
||||
|
||||
[:octicons-arrow-right-24: Runtime Activities](runtime-activities.md)
|
||||
|
||||
- :material-websocket: **WebSocket Events**
|
||||
|
||||
---
|
||||
|
||||
Pipeline-, Queue-, Disk-, Settings-, Cron-, Converter- und Download-Events.
|
||||
|
||||
[:octicons-arrow-right-24: WebSocket](websocket.md)
|
||||
|
||||
</div>
|
||||
|
||||
## Hinweis
|
||||
|
||||
Ripster hat keine eingebaute Authentifizierung und ist für lokalen, geschützten Betrieb gedacht.
|
||||
@@ -0,0 +1,534 @@
|
||||
# Pipeline API
|
||||
|
||||
Endpunkte zur Steuerung des Pipeline-Workflows.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/state
|
||||
|
||||
Liefert aktuellen Pipeline- und Hardware-Monitoring-Snapshot.
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"pipeline": {
|
||||
"state": "READY_TO_ENCODE",
|
||||
"activeJobId": 42,
|
||||
"progress": 0,
|
||||
"eta": null,
|
||||
"statusText": "Mediainfo bestätigt - Encode manuell starten",
|
||||
"context": {
|
||||
"jobId": 42
|
||||
},
|
||||
"jobProgress": {
|
||||
"42": {
|
||||
"state": "MEDIAINFO_CHECK",
|
||||
"progress": 68.5,
|
||||
"eta": null,
|
||||
"statusText": "MEDIAINFO_CHECK 68.50%"
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"maxParallelJobs": 1,
|
||||
"runningCount": 1,
|
||||
"queuedCount": 2,
|
||||
"runningJobs": [],
|
||||
"queuedJobs": []
|
||||
}
|
||||
},
|
||||
"hardwareMonitoring": {
|
||||
"enabled": true,
|
||||
"intervalMs": 5000,
|
||||
"updatedAt": "2026-03-10T09:00:00.000Z",
|
||||
"sample": {
|
||||
"cpu": {},
|
||||
"memory": {},
|
||||
"gpu": {},
|
||||
"storage": {}
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/analyze
|
||||
|
||||
Startet Disc-Analyse und legt Job an.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"jobId": 42,
|
||||
"detectedTitle": "INCEPTION",
|
||||
"omdbCandidates": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/rescan-disc
|
||||
|
||||
Erzwingt erneute Laufwerksprüfung.
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"present": true,
|
||||
"changed": true,
|
||||
"emitted": "discInserted",
|
||||
"device": {
|
||||
"path": "/dev/sr0",
|
||||
"discLabel": "INCEPTION",
|
||||
"mediaProfile": "bluray"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/rescan-drive
|
||||
|
||||
Erzwingt Rescan für ein konkretes Laufwerk.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "devicePath": "/dev/sr0" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/tmdb/movie/search?q=<query>
|
||||
|
||||
TMDb-Filmsuche.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"imdbId": "tt1375666",
|
||||
"title": "Inception",
|
||||
"year": "2010",
|
||||
"type": "movie",
|
||||
"poster": "https://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/tmdb/series/search?q=<query>&season=<n>
|
||||
|
||||
TMDb-Seriensuche (für Serien-Workflow bei DVD/Blu-ray).
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/cd/drives
|
||||
|
||||
Liefert Snapshot der aktuell bekannten CD-Laufwerke.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/pipeline/cd/musicbrainz/search?q=<query>
|
||||
|
||||
MusicBrainz-Suche für CD-Metadaten.
|
||||
|
||||
## GET /api/pipeline/cd/musicbrainz/release/:mbId
|
||||
|
||||
Lädt Release-Details zu einer MusicBrainz-ID.
|
||||
|
||||
## POST /api/pipeline/cd/select-metadata
|
||||
|
||||
Übernimmt CD-Metadaten für einen Job.
|
||||
|
||||
**Request (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": 91,
|
||||
"title": "Album",
|
||||
"artist": "Artist",
|
||||
"year": 2020,
|
||||
"mbId": "f5093c06-23e3-404f-aeaa-40f72885ee3a",
|
||||
"coverUrl": "https://...",
|
||||
"tracks": []
|
||||
}
|
||||
```
|
||||
|
||||
## POST /api/pipeline/cd/start/:jobId
|
||||
|
||||
Startet/queued CD-Rip mit Format-/Script-/Chain-Konfiguration.
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/audiobook/upload
|
||||
|
||||
Upload von AAX-Datei (Multipart/FormData).
|
||||
|
||||
**FormData-Felder:**
|
||||
|
||||
- `file` (Pflicht)
|
||||
- `format` (optional)
|
||||
- `startImmediately` (optional)
|
||||
|
||||
## GET /api/pipeline/audiobook/pending-activation
|
||||
|
||||
Zeigt AAX-Jobs mit fehlenden Activation-Bytes.
|
||||
|
||||
## POST /api/pipeline/audiobook/start/:jobId
|
||||
|
||||
Startet Audiobook-Job mit optionaler Konfiguration.
|
||||
|
||||
## GET /api/pipeline/audiobook/jobs
|
||||
|
||||
Liefert Audiobook-Jobliste.
|
||||
|
||||
## GET /api/pipeline/audiobook/output-tree
|
||||
|
||||
Read-only Baumansicht des Audiobook-Ausgabeordners.
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/select-metadata
|
||||
|
||||
Setzt Metadaten (und optional Playlist) für einen Job.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": 42,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"imdbId": "tt1375666",
|
||||
"poster": "https://...",
|
||||
"fromOmdb": true,
|
||||
"selectedPlaylist": "00800"
|
||||
}
|
||||
```
|
||||
|
||||
Wichtige optionale Felder:
|
||||
|
||||
- `selectedHandBrakeTitleId` / `selectedHandBrakeTitleIds`: Titel-Auswahl für Review/MediaInfo.
|
||||
- `metadataProvider`: `omdb` oder `tmdb`.
|
||||
- `workflowKind`: bei Disc-Jobs typischerweise `film` oder `series`.
|
||||
- `discNumber`: Pflicht für Serien-Disc-Zuordnung (`workflowKind=series`).
|
||||
- `duplicateAction`: Duplikatverhalten bei Film-Metadaten (`allow_new` oder `multipart_movie`).
|
||||
- `existingJobId`: optionaler Referenzjob für `multipart_movie`.
|
||||
- `existingDiscNumber`: Disc-Nummer des bestehenden Jobs für `multipart_movie`.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "job": { "id": 42, "status": "READY_TO_START" } }
|
||||
```
|
||||
|
||||
**Konflikt-Response (Beispiel, HTTP 409):**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Metadaten bereits in der Historie gefunden. Bitte Auswahl übernehmen oder Multipart movie wählen.",
|
||||
"details": [
|
||||
{
|
||||
"code": "METADATA_DUPLICATE_FOUND",
|
||||
"mediaProfile": "bluray",
|
||||
"existingJob": {
|
||||
"id": 17,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"status": "FINISHED",
|
||||
"jobKind": "bluray",
|
||||
"discNumber": 1,
|
||||
"isMultipartMovie": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Relevante Fehlercodes (`POST /api/pipeline/select-metadata`):**
|
||||
|
||||
| Code | Bedeutung |
|
||||
|---|---|
|
||||
| `METADATA_DUPLICATE_FOUND` | Film-Metadaten existieren bereits in der Historie (Konfliktdialog im UI). |
|
||||
| `MULTIPART_DISC_REQUIRED` | Für Multipart fehlen Disc-Nummern (`discNumber`/`existingDiscNumber`). |
|
||||
| `MULTIPART_DISC_ALREADY_EXISTS` | Disc-Nummer im Multipart-Container bereits vergeben oder doppelt. |
|
||||
| `MULTIPART_MEDIA_MISMATCH` | Multipart nur bei gleichem Medientyp (DVD oder Blu-ray). |
|
||||
| `MULTIPART_SERIES_NOT_ALLOWED` | Multipart ist nur für Film-Jobs erlaubt, nicht für Serien-Workflow. |
|
||||
| `MULTIPART_METADATA_MISMATCH` | Ausgewählter bestehender Job passt nicht zu den Film-Metadaten. |
|
||||
| `SERIES_DISC_ALREADY_EXISTS` | Serien-Disc-Nummer innerhalb einer Staffel bereits vorhanden. |
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/jobs/:jobId/raw-decision
|
||||
|
||||
Trifft Entscheidung bei vorhandenem RAW (`continue` oder `restart` je nach Dialogfluss).
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "decision": "continue" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/start/:jobId
|
||||
|
||||
Startet vorbereiteten Job oder queued ihn (je nach Parallel-Limit).
|
||||
|
||||
**Mögliche Responses:**
|
||||
|
||||
```json
|
||||
{ "result": { "started": true, "stage": "RIPPING" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "result": { "queued": true, "started": false, "queuePosition": 2, "action": "START_PREPARED" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/confirm-encode/:jobId
|
||||
|
||||
Bestätigt Review-Auswahl (Tracks, Pre/Post-Skripte/Ketten, User-Preset).
|
||||
|
||||
**Request (typisch):**
|
||||
|
||||
```json
|
||||
{
|
||||
"selectedEncodeTitleId": 1,
|
||||
"selectedTrackSelection": {
|
||||
"1": {
|
||||
"audioTrackIds": [1, 2],
|
||||
"subtitleTrackIds": [3]
|
||||
}
|
||||
},
|
||||
"selectedPreEncodeScriptIds": [1],
|
||||
"selectedPostEncodeScriptIds": [2, 7],
|
||||
"selectedPreEncodeChainIds": [3],
|
||||
"selectedPostEncodeChainIds": [4],
|
||||
"selectedUserPresetId": 5,
|
||||
"skipPipelineStateUpdate": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "job": { "id": 42, "encode_review_confirmed": 1 } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/cancel
|
||||
|
||||
Bricht laufenden Job ab oder entfernt Queue-Eintrag.
|
||||
|
||||
**Request (optional):**
|
||||
|
||||
```json
|
||||
{ "jobId": 42 }
|
||||
```
|
||||
|
||||
**Mögliche Responses:**
|
||||
|
||||
```json
|
||||
{ "result": { "cancelled": true, "queuedOnly": true, "jobId": 42 } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "result": { "cancelled": true, "queuedOnly": false, "jobId": 42 } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "result": { "cancelled": true, "queuedOnly": false, "pending": true, "jobId": 42 } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/pipeline/retry/:jobId
|
||||
|
||||
Retry für `ERROR`/`CANCELLED`-Jobs (oder Queue-Einreihung).
|
||||
|
||||
## POST /api/pipeline/reencode/:jobId
|
||||
|
||||
Startet Re-Encode aus bestehendem RAW.
|
||||
|
||||
## POST /api/pipeline/restart-review/:jobId
|
||||
|
||||
Berechnet Review aus RAW neu.
|
||||
|
||||
## POST /api/pipeline/restart-encode/:jobId
|
||||
|
||||
Startet Encoding mit letzter bestätigter Review neu.
|
||||
|
||||
## POST /api/pipeline/restart-cd-review/:jobId
|
||||
|
||||
Berechnet CD-Review aus vorhandenem RAW neu.
|
||||
|
||||
## POST /api/pipeline/resume-ready/:jobId
|
||||
|
||||
Lädt `READY_TO_ENCODE`-Job nach Neustart wieder in aktive Session.
|
||||
|
||||
## GET /api/pipeline/output-folders/:jobId
|
||||
|
||||
Liefert bekannte Output-Ordner entlang der Job-Lineage.
|
||||
|
||||
## POST /api/pipeline/delete-output-folders/:jobId
|
||||
|
||||
Löscht ausgewählte Output-Ordner.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "folderPaths": ["/mnt/movies/Inception (2010)"] }
|
||||
```
|
||||
|
||||
Alle Endpunkte liefern `{ result: ... }` bzw. `{ job: ... }`.
|
||||
|
||||
---
|
||||
|
||||
## Queue-Endpunkte
|
||||
|
||||
### GET /api/pipeline/queue
|
||||
|
||||
Liefert Queue-Snapshot.
|
||||
|
||||
```json
|
||||
{
|
||||
"queue": {
|
||||
"maxParallelJobs": 1,
|
||||
"runningCount": 1,
|
||||
"queuedCount": 3,
|
||||
"runningJobs": [
|
||||
{
|
||||
"jobId": 41,
|
||||
"title": "Inception",
|
||||
"status": "ENCODING",
|
||||
"lastState": "ENCODING"
|
||||
}
|
||||
],
|
||||
"queuedJobs": [
|
||||
{
|
||||
"entryId": 11,
|
||||
"position": 1,
|
||||
"type": "job",
|
||||
"jobId": 42,
|
||||
"action": "START_PREPARED",
|
||||
"actionLabel": "Start",
|
||||
"title": "Matrix",
|
||||
"status": "READY_TO_ENCODE",
|
||||
"lastState": "READY_TO_ENCODE",
|
||||
"hasScripts": true,
|
||||
"hasChains": false,
|
||||
"enqueuedAt": "2026-03-10T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"entryId": 12,
|
||||
"position": 2,
|
||||
"type": "wait",
|
||||
"waitSeconds": 30,
|
||||
"title": "Warten 30s",
|
||||
"status": "QUEUED",
|
||||
"enqueuedAt": "2026-03-10T09:01:00.000Z"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-03-10T09:01:02.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/pipeline/queue/reorder
|
||||
|
||||
Sortiert Queue-Einträge neu.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"orderedEntryIds": [12, 11]
|
||||
}
|
||||
```
|
||||
|
||||
Legacy fallback wird akzeptiert:
|
||||
|
||||
```json
|
||||
{
|
||||
"orderedJobIds": [42, 43]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/pipeline/queue/entry
|
||||
|
||||
Fügt Nicht-Job-Queue-Eintrag hinzu (`script`, `chain`, `wait`).
|
||||
|
||||
**Request-Beispiele:**
|
||||
|
||||
```json
|
||||
{ "type": "script", "scriptId": 3 }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "chain", "chainId": 2, "insertAfterEntryId": 11 }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "type": "wait", "waitSeconds": 45 }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"result": { "entryId": 12, "type": "wait", "position": 2 },
|
||||
"queue": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/pipeline/queue/entry/:entryId
|
||||
|
||||
Entfernt Queue-Eintrag.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{ "queue": { "...": "..." } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pipeline-Zustände
|
||||
|
||||
| State | Bedeutung |
|
||||
|------|-----------|
|
||||
| `IDLE` | Wartet auf Medium |
|
||||
| `DISC_DETECTED` | Medium erkannt |
|
||||
| `ANALYZING` | MakeMKV-Analyse läuft |
|
||||
| `METADATA_SELECTION` | Metadaten-Auswahl |
|
||||
| `WAITING_FOR_USER_DECISION` | Nutzerentscheidung erforderlich (Playlist-Auswahl oder RAW-Entscheidung) |
|
||||
| `READY_TO_START` | Übergang vor Start |
|
||||
| `RIPPING` | MakeMKV-Rip läuft |
|
||||
| `MEDIAINFO_CHECK` | Titel-/Track-Auswertung |
|
||||
| `READY_TO_ENCODE` | Review bereit |
|
||||
| `ENCODING` | HandBrake-Encoding läuft |
|
||||
| `CD_METADATA_SELECTION` | CD-Metadatenauswahl aktiv |
|
||||
| `CD_READY_TO_RIP` | CD-Job ist startbereit |
|
||||
| `CD_ANALYZING` | CD-Struktur/Tracks werden vorbereitet |
|
||||
| `CD_RIPPING` | CD-Ripping läuft |
|
||||
| `CD_ENCODING` | CD-Encode läuft |
|
||||
| `FINISHED` | Abgeschlossen |
|
||||
| `DONE` | Abgeschlossen (v. a. Audiobook/Converter/CD-Varianten) |
|
||||
| `CANCELLED` | Abgebrochen |
|
||||
| `ERROR` | Fehler |
|
||||
@@ -0,0 +1,235 @@
|
||||
# Runtime Activities API
|
||||
|
||||
Ripster verfolgt alle laufenden und kürzlich abgeschlossenen Aktivitäten (Skripte, Skript-Ketten, Cron-Jobs, interne Tasks) in Echtzeit über den `RuntimeActivityService`.
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
Aktivitäten entstehen, wenn Ripster intern Aktionen ausführt – z. B. beim Start eines Cron-Jobs, beim Ausführen einer Skript-Kette oder beim Durchlaufen von Pipeline-Schritten. Sie sind **nicht persistent** (kein DB-Speicher) und werden nur im Arbeitsspeicher gehalten.
|
||||
|
||||
- **Aktive Aktivitäten** (`active`): Laufen gerade.
|
||||
- **Letzte Aktivitäten** (`recent`): Abgeschlossen, max. 120 Einträge.
|
||||
|
||||
Änderungen werden über WebSocket (`RUNTIME_ACTIVITY_CHANGED`) in Echtzeit gesendet.
|
||||
|
||||
---
|
||||
|
||||
## Aktivitäts-Objekt
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 7,
|
||||
"type": "chain",
|
||||
"name": "Post-Encode Aufräumen",
|
||||
"status": "running",
|
||||
"source": "cron",
|
||||
"message": "Schritt 2 von 3",
|
||||
"currentStep": "cleanup.sh",
|
||||
"currentStepType": "script",
|
||||
"currentScriptName": "cleanup.sh",
|
||||
"stepIndex": 2,
|
||||
"stepTotal": 3,
|
||||
"parentActivityId": null,
|
||||
"jobId": 42,
|
||||
"cronJobId": 3,
|
||||
"chainId": 5,
|
||||
"scriptId": null,
|
||||
"canCancel": true,
|
||||
"canNextStep": false,
|
||||
"outcome": "running",
|
||||
"errorMessage": null,
|
||||
"output": null,
|
||||
"stdout": null,
|
||||
"stderr": null,
|
||||
"stdoutTruncated": false,
|
||||
"stderrTruncated": false,
|
||||
"startedAt": "2026-03-10T10:00:00.000Z",
|
||||
"finishedAt": null,
|
||||
"durationMs": null,
|
||||
"exitCode": null,
|
||||
"success": null
|
||||
}
|
||||
```
|
||||
|
||||
### Felder
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|------|-----|--------------|
|
||||
| `id` | `number` | Eindeutige ID (Laufzähler, nicht persistent) |
|
||||
| `type` | `string` | Art der Aktivität: `script` \| `chain` \| `cron` \| `task` |
|
||||
| `name` | `string \| null` | Anzeigename der Aktivität |
|
||||
| `status` | `string` | Aktueller Status: `running` \| `success` \| `error` |
|
||||
| `source` | `string \| null` | Auslöser (z. B. `cron`, `pipeline`, `manual`) |
|
||||
| `message` | `string \| null` | Kurztext zum aktuellen Zustand |
|
||||
| `currentStep` | `string \| null` | Name des aktuell ausgeführten Schritts |
|
||||
| `currentStepType` | `string \| null` | Typ des Schritts (z. B. `script`, `wait`) |
|
||||
| `currentScriptName` | `string \| null` | Name des Skripts im aktuellen Schritt |
|
||||
| `stepIndex` | `number \| null` | Aktueller Schritt (1-basiert) |
|
||||
| `stepTotal` | `number \| null` | Gesamtanzahl Schritte |
|
||||
| `parentActivityId` | `number \| null` | ID der übergeordneten Aktivität |
|
||||
| `jobId` | `number \| null` | Verknüpfte Job-ID |
|
||||
| `cronJobId` | `number \| null` | Verknüpfte Cron-Job-ID |
|
||||
| `chainId` | `number \| null` | Verknüpfte Skript-Ketten-ID |
|
||||
| `scriptId` | `number \| null` | Verknüpfte Skript-ID |
|
||||
| `canCancel` | `boolean` | Abbrechen über API möglich |
|
||||
| `canNextStep` | `boolean` | Nächster Schritt über API auslösbar |
|
||||
| `outcome` | `string \| null` | Abschluss-Ergebnis: `success` \| `error` \| `cancelled` \| `skipped` \| `running` |
|
||||
| `errorMessage` | `string \| null` | Fehlermeldung (max. 2.000 Zeichen) |
|
||||
| `output` | `string \| null` | Allgemeine Ausgabe (max. 12.000 Zeichen) |
|
||||
| `stdout` | `string \| null` | Standardausgabe des Prozesses (max. 12.000 Zeichen) |
|
||||
| `stderr` | `string \| null` | Fehlerausgabe des Prozesses (max. 12.000 Zeichen) |
|
||||
| `stdoutTruncated` | `boolean` | `true`, wenn `stdout` gekürzt wurde |
|
||||
| `stderrTruncated` | `boolean` | `true`, wenn `stderr` gekürzt wurde |
|
||||
| `startedAt` | `string` | ISO-8601-Zeitstempel des Starts |
|
||||
| `finishedAt` | `string \| null` | ISO-8601-Zeitstempel des Endes |
|
||||
| `durationMs` | `number \| null` | Laufzeit in Millisekunden |
|
||||
| `exitCode` | `number \| null` | Exit-Code des Prozesses |
|
||||
| `success` | `boolean \| null` | Erfolgsstatus (`null` bei laufender Aktivität) |
|
||||
|
||||
---
|
||||
|
||||
## Snapshot-Objekt
|
||||
|
||||
Alle Aktivitäts-Endpunkte geben einen Snapshot zurück:
|
||||
|
||||
```json
|
||||
{
|
||||
"active": [ /* laufende Aktivitäten, nach startedAt absteigend */ ],
|
||||
"recent": [ /* abgeschlossene Aktivitäten, nach finishedAt absteigend, max. 120 */ ],
|
||||
"updatedAt": "2026-03-10T10:05:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpunkte
|
||||
|
||||
### GET `/api/runtime/activities`
|
||||
|
||||
Aktuellen Aktivitäts-Snapshot abrufen.
|
||||
|
||||
**Antwort:**
|
||||
|
||||
```json
|
||||
{
|
||||
"active": [],
|
||||
"recent": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "script",
|
||||
"name": "notify.sh",
|
||||
"status": "success",
|
||||
"outcome": "success",
|
||||
"startedAt": "2026-03-10T09:58:00.000Z",
|
||||
"finishedAt": "2026-03-10T09:58:02.000Z",
|
||||
"durationMs": 2100,
|
||||
"exitCode": 0,
|
||||
"success": true,
|
||||
"canCancel": false,
|
||||
"canNextStep": false
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-03-10T10:05:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/runtime/activities/:id/cancel`
|
||||
|
||||
Aktive Aktivität abbrechen (nur wenn `canCancel: true`).
|
||||
|
||||
**Parameter:**
|
||||
|
||||
| Name | In | Typ | Beschreibung |
|
||||
|------|----|-----|--------------|
|
||||
| `id` | path | `number` | Aktivitäts-ID |
|
||||
| `reason` | body | `string` | Optionaler Abbruchgrund |
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{ "reason": "Manueller Abbruch durch Benutzer" }
|
||||
```
|
||||
|
||||
**Antwort (Erfolg):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"action": null,
|
||||
"snapshot": { "active": [], "recent": [], "updatedAt": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
**Fehlercodes:**
|
||||
|
||||
| HTTP | Bedeutung |
|
||||
|------|-----------|
|
||||
| `404` | Aktivität nicht gefunden oder bereits abgeschlossen |
|
||||
| `409` | Abbrechen wird von dieser Aktivität nicht unterstützt |
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/runtime/activities/:id/next-step`
|
||||
|
||||
Nächsten Schritt einer Aktivität auslösen (nur wenn `canNextStep: true`).
|
||||
|
||||
**Parameter:**
|
||||
|
||||
| Name | In | Typ | Beschreibung |
|
||||
|------|----|-----|--------------|
|
||||
| `id` | path | `number` | Aktivitäts-ID |
|
||||
|
||||
**Antwort (Erfolg):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"action": null,
|
||||
"snapshot": { "active": [], "recent": [], "updatedAt": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
**Fehlercodes:**
|
||||
|
||||
| HTTP | Bedeutung |
|
||||
|------|-----------|
|
||||
| `404` | Aktivität nicht gefunden |
|
||||
| `409` | Nächster Schritt wird von dieser Aktivität nicht unterstützt |
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/runtime/activities/clear-recent`
|
||||
|
||||
Alle abgeschlossenen Aktivitäten aus `recent` löschen.
|
||||
|
||||
**Antwort:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"removed": 14,
|
||||
"snapshot": { "active": [], "recent": [], "updatedAt": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Grenzwerte
|
||||
|
||||
| Wert | Limit |
|
||||
|------|-------|
|
||||
| Maximale `recent`-Einträge | 120 |
|
||||
| Maximale Länge `stdout` / `stderr` / `output` | 12.000 Zeichen |
|
||||
| Maximale Länge `errorMessage` / `message` | 2.000 Zeichen |
|
||||
| Maximale Länge `outcome` | 40 Zeichen |
|
||||
|
||||
Gekürzte Ausgaben erhalten den Suffix ` ...[gekürzt]` (bei Inline-Text) bzw. `\n...[gekürzt]` (bei mehrzeiligem Output).
|
||||
|
||||
---
|
||||
|
||||
## Echtzeit-Updates
|
||||
|
||||
Änderungen werden automatisch als [`RUNTIME_ACTIVITY_CHANGED`](websocket.md#runtime_activity_changed) WebSocket-Event gesendet. Die Frontend-Komponente braucht `GET /api/runtime/activities` nur beim initialen Laden aufzurufen.
|
||||
@@ -0,0 +1,424 @@
|
||||
# Settings API
|
||||
|
||||
Endpunkte für Einstellungen, Skripte, Skript-Ketten und User-Presets.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/settings
|
||||
|
||||
Liefert alle Einstellungen kategorisiert.
|
||||
|
||||
**Response (Struktur):**
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"category": "Pfade",
|
||||
"settings": [
|
||||
{
|
||||
"key": "raw_dir",
|
||||
"label": "Raw Ausgabeordner",
|
||||
"type": "path",
|
||||
"required": true,
|
||||
"description": "...",
|
||||
"defaultValue": "data/output/raw",
|
||||
"options": [],
|
||||
"validation": { "minLength": 1 },
|
||||
"value": "data/output/raw",
|
||||
"orderIndex": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/settings/:key
|
||||
|
||||
Aktualisiert eine einzelne Einstellung.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "value": "/mnt/storage/raw" }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"setting": {
|
||||
"key": "raw_dir",
|
||||
"value": "/mnt/storage/raw"
|
||||
},
|
||||
"reviewRefresh": {
|
||||
"triggered": false,
|
||||
"reason": "not_ready"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`reviewRefresh` ist `null` oder ein Objekt mit Status der optionalen Review-Neuberechnung.
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/settings
|
||||
|
||||
Aktualisiert mehrere Einstellungen atomar.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"raw_dir": "/mnt/storage/raw",
|
||||
"movie_dir": "/mnt/storage/movies",
|
||||
"handbrake_preset_bluray": "H.264 MKV 1080p30"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"changes": [
|
||||
{ "key": "raw_dir", "value": "/mnt/storage/raw" },
|
||||
{ "key": "movie_dir", "value": "/mnt/storage/movies" }
|
||||
],
|
||||
"reviewRefresh": {
|
||||
"triggered": true,
|
||||
"jobId": 42,
|
||||
"relevantKeys": ["handbrake_preset_bluray"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bei Validierungsfehlern kommt `400` mit `error.details[]`.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/settings/handbrake-presets
|
||||
|
||||
Liest Preset-Liste via `HandBrakeCLI -z` (mit Fallback auf konfigurierte Presets).
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "handbrake-cli",
|
||||
"message": null,
|
||||
"options": [
|
||||
{ "label": "General/", "value": "__group__general", "disabled": true, "category": "General" },
|
||||
{ "label": " Fast 1080p30", "value": "Fast 1080p30", "category": "General" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/settings/pushover/test
|
||||
|
||||
Sendet Testnachricht über aktuelle PushOver-Settings.
|
||||
|
||||
**Request (optional):**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Test",
|
||||
"message": "Ripster Test"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"sent": true,
|
||||
"eventKey": "test",
|
||||
"requestId": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wenn PushOver deaktiviert ist oder Credentials fehlen, kommt i. d. R. ebenfalls `200` mit `sent: false` + `reason`.
|
||||
|
||||
---
|
||||
|
||||
## Skripte
|
||||
|
||||
Basis: `/api/settings/scripts`
|
||||
|
||||
### GET /api/settings/scripts
|
||||
|
||||
```json
|
||||
{ "scripts": [ { "id": 1, "name": "...", "scriptBody": "...", "orderIndex": 1, "createdAt": "...", "updatedAt": "..." } ] }
|
||||
```
|
||||
|
||||
### POST /api/settings/scripts
|
||||
|
||||
```json
|
||||
{ "name": "Move", "scriptBody": "mv \"$RIPSTER_OUTPUT_PATH\" /mnt/movies/" }
|
||||
```
|
||||
|
||||
Response: `201` mit `{ "script": { ... } }`
|
||||
|
||||
### PUT /api/settings/scripts/:id
|
||||
|
||||
Body wie `POST`, Response `{ "script": { ... } }`.
|
||||
|
||||
### DELETE /api/settings/scripts/:id
|
||||
|
||||
Response `{ "removed": { ... } }`.
|
||||
|
||||
### POST /api/settings/scripts/reorder
|
||||
|
||||
```json
|
||||
{ "orderedScriptIds": [3, 1, 2] }
|
||||
```
|
||||
|
||||
Response `{ "scripts": [ ... ] }`.
|
||||
|
||||
### POST /api/settings/scripts/:id/test
|
||||
|
||||
Führt Skript als Testlauf aus.
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"scriptId": 1,
|
||||
"scriptName": "Move",
|
||||
"success": true,
|
||||
"exitCode": 0,
|
||||
"signal": null,
|
||||
"timedOut": false,
|
||||
"durationMs": 120,
|
||||
"stdout": "...",
|
||||
"stderr": "...",
|
||||
"stdoutTruncated": false,
|
||||
"stderrTruncated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Umgebungsvariablen für Skripte
|
||||
|
||||
Diese Variablen werden beim Ausführen gesetzt:
|
||||
|
||||
- `RIPSTER_SCRIPT_RUN_AT`
|
||||
- `RIPSTER_JOB_ID`
|
||||
- `RIPSTER_JOB_TITLE`
|
||||
- `RIPSTER_MODE`
|
||||
- `RIPSTER_INPUT_PATH`
|
||||
- `RIPSTER_OUTPUT_PATH`
|
||||
- `RIPSTER_RAW_PATH`
|
||||
- `RIPSTER_SCRIPT_ID`
|
||||
- `RIPSTER_SCRIPT_NAME`
|
||||
- `RIPSTER_SCRIPT_SOURCE`
|
||||
|
||||
---
|
||||
|
||||
## Skript-Ketten
|
||||
|
||||
Basis: `/api/settings/script-chains`
|
||||
|
||||
Eine Kette hat Schritte vom Typ:
|
||||
|
||||
- `script` (`scriptId` erforderlich)
|
||||
- `wait` (`waitSeconds` 1..3600)
|
||||
|
||||
### GET /api/settings/script-chains
|
||||
|
||||
Response `{ "chains": [ ... ] }` (inkl. `steps[]`).
|
||||
|
||||
### GET /api/settings/script-chains/:id
|
||||
|
||||
Response `{ "chain": { ... } }`.
|
||||
|
||||
### POST /api/settings/script-chains
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "After Encode",
|
||||
"steps": [
|
||||
{ "stepType": "script", "scriptId": 1 },
|
||||
{ "stepType": "wait", "waitSeconds": 15 },
|
||||
{ "stepType": "script", "scriptId": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response: `201` mit `{ "chain": { ... } }`
|
||||
|
||||
### PUT /api/settings/script-chains/:id
|
||||
|
||||
Body wie `POST`, Response `{ "chain": { ... } }`.
|
||||
|
||||
### DELETE /api/settings/script-chains/:id
|
||||
|
||||
Response `{ "removed": { ... } }`.
|
||||
|
||||
### POST /api/settings/script-chains/reorder
|
||||
|
||||
```json
|
||||
{ "orderedChainIds": [2, 1, 3] }
|
||||
```
|
||||
|
||||
Response `{ "chains": [ ... ] }`.
|
||||
|
||||
### POST /api/settings/script-chains/:id/test
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"chainId": 2,
|
||||
"chainName": "After Encode",
|
||||
"steps": 3,
|
||||
"succeeded": 3,
|
||||
"failed": 0,
|
||||
"aborted": false,
|
||||
"results": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User-Presets
|
||||
|
||||
Basis: `/api/settings/user-presets`
|
||||
|
||||
### GET /api/settings/user-presets
|
||||
|
||||
Optionaler Query-Parameter: `media_type=bluray|dvd|other|all`
|
||||
|
||||
```json
|
||||
{
|
||||
"presets": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Blu-ray HQ",
|
||||
"mediaType": "bluray",
|
||||
"handbrakePreset": "H.264 MKV 1080p30",
|
||||
"extraArgs": "--encoder-preset slow",
|
||||
"description": "...",
|
||||
"createdAt": "...",
|
||||
"updatedAt": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/settings/user-presets
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Blu-ray HQ",
|
||||
"mediaType": "bluray",
|
||||
"handbrakePreset": "H.264 MKV 1080p30",
|
||||
"extraArgs": "--encoder-preset slow",
|
||||
"description": "optional"
|
||||
}
|
||||
```
|
||||
|
||||
Response: `201` mit `{ "preset": { ... } }`
|
||||
|
||||
### PUT /api/settings/user-presets/:id
|
||||
|
||||
Body mit beliebigen Feldern aus `POST`, Response `{ "preset": { ... } }`.
|
||||
|
||||
### DELETE /api/settings/user-presets/:id
|
||||
|
||||
Response `{ "removed": { ... } }`.
|
||||
|
||||
---
|
||||
|
||||
## Weitere Endpunkte
|
||||
|
||||
### GET /api/settings/effective-paths
|
||||
|
||||
Liefert aufgelöste, effektiv genutzte Pfade aus den Settings (für UI- und Diagnosezwecke).
|
||||
|
||||
### POST /api/settings/coverart/recover
|
||||
|
||||
Startet eine manuelle Coverart-Recovery.
|
||||
|
||||
**Response (Beispiel):**
|
||||
|
||||
```json
|
||||
{
|
||||
"result": { "started": true },
|
||||
"scheduler": { "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Activation Bytes (AAX)
|
||||
|
||||
### GET /api/settings/activation-bytes
|
||||
|
||||
Liest gecachte AAX-Activation-Bytes.
|
||||
|
||||
### POST /api/settings/activation-bytes
|
||||
|
||||
Speichert Activation-Bytes.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"checksum": "abc123...",
|
||||
"activationBytes": "1a2b3c4d"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Laufwerke
|
||||
|
||||
### GET /api/settings/drives
|
||||
|
||||
Liefert erkannte optische Laufwerke (`/dev/...`) inkl. MakeMKV-Disc-Index.
|
||||
|
||||
### POST /api/settings/drives/force-unlock
|
||||
|
||||
Erzwingt Entsperren aktiver Laufwerkslocks.
|
||||
|
||||
Hinweis: Expertenmodus (`ui_expert_mode`) ist erforderlich, sonst `403`.
|
||||
|
||||
**Request (ein Laufwerk):**
|
||||
|
||||
```json
|
||||
{ "devicePath": "/dev/sr0" }
|
||||
```
|
||||
|
||||
**Request (alle):**
|
||||
|
||||
```json
|
||||
{ "all": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Preferences
|
||||
|
||||
### GET /api/settings/prefs/:key
|
||||
|
||||
Liest einen persistierten UI-Preference-Wert.
|
||||
|
||||
### PUT /api/settings/prefs/:key
|
||||
|
||||
Speichert einen persistierten UI-Preference-Wert.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "value": "{\"columns\":[\"id\",\"status\"]}" }
|
||||
```
|
||||
@@ -0,0 +1,294 @@
|
||||
# WebSocket Events
|
||||
|
||||
Ripster sendet Echtzeit-Updates über `/ws`.
|
||||
|
||||
---
|
||||
|
||||
## Verbindung
|
||||
|
||||
```js
|
||||
const ws = new WebSocket('ws://localhost:3001/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
console.log(msg.type, msg.payload);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nachrichtenformat
|
||||
|
||||
Die meisten Broadcasts haben dieses Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "EVENT_TYPE",
|
||||
"payload": {},
|
||||
"timestamp": "2026-03-10T09:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Ausnahme: `WS_CONNECTED` beim Verbindungsaufbau enthält kein `timestamp`.
|
||||
|
||||
---
|
||||
|
||||
## Event-Typen
|
||||
|
||||
### WS_CONNECTED
|
||||
|
||||
Sofort nach erfolgreicher Verbindung.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "WS_CONNECTED",
|
||||
"payload": {
|
||||
"connectedAt": "2026-03-10T09:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PIPELINE_STATE_CHANGED
|
||||
|
||||
Neuer Pipeline-Snapshot.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "PIPELINE_STATE_CHANGED",
|
||||
"payload": {
|
||||
"state": "ENCODING",
|
||||
"activeJobId": 42,
|
||||
"progress": 62.5,
|
||||
"eta": "00:12:34",
|
||||
"statusText": "ENCODING 62.50%",
|
||||
"context": {},
|
||||
"jobProgress": {
|
||||
"42": {
|
||||
"state": "ENCODING",
|
||||
"progress": 62.5,
|
||||
"eta": "00:12:34",
|
||||
"statusText": "ENCODING 62.50%"
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"maxParallelJobs": 1,
|
||||
"runningCount": 1,
|
||||
"queuedCount": 2,
|
||||
"runningJobs": [],
|
||||
"queuedJobs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PIPELINE_PROGRESS
|
||||
|
||||
Laufende Fortschrittsupdates.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "PIPELINE_PROGRESS",
|
||||
"payload": {
|
||||
"state": "ENCODING",
|
||||
"activeJobId": 42,
|
||||
"progress": 62.5,
|
||||
"eta": "00:12:34",
|
||||
"statusText": "ENCODING 62.50%"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PIPELINE_QUEUE_CHANGED
|
||||
|
||||
Queue-Snapshot aktualisiert.
|
||||
|
||||
### DISC_DETECTED / DISC_REMOVED
|
||||
|
||||
Disc-Insertion/-Removal.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "DISC_DETECTED",
|
||||
"payload": {
|
||||
"device": {
|
||||
"path": "/dev/sr0",
|
||||
"discLabel": "INCEPTION",
|
||||
"model": "ASUS BW-16D1HT",
|
||||
"fstype": "udf",
|
||||
"mountpoint": null,
|
||||
"mediaProfile": "bluray"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`mediaProfile`: `bluray` | `dvd` | `other` | `null`
|
||||
|
||||
### HARDWARE_MONITOR_UPDATE
|
||||
|
||||
Snapshot aus Hardware-Monitoring.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "HARDWARE_MONITOR_UPDATE",
|
||||
"payload": {
|
||||
"enabled": true,
|
||||
"intervalMs": 5000,
|
||||
"updatedAt": "2026-03-10T09:00:00.000Z",
|
||||
"sample": {
|
||||
"cpu": {},
|
||||
"memory": {},
|
||||
"gpu": {},
|
||||
"storage": {}
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PIPELINE_ERROR
|
||||
|
||||
Fehler bei Disc-Event-Verarbeitung in Pipeline.
|
||||
|
||||
### DISK_DETECTION_ERROR
|
||||
|
||||
Fehler in Laufwerkserkennung.
|
||||
|
||||
### SETTINGS_UPDATED
|
||||
|
||||
Einzelnes Setting wurde gespeichert.
|
||||
|
||||
### SETTINGS_BULK_UPDATED
|
||||
|
||||
Bulk-Settings gespeichert.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SETTINGS_BULK_UPDATED",
|
||||
"payload": {
|
||||
"count": 3,
|
||||
"keys": ["raw_dir", "movie_dir", "handbrake_preset_bluray"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SETTINGS_SCRIPTS_UPDATED
|
||||
|
||||
Skript geändert (`created|updated|deleted|reordered`).
|
||||
|
||||
### SETTINGS_SCRIPT_CHAINS_UPDATED
|
||||
|
||||
Skript-Kette geändert (`created|updated|deleted|reordered`).
|
||||
|
||||
### USER_PRESETS_UPDATED
|
||||
|
||||
User-Preset geändert (`created|updated|deleted`).
|
||||
|
||||
### CRON_JOBS_UPDATED
|
||||
|
||||
Cron-Config geändert (`created|updated|deleted`).
|
||||
|
||||
### CRON_JOB_UPDATED
|
||||
|
||||
Laufzeitstatus eines Cron-Jobs geändert.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "CRON_JOB_UPDATED",
|
||||
"payload": {
|
||||
"id": 1,
|
||||
"lastRunStatus": "running",
|
||||
"lastRunAt": "2026-03-10T10:00:00.000Z",
|
||||
"nextRunAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### RUNTIME_ACTIVITY_CHANGED
|
||||
|
||||
Vollständiger Snapshot aller laufenden und kürzlich abgeschlossenen Aktivitäten.
|
||||
|
||||
Wird ausgelöst, wenn eine Aktivität gestartet, aktualisiert oder abgeschlossen wird sowie nach `clear-recent`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "RUNTIME_ACTIVITY_CHANGED",
|
||||
"payload": {
|
||||
"active": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "chain",
|
||||
"name": "Post-Encode Aufräumen",
|
||||
"status": "running",
|
||||
"source": "cron",
|
||||
"message": "Schritt 2 von 3",
|
||||
"currentStep": "cleanup.sh",
|
||||
"currentStepType": "script",
|
||||
"stepIndex": 2,
|
||||
"stepTotal": 3,
|
||||
"canCancel": true,
|
||||
"canNextStep": false,
|
||||
"outcome": "running",
|
||||
"startedAt": "2026-03-10T10:00:00.000Z",
|
||||
"finishedAt": null,
|
||||
"durationMs": null
|
||||
}
|
||||
],
|
||||
"recent": [],
|
||||
"updatedAt": "2026-03-10T10:00:05.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Vollständige Feldbeschreibung: [Runtime Activities API](runtime-activities.md).
|
||||
|
||||
### CONVERTER_SCAN_UPDATE
|
||||
|
||||
Wird nach einem Scan des Converter-Eingangsordners gesendet (manuell oder per Polling).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "CONVERTER_SCAN_UPDATE",
|
||||
"payload": {
|
||||
"entryCount": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DOWNLOADS_UPDATED
|
||||
|
||||
Wird bei jeder Statusänderung in der Download-Queue gesendet.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "DOWNLOADS_UPDATED",
|
||||
"payload": {
|
||||
"reason": "ready",
|
||||
"item": {
|
||||
"id": "dl-1",
|
||||
"jobId": 42,
|
||||
"status": "ready",
|
||||
"archiveName": "Job_42_movie.zip",
|
||||
"createdAt": "2026-03-30T10:00:00.000Z"
|
||||
},
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"pending": 1,
|
||||
"processing": 0,
|
||||
"ready": 1,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mögliche `reason`-Werte: `queued`, `processing`, `ready`, `failed`, `deleted`.
|
||||
|
||||
---
|
||||
|
||||
## Reconnect-Verhalten
|
||||
|
||||
`useWebSocket` verbindet bei Abbruch automatisch neu:
|
||||
|
||||
- Retry-Intervall: `1500ms`
|
||||
- Wiederverbindung bis Komponente unmounted wird
|
||||
@@ -0,0 +1,30 @@
|
||||
# Technischer Anhang
|
||||
|
||||
Dieser Bereich enthält die technische Referenz hinter dem Benutzerhandbuch.
|
||||
|
||||
## Inhalt
|
||||
|
||||
- **Konfiguration**
|
||||
- komplette Feldreferenz
|
||||
- Umgebungsvariablen
|
||||
- **Pipeline intern**
|
||||
- Zustandsmodell
|
||||
- Encode-Planung
|
||||
- Playlist-Analyse
|
||||
- Pre-/Post-Encode-Ausführungen
|
||||
- **API-Referenz**
|
||||
- REST-Endpunkte
|
||||
- WebSocket-Events
|
||||
- **Architektur**
|
||||
- Backend-/Frontend-Aufbau
|
||||
- Datenbank
|
||||
- **Deployment**
|
||||
- Betrieb in Entwicklung und Produktion
|
||||
- **Externe Tools**
|
||||
- MakeMKV, HandBrake, MediaInfo
|
||||
|
||||
## Wann du in den Anhang wechselst
|
||||
|
||||
- du integrierst Ripster mit anderen Systemen
|
||||
- du betreibst mehrere Instanzen oder willst tiefer debuggen
|
||||
- du brauchst Feld-/API-/Schema-Details für Automatisierung
|
||||
@@ -0,0 +1,241 @@
|
||||
# Backend-Services
|
||||
|
||||
Das Backend ist in Services aufgeteilt, die von Express-Routen orchestriert werden. Seit v0.12.0 erfolgt die Medienverarbeitung über ein **Plugin-System**.
|
||||
|
||||
---
|
||||
|
||||
## Plugin-System (`src/plugins/`)
|
||||
|
||||
Ab v0.12.0 ist die Pipeline modular aufgebaut. Jeder Medientyp wird von einem eigenen Plugin verwaltet.
|
||||
|
||||
### Basisklassen
|
||||
|
||||
- **`PluginBase.js`** — Abstrakte Basisklasse `SourcePlugin` mit Lifecycle-Methoden
|
||||
- **`PluginRegistry.js`** — Dynamische Plugin-Erkennung, prioritätsbasierte Auswahl, Settings-Schema-Aggregation
|
||||
- **`PluginContext.js`** — Ausführungskontext (Logger, Callbacks, Signals) für Plugin-Aufrufe
|
||||
|
||||
### Plugin-Lifecycle
|
||||
|
||||
Jedes Plugin implementiert die folgenden Methoden:
|
||||
|
||||
| Methode | Beschreibung |
|
||||
|---|---|
|
||||
| `detect(discInfo)` | Medientyp-Erkennung |
|
||||
| `analyze(devicePath, job, ctx)` | Analyse / Metadaten-Extraktion |
|
||||
| `rip(job, ctx)` | Rohdaten-Extraktion |
|
||||
| `review(job, ctx)` | Optionale Vorschau-Vorbereitung |
|
||||
| `encode(job, ctx)` | Finales Encoding/Konvertierung |
|
||||
| `finalize(job, ctx)` | Nachbearbeitung |
|
||||
| `onCancel(job, ctx)` | Bereinigung bei Abbruch |
|
||||
| `onRetry(job, ctx)` | Zustand-Reset vor Retry |
|
||||
|
||||
### Implementierte Plugins
|
||||
|
||||
- **`BluRayPlugin.js`** — Blu-ray via MakeMKV (Backup-Modus)
|
||||
- **`DVDPlugin.js`** — DVD via MakeMKV (MKV-Modus)
|
||||
- **`CdPlugin.js`** — Audio-CD via cdparanoia + FFmpeg (experimentell)
|
||||
- **`AudiobookPlugin.js`** — AAX/M4B via FFmpeg mit Kapitel-Splitting
|
||||
- **`ConverterPlugin.js`** — Generische Audio/Video-Konvertierung via FFmpeg
|
||||
- **`VideoDiscPlugin.js`** — Gemeinsame Basis für Blu-ray/DVD (MediaInfo-Parsing)
|
||||
|
||||
---
|
||||
|
||||
## `pipelineService.js`
|
||||
|
||||
Zentrale Workflow-Orchestrierung.
|
||||
|
||||
Aufgaben:
|
||||
|
||||
- Pipeline-State-Machine + Persistenz (`pipeline_state`)
|
||||
- Disc-Analyse/Rip/Review/Encode via Plugin-Delegation
|
||||
- Queue-Management (Jobs + `script|chain|wait` Einträge)
|
||||
- Retry/Re-Encode/Restart-Flows
|
||||
- WebSocket-Broadcasts für State/Progress/Queue
|
||||
- Converter-Job-Verwaltung (`createConverterJobFromEntry`, `uploadConverterFiles`, `startConverterJob`)
|
||||
|
||||
Wichtige Methoden:
|
||||
|
||||
- `analyzeDisc()`
|
||||
- `selectMetadata()`
|
||||
- `startPreparedJob()`
|
||||
- `confirmEncodeReview()`
|
||||
- `cancel()`
|
||||
- `retry()`
|
||||
- `reencodeFromRaw()`
|
||||
- `restartReviewFromRaw()`
|
||||
- `restartEncodeWithLastSettings()`
|
||||
- `resumeReadyToEncodeJob()`
|
||||
- `enqueueNonJobEntry()`, `reorderQueue()`, `removeQueueEntry()`
|
||||
- `createConverterJobFromEntry()`, `createConverterJobsFromSelection()`
|
||||
- `uploadConverterFiles()`, `startConverterJob()`
|
||||
|
||||
---
|
||||
|
||||
## `diskDetectionService.js`
|
||||
|
||||
Pollt Laufwerk(e) und emittiert:
|
||||
|
||||
- `discInserted`
|
||||
- `discRemoved`
|
||||
- `error`
|
||||
|
||||
Zusatz:
|
||||
|
||||
- Modus `auto` oder `explicit`
|
||||
- heuristische `mediaProfile`-Erkennung (`bluray`/`dvd`/`other`)
|
||||
- `rescanAndEmit()` für manuellen Trigger
|
||||
|
||||
---
|
||||
|
||||
## `settingsService.js`
|
||||
|
||||
Settings-Layer mit Validation/Serialisierung.
|
||||
|
||||
Features:
|
||||
|
||||
- `getCategorizedSettings()` für UI-Form
|
||||
- `setSettingValue()` / `setSettingsBulk()`
|
||||
- profilspezifische Auflösung (`resolveEffectiveToolSettings`)
|
||||
- CLI-Config-Building für MakeMKV/HandBrake/MediaInfo/FFmpeg
|
||||
- HandBrake-Preset-Liste via `HandBrakeCLI -z`
|
||||
- MakeMKV-Key-Synchronisation aus `makemkv_registration_key` nach `~/.MakeMKV/settings.conf`
|
||||
- Pfadauflösung für alle Medienprofile inkl. Audiobook und Converter
|
||||
|
||||
---
|
||||
|
||||
## `historyService.js`
|
||||
|
||||
Historie + Dateioperationen.
|
||||
|
||||
Features:
|
||||
|
||||
- Job-Liste/Detail inkl. Log-Tail
|
||||
- Orphan-RAW-Erkennung und Import
|
||||
- TMDb-Neuzuordnung
|
||||
- Dateilöschung (`raw|movie|both`)
|
||||
- Job-Löschung (`none|raw|movie|both`)
|
||||
|
||||
---
|
||||
|
||||
## `audiobookService.js`
|
||||
|
||||
Audiobook-Verarbeitung (AAX/M4B).
|
||||
|
||||
Features:
|
||||
|
||||
- FFprobe-basierte Analyse (Kapitel, Metadaten)
|
||||
- FFmpeg-basiertes Encoding mit Kapitel-Splitting
|
||||
- Ausgabeformate: M4B, MP3, FLAC
|
||||
- Template-basierte Pfade (`{author}`, `{title}`, `{year}`, `{narrator}`, `{series}`, `{part}`)
|
||||
|
||||
---
|
||||
|
||||
## `activationBytesService.js`
|
||||
|
||||
Verwaltung von Audible-Activation-Bytes für AAX-DRM-Handling.
|
||||
|
||||
Features:
|
||||
|
||||
- SHA-1-Prüfsumme der AAX-Datei berechnen
|
||||
- Activation Bytes in `aax_activation_bytes`-Tabelle cachen
|
||||
- Lookup via `audnexService` (wenn konfiguriert)
|
||||
|
||||
---
|
||||
|
||||
## `converterScanService.js`
|
||||
|
||||
Dateisystem-Überwachung des Converter-Eingangsordners.
|
||||
|
||||
Features:
|
||||
|
||||
- Vollständiger FS-Baum (`getTree()`)
|
||||
- DB-basierter Datei-Explorer (`getEntries()`)
|
||||
- Manueller und automatischer Scan (Polling)
|
||||
- Pfad-Normalisierung mit Traversal-Schutz
|
||||
- WebSocket-Broadcast: `CONVERTER_SCAN_UPDATE`
|
||||
|
||||
---
|
||||
|
||||
## `downloadService.js`
|
||||
|
||||
Download-Queue für Ausgabedateien aus der Historie.
|
||||
|
||||
Features:
|
||||
|
||||
- Job in Download-Queue einreihen (`enqueueHistoryJob`)
|
||||
- ZIP-Archiv aus Ausgabedateien erstellen (`archiver`)
|
||||
- Datei-Streaming via `res.download()`
|
||||
- WebSocket-Broadcast: `DOWNLOADS_UPDATED` bei Status-Änderungen
|
||||
- Download-Item löschen
|
||||
|
||||
---
|
||||
|
||||
## `cronService.js`
|
||||
|
||||
Integriertes Cron-System ohne externe Parser-Library.
|
||||
|
||||
Features:
|
||||
|
||||
- 5-Feld-Cron-Parser + `nextRun`-Berechnung
|
||||
- Quellen: `script` oder `chain`
|
||||
- Laufzeitlogs (`cron_run_logs`)
|
||||
- manuelles Triggern
|
||||
- WebSocket-Events: `CRON_JOBS_UPDATED`, `CRON_JOB_UPDATED`
|
||||
|
||||
---
|
||||
|
||||
## `runtimeActivityService.js`
|
||||
|
||||
In-Memory-Tracking aller laufenden und kürzlich abgeschlossenen Aktivitäten (Skripte, Ketten, Cron-Jobs, Tasks).
|
||||
|
||||
Features:
|
||||
|
||||
- `startActivity(type, payload)` → Aktivität registrieren, ID zurückgeben
|
||||
- `updateActivity(id, patch)` → Laufende Aktivität aktualisieren
|
||||
- `completeActivity(id, payload)` → Aktivität abschließen und in `recent` verschieben
|
||||
- `setControls(id, { cancel, nextStep })` → Steuer-Handler registrieren (für `canCancel`/`canNextStep`)
|
||||
- `requestCancel(id)` / `requestNextStep(id)` → Steuer-Handler aufrufen
|
||||
- `clearRecent()` → Abgeschlossene Aktivitäten löschen
|
||||
- `getSnapshot()` → Snapshot mit `active` + `recent` + `updatedAt`
|
||||
- Broadcasts `RUNTIME_ACTIVITY_CHANGED` über WebSocket bei jeder Änderung
|
||||
|
||||
Limits:
|
||||
|
||||
- `recent` max. 120 Einträge
|
||||
- `stdout`/`stderr`/`output` max. 12.000 Zeichen
|
||||
- `message`/`errorMessage` max. 2.000 Zeichen
|
||||
|
||||
Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activities.md)
|
||||
|
||||
---
|
||||
|
||||
## Weitere Services
|
||||
|
||||
- `scriptService.js` (CRUD + Test + Wrapper-Ausführung)
|
||||
- `scriptChainService.js` (CRUD + Step-Execution)
|
||||
- `userPresetService.js` (HandBrake User-Presets)
|
||||
- `hardwareMonitorService.js` (CPU/RAM/GPU/Storage)
|
||||
- `websocketService.js` (Client-Registry + Broadcast)
|
||||
- `notificationService.js` (PushOver)
|
||||
- `cdRipService.js` (CD-Ripping mit cdparanoia)
|
||||
- `musicBrainzService.js` (MusicBrainz-Metadaten-Lookup)
|
||||
- `audnexService.js` (Audnex-API für Audiobook-Metadaten)
|
||||
- `coverArtRecoveryService.js` (Cover-Art-Wiederherstellung)
|
||||
- `thumbnailService.js` (Vorschaubild-Generierung)
|
||||
- `tmdbService.js` (Film-/Serien-Metadaten-Suche)
|
||||
- `logger.js` (rotierende Datei-Logs)
|
||||
|
||||
---
|
||||
|
||||
## Bootstrapping (`src/index.js`)
|
||||
|
||||
Beim Start:
|
||||
|
||||
1. DB init/migrate
|
||||
2. Pipeline-Init (inkl. Plugin-Registry)
|
||||
3. Cron-Init
|
||||
4. Express-Routes + Error-Handler
|
||||
5. WebSocket-Server auf `/ws`
|
||||
6. Hardware-Monitoring-Init
|
||||
7. Disk-Detection-Start
|
||||
8. Converter-Scan-Service-Init (Polling falls aktiviert)
|
||||
@@ -0,0 +1,203 @@
|
||||
# Datenbank
|
||||
|
||||
Ripster verwendet SQLite (`backend/data/ripster.db`).
|
||||
|
||||
---
|
||||
|
||||
## Tabellen
|
||||
|
||||
```text
|
||||
settings_schema
|
||||
settings_values
|
||||
jobs
|
||||
job_lineage_artifacts
|
||||
job_output_folders
|
||||
pipeline_state
|
||||
scripts
|
||||
script_chains
|
||||
script_chain_steps
|
||||
user_presets
|
||||
cron_jobs
|
||||
cron_run_logs
|
||||
aax_activation_bytes
|
||||
user_prefs
|
||||
converter_scan_entries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `jobs`
|
||||
|
||||
Speichert Pipeline-Lifecycle und Artefakte pro Job.
|
||||
|
||||
Zentrale Felder:
|
||||
|
||||
- Metadaten: `title`, `year`, `imdb_id`, `poster_url`, `omdb_json`, `selected_from_omdb`
|
||||
- Laufzeit: `start_time`, `end_time`, `status`, `last_state`
|
||||
- Pfade: `raw_path`, `output_path`, `encode_input_path`
|
||||
- Tool-Ausgaben: `makemkv_info_json`, `handbrake_info_json`, `mediainfo_info_json`, `encode_plan_json`
|
||||
- Kontrolle: `encode_review_confirmed`, `rip_successful`, `error_message`
|
||||
- Medientyp: `media_type` (`bluray|dvd|cd|audiobook|converter`)
|
||||
- Job-Typ/Beziehung: `job_kind`, `parent_job_id`
|
||||
- Multipart-/Disc-Merkmale: `is_multipart_movie`, `disc_number`, `metadata_fingerprint`
|
||||
- Prüfsumme: `aax_checksum` (für AAX-Dateien)
|
||||
- Audit: `created_at`, `updated_at`
|
||||
|
||||
Typische `job_kind`-Werte:
|
||||
|
||||
- Standard: `bluray`, `dvd`, `cd`, `audiobook`, `converter_audio`, `converter_video`, `converter_iso`
|
||||
- Serien: `dvd_series_container`, `dvd_series_child`
|
||||
- Multipart Film: `multipart_movie_container`, `multipart_movie_child`
|
||||
|
||||
Wichtige Indizes:
|
||||
|
||||
- `idx_jobs_status`
|
||||
- `idx_jobs_parent_job_id`
|
||||
- `idx_jobs_job_kind`
|
||||
- `idx_jobs_disc_number`
|
||||
- `idx_jobs_metadata_fingerprint`
|
||||
|
||||
---
|
||||
|
||||
## `job_lineage_artifacts`
|
||||
|
||||
Verknüpft Jobs mit ihren Quell-Jobs (z. B. Re-Encode aus einem vorherigen Rip).
|
||||
|
||||
Felder:
|
||||
|
||||
- `job_id` — der abgeleitete Job
|
||||
- `source_job_id` — der Quell-Job
|
||||
- `artifact_type` — Art der Verknüpfung (z. B. `reencode`, `restart_review`)
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## `job_output_folders`
|
||||
|
||||
Verfolgt Ausgabepfade pro Job (mehrere Ausgaben möglich, z. B. bei Kapitel-Splitting).
|
||||
|
||||
Felder:
|
||||
|
||||
- `job_id`
|
||||
- `folder_path`
|
||||
- `label` — optionaler Bezeichner
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## `pipeline_state`
|
||||
|
||||
Singleton-Tabelle (`id = 1`) für aktiven Snapshot:
|
||||
|
||||
- `state`
|
||||
- `active_job_id`
|
||||
- `progress`
|
||||
- `eta`
|
||||
- `status_text`
|
||||
- `context_json`
|
||||
- `updated_at`
|
||||
|
||||
---
|
||||
|
||||
## `settings_schema` + `settings_values`
|
||||
|
||||
- `settings_schema`: Definition (Typ, Default, Validation, Reihenfolge)
|
||||
- `settings_values`: aktueller Wert pro Key
|
||||
|
||||
---
|
||||
|
||||
## `scripts`, `script_chains`, `script_chain_steps`
|
||||
|
||||
- `scripts`: Shell-Skripte (`name`, `script_body`, `order_index`)
|
||||
- `script_chains`: Ketten (`name`, `order_index`)
|
||||
- `script_chain_steps`: Schritte je Kette
|
||||
- `step_type`: `script` oder `wait`
|
||||
- `script_id` oder `wait_seconds`
|
||||
|
||||
---
|
||||
|
||||
## `user_presets`
|
||||
|
||||
Benannte HandBrake-Preset-Sets:
|
||||
|
||||
- `name`
|
||||
- `media_type` (`bluray|dvd|other|all`)
|
||||
- `handbrake_preset`
|
||||
- `extra_args`
|
||||
- `description`
|
||||
|
||||
---
|
||||
|
||||
## `cron_jobs` + `cron_run_logs`
|
||||
|
||||
- `cron_jobs`: Zeitplan + Status
|
||||
- `cron_run_logs`: einzelne Läufe
|
||||
- `status`: `running|success|error`
|
||||
- `output`
|
||||
- `error_message`
|
||||
|
||||
---
|
||||
|
||||
## `aax_activation_bytes`
|
||||
|
||||
Cache für Audible-Activation-Bytes (AAX-DRM).
|
||||
|
||||
Felder:
|
||||
|
||||
- `checksum` — SHA-1-Prüfsumme der AAX-Datei (Primärschlüssel)
|
||||
- `activation_bytes` — Hex-String der Activation Bytes
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## `user_prefs`
|
||||
|
||||
Benutzer-spezifische Einstellungen (Key-Value).
|
||||
|
||||
Felder:
|
||||
|
||||
- `key`
|
||||
- `value`
|
||||
- `updated_at`
|
||||
|
||||
---
|
||||
|
||||
## `converter_scan_entries`
|
||||
|
||||
DB-seitige Erfassung gescannter Dateien im Converter-Eingangsordner.
|
||||
|
||||
Felder:
|
||||
|
||||
- `rel_path` — relativer Pfad zum `converter_raw_dir`
|
||||
- `is_dir` — Verzeichnis-Flag
|
||||
- `size` — Dateigröße in Bytes
|
||||
- `modified_at` — Datei-Änderungsdatum
|
||||
- `job_id` — zugewiesener Converter-Job (falls vorhanden)
|
||||
- `scanned_at`
|
||||
|
||||
---
|
||||
|
||||
## Migration/Recovery
|
||||
|
||||
Beim Start werden Schema und Settings-Metadaten automatisch abgeglichen.
|
||||
|
||||
Bei korruptem SQLite-File:
|
||||
|
||||
1. Datei wird nach `backend/data/corrupt-backups/` verschoben
|
||||
2. neue DB wird initialisiert
|
||||
3. Schema wird neu aufgebaut
|
||||
|
||||
---
|
||||
|
||||
## Direkte Inspektion
|
||||
|
||||
```bash
|
||||
sqlite3 backend/data/ripster.db
|
||||
|
||||
.mode table
|
||||
SELECT id, status, title, media_type, created_at FROM jobs ORDER BY created_at DESC;
|
||||
SELECT id, parent_job_id, job_kind, is_multipart_movie, disc_number FROM jobs ORDER BY created_at DESC;
|
||||
SELECT key, value FROM settings_values ORDER BY key;
|
||||
SELECT checksum, activation_bytes FROM aax_activation_bytes;
|
||||
SELECT rel_path, job_id FROM converter_scan_entries;
|
||||
```
|
||||
@@ -0,0 +1,125 @@
|
||||
# Frontend-Komponenten
|
||||
|
||||
Frontend: React + PrimeReact + Vite.
|
||||
|
||||
---
|
||||
|
||||
## Hauptseiten
|
||||
|
||||
### `RipperPage.jsx`
|
||||
|
||||
Pipeline-Steuerung:
|
||||
|
||||
- Status/Progress/ETA
|
||||
- Metadaten-Dialog
|
||||
- Playlist-Entscheidung
|
||||
- Review-Panel (Track-Auswahl)
|
||||
- Queue-Interaktion (reorder/add/remove)
|
||||
- Job-Aktionen (Start/Cancel/Retry/Re-Encode)
|
||||
- Hardware-Monitoring-Anzeige
|
||||
- Aktivitäts-Tracking (Skripte, Ketten, Cron)
|
||||
|
||||
### `SettingsPage.jsx`
|
||||
|
||||
Konfiguration:
|
||||
|
||||
- dynamisches Settings-Formular (`DynamicSettingsForm`)
|
||||
- Skripte/Ketten inkl. Reorder/Test
|
||||
- User-Presets
|
||||
- Cron-Jobs (`CronJobsTab`)
|
||||
|
||||
### `HistoryPage.jsx`
|
||||
|
||||
Historie:
|
||||
|
||||
- Job-Liste/Filter
|
||||
- Job-Details + Logs
|
||||
- TMDb-Neuzuordnung
|
||||
- Re-Encode/Restart-Workflows
|
||||
|
||||
### `ConverterPage.jsx`
|
||||
|
||||
Datei-Converter:
|
||||
|
||||
- Datei-Explorer des Converter-Eingangsordners (Baum-Ansicht)
|
||||
- Upload von Audio/Video-Dateien (bis zu 50 Dateien gleichzeitig)
|
||||
- Dateiverwaltung: Umbenennen, Verschieben, Löschen, Ordner erstellen
|
||||
- Jobs aus Dateiauswahl erstellen
|
||||
- Converter-Job-Konfiguration (Ausgabeformat, Preset, Tracks)
|
||||
- Job-Start, Abbruch, Löschung
|
||||
- Automatischer Scan-Status
|
||||
|
||||
### `AudiobooksPage.jsx`
|
||||
|
||||
Dedizierter AAX-/Audiobook-Flow:
|
||||
|
||||
- Upload-Panel für AAX-Dateien
|
||||
- aktive Audiobook-Jobkarten mit Start/Cancel/Retry/Delete
|
||||
- Audiobook-Konfigurationspanel und Output-Explorer
|
||||
|
||||
### `DownloadsPage.jsx`
|
||||
|
||||
Download-Queue:
|
||||
|
||||
- Ausgabedateien aus der Job-Historie in die Queue einreihen
|
||||
- Download-Status verfolgen (ausstehend, wird verarbeitet, bereit, fehlgeschlagen)
|
||||
- Dateien als ZIP herunterladen
|
||||
- Download-Einträge löschen
|
||||
|
||||
### `DatabasePage.jsx`
|
||||
|
||||
Expert-Modus:
|
||||
|
||||
- Tabellarische Rohsicht der Job-Datenbank
|
||||
- Orphan-RAW-Import
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Komponenten
|
||||
|
||||
- `PipelineStatusCard.jsx` — Pipeline-Status-Anzeige mit Progress
|
||||
- `MetadataSelectionDialog.jsx` — TMDb-Metadaten-Auswahl
|
||||
- `MediaInfoReviewPanel.jsx` — Track-Auswahl-Interface (Video/Audio/Untertitel)
|
||||
- `JobDetailDialog.jsx` — Job-Detailansicht mit Logs
|
||||
- `CronJobsTab.jsx` — Cron-Job-Verwaltung
|
||||
- `DynamicSettingsForm.jsx` — Schema-gesteuertes Einstellungsformular
|
||||
- `ConverterJobCard.jsx` — Converter-Job-Darstellung
|
||||
- `CdRipConfigPanel.jsx` — Konfiguration für Audio-CD-Ripping
|
||||
|
||||
---
|
||||
|
||||
## API-Client (`api/client.js`)
|
||||
|
||||
- zentraler `request()` mit JSON-Handling
|
||||
- Fehlerobjekt aus API wird auf `Error(message)` gemappt
|
||||
- `VITE_API_BASE` default `/api`
|
||||
|
||||
---
|
||||
|
||||
## WebSocket (`hooks/useWebSocket.js`)
|
||||
|
||||
- URL: `VITE_WS_URL` oder automatisch `ws(s)://<host>/ws`
|
||||
- Auto-Reconnect mit 1500ms Intervall
|
||||
|
||||
In `App.jsx` werden u. a. verarbeitet:
|
||||
|
||||
- `PIPELINE_STATE_CHANGED`
|
||||
- `PIPELINE_PROGRESS`
|
||||
- `PIPELINE_QUEUE_CHANGED`
|
||||
- Disk erkannt / Disk entfernt
|
||||
- `HARDWARE_MONITOR_UPDATE`
|
||||
- `DOWNLOADS_UPDATED`
|
||||
- `CONVERTER_SCAN_UPDATE`
|
||||
- `RUNTIME_ACTIVITY_CHANGED`
|
||||
|
||||
---
|
||||
|
||||
## Build/Run
|
||||
|
||||
```bash
|
||||
# dev
|
||||
npm run dev --prefix frontend
|
||||
|
||||
# prod build
|
||||
npm run build --prefix frontend
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# Anhang: Architektur
|
||||
|
||||
Ripster ist eine Client-Server-Anwendung mit REST + WebSocket und externen CLI-Tools.
|
||||
|
||||
---
|
||||
|
||||
## Systemüberblick
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Browser["Browser (React)"]
|
||||
Ripper[Ripper]
|
||||
Settings[Einstellungen]
|
||||
History[Historie]
|
||||
end
|
||||
|
||||
subgraph Backend["Node.js Backend"]
|
||||
API[REST API\nExpress]
|
||||
WS[WebSocket\n/ws]
|
||||
Pipeline[pipelineService]
|
||||
Cron[cronService]
|
||||
DB[(SQLite)]
|
||||
end
|
||||
|
||||
subgraph Tools["Externe Tools"]
|
||||
MakeMKV[makemkvcon]
|
||||
HandBrake[HandBrakeCLI]
|
||||
MediaInfo[mediainfo]
|
||||
end
|
||||
|
||||
Browser <-->|HTTP| API
|
||||
Browser <-->|WebSocket| WS
|
||||
Pipeline --> MakeMKV
|
||||
Pipeline --> HandBrake
|
||||
Pipeline --> MediaInfo
|
||||
API --> DB
|
||||
Pipeline --> DB
|
||||
Cron --> DB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Details
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- [:octicons-arrow-right-24: Übersicht](overview.md)
|
||||
- [:octicons-arrow-right-24: Backend-Services](backend.md)
|
||||
- [:octicons-arrow-right-24: Frontend-Komponenten](frontend.md)
|
||||
- [:octicons-arrow-right-24: Datenbank](database.md)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
# Architektur-Übersicht
|
||||
|
||||
---
|
||||
|
||||
## Kernprinzipien
|
||||
|
||||
### Event-getriebene Pipeline
|
||||
|
||||
`pipelineService` hält einen Snapshot der State-Machine und broadcastet Änderungen sofort via WebSocket.
|
||||
|
||||
```text
|
||||
State-Änderung -> PIPELINE_STATE_CHANGED/PIPELINE_PROGRESS -> Frontend-Update
|
||||
```
|
||||
|
||||
### Service-Layer
|
||||
|
||||
```text
|
||||
Route -> Service -> DB/Tool-Execution
|
||||
```
|
||||
|
||||
Routes enthalten kaum Business-Logik.
|
||||
|
||||
### Schema-getriebene Settings
|
||||
|
||||
Settings sind DB-schema-getrieben (`settings_schema` + `settings_values`), UI rendert dynamisch aus diesen Daten.
|
||||
|
||||
---
|
||||
|
||||
## Echtzeit-Kommunikation
|
||||
|
||||
WebSocket läuft auf `/ws`.
|
||||
|
||||
Wichtige Events:
|
||||
|
||||
- `PIPELINE_STATE_CHANGED`, `PIPELINE_PROGRESS`, `PIPELINE_QUEUE_CHANGED`
|
||||
- Disk erkannt, Disk entfernt
|
||||
- `HARDWARE_MONITOR_UPDATE`
|
||||
- `SETTINGS_UPDATED`, `SETTINGS_BULK_UPDATED`
|
||||
- `SETTINGS_SCRIPTS_UPDATED`, `SETTINGS_SCRIPT_CHAINS_UPDATED`, `USER_PRESETS_UPDATED`
|
||||
- `CRON_JOBS_UPDATED`, `CRON_JOB_UPDATED`
|
||||
- `PIPELINE_ERROR`, `DISK_DETECTION_ERROR`
|
||||
|
||||
---
|
||||
|
||||
## Prozessausführung
|
||||
|
||||
Externe Tools werden als Child-Processes gestartet (`processRunner`):
|
||||
|
||||
- Streaming von stdout/stderr
|
||||
- Progress-Parsing (`progressParsers.js`)
|
||||
- kontrollierter Abbruch (SIGINT/SIGKILL-Fallback)
|
||||
|
||||
---
|
||||
|
||||
## Persistenz
|
||||
|
||||
SQLite-Datei: `backend/data/ripster.db`
|
||||
|
||||
Kern-Tabellen:
|
||||
|
||||
- `jobs`, `pipeline_state`
|
||||
- `settings_schema`, `settings_values`
|
||||
- `scripts`, `script_chains`, `script_chain_steps`
|
||||
- `user_presets`
|
||||
- `cron_jobs`, `cron_run_logs`
|
||||
|
||||
Beim Start werden Schema und Settings-Migrationen automatisch ausgeführt.
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Zentrales Error-Handling liefert:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "...",
|
||||
"statusCode": 400,
|
||||
"reqId": "...",
|
||||
"details": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fehlgeschlagene Jobs bleiben in der Historie (`Fehler` oder `Abgebrochen`) und können erneut gestartet werden.
|
||||
|
||||
---
|
||||
|
||||
## CORS & Runtime-Konfig
|
||||
|
||||
- `CORS_ORIGIN` default: `*`
|
||||
- `LOG_LEVEL` default: `info`
|
||||
- DB-/Log-Pfade über `DB_PATH`/`LOG_DIR` konfigurierbar
|
||||
@@ -0,0 +1,67 @@
|
||||
# Umgebungsvariablen
|
||||
|
||||
Umgebungsvariablen steuern Backend/Vite außerhalb der DB-basierten UI-Settings.
|
||||
|
||||
---
|
||||
|
||||
## Backend (`backend/.env`)
|
||||
|
||||
| Variable | Default (Code) | Beschreibung |
|
||||
|---------|------------------|-------------|
|
||||
| `PORT` | `3001` | Express-Port |
|
||||
| `DB_PATH` | `backend/data/ripster.db` | SQLite-Datei (relativ zu `backend/`) |
|
||||
| `LOG_DIR` | `backend/logs` | Fallback-Logverzeichnis (wenn `log_dir`-Setting nicht gesetzt/lesbar) |
|
||||
| `CORS_ORIGIN` | `*` | CORS-Origin für API |
|
||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||
|
||||
Beispiel:
|
||||
|
||||
```env
|
||||
PORT=3001
|
||||
DB_PATH=/var/lib/ripster/ripster.db
|
||||
LOG_DIR=/var/log/ripster
|
||||
CORS_ORIGIN=http://192.168.1.50:5173
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
Hinweis: `backend/.env.example` enthält bewusst dev-freundliche Werte (z. B. lokaler `CORS_ORIGIN`).
|
||||
|
||||
---
|
||||
|
||||
## Frontend (`frontend/.env`)
|
||||
|
||||
| Variable | Default | Beschreibung |
|
||||
|---------|---------|-------------|
|
||||
| `VITE_API_BASE` | `/api` | API-Basis für Fetch-Client |
|
||||
| `VITE_WS_URL` | automatisch aus `window.location` + `/ws` | Optional explizite WebSocket-URL |
|
||||
| `VITE_PUBLIC_ORIGIN` | leer | Öffentliche Vite-Origin (Remote-Dev) |
|
||||
| `VITE_ALLOWED_HOSTS` | `true` | Komma-separierte Hostliste für Vite `allowedHosts` |
|
||||
| `VITE_HMR_PROTOCOL` | abgeleitet aus `VITE_PUBLIC_ORIGIN` | HMR-Protokoll (`ws`/`wss`) |
|
||||
| `VITE_HMR_HOST` | abgeleitet aus `VITE_PUBLIC_ORIGIN` | HMR-Host |
|
||||
| `VITE_HMR_CLIENT_PORT` | abgeleitet aus `VITE_PUBLIC_ORIGIN` | HMR-Client-Port |
|
||||
|
||||
Beispiele:
|
||||
|
||||
```env
|
||||
# lokal (mit Vite-Proxy)
|
||||
VITE_API_BASE=/api
|
||||
```
|
||||
|
||||
```env
|
||||
# remote dev
|
||||
VITE_API_BASE=http://192.168.1.50:3001/api
|
||||
VITE_WS_URL=ws://192.168.1.50:3001/ws
|
||||
VITE_PUBLIC_ORIGIN=http://192.168.1.50:5173
|
||||
VITE_ALLOWED_HOSTS=192.168.1.50,ripster.local
|
||||
VITE_HMR_PROTOCOL=ws
|
||||
VITE_HMR_HOST=192.168.1.50
|
||||
VITE_HMR_CLIENT_PORT=5173
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Priorität
|
||||
|
||||
1. Prozess-Umgebungsvariablen
|
||||
2. `.env`
|
||||
3. Code-Defaults
|
||||
@@ -0,0 +1,29 @@
|
||||
# Anhang: Konfiguration
|
||||
|
||||
Dieser Abschnitt ist die technische Referenz zu allen Konfigurationsarten in Ripster.
|
||||
|
||||
## Inhalte
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-tune: **Einstellungsreferenz**
|
||||
|
||||
---
|
||||
|
||||
Vollständige Liste aller UI-Settings (Typ, Default, Hinweise).
|
||||
|
||||
[:octicons-arrow-right-24: Einstellungsreferenz](settings-reference.md)
|
||||
|
||||
- :material-variable: **Umgebungsvariablen**
|
||||
|
||||
---
|
||||
|
||||
`backend/.env` und `frontend/.env` inkl. Prioritäten.
|
||||
|
||||
[:octicons-arrow-right-24: Umgebungsvariablen](environment.md)
|
||||
|
||||
</div>
|
||||
|
||||
## Zurück zum Handbuch
|
||||
|
||||
- [Benutzerhandbuch Überblick](../getting-started/index.md)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
# Entwicklungsumgebung
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Node.js >= 20.19.0
|
||||
- externe Tools installiert (`makemkvcon`, `HandBrakeCLI`, `mediainfo`)
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Startet:
|
||||
|
||||
- Backend (`http://localhost:3001`, mit nodemon)
|
||||
- Frontend (`http://localhost:5173`, mit Vite HMR)
|
||||
|
||||
Stoppen: `Ctrl+C`.
|
||||
|
||||
---
|
||||
|
||||
## Manuell
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vite-Proxy (Dev)
|
||||
|
||||
`frontend/vite.config.js` proxied standardmäßig:
|
||||
|
||||
- `/api` -> `http://127.0.0.1:3001`
|
||||
- `/ws` -> `ws://127.0.0.1:3001`
|
||||
|
||||
---
|
||||
|
||||
## Remote-Dev (optional)
|
||||
|
||||
Beispiel `frontend/.env.local`:
|
||||
|
||||
```env
|
||||
VITE_API_BASE=http://192.168.1.100:3001/api
|
||||
VITE_WS_URL=ws://192.168.1.100:3001/ws
|
||||
VITE_PUBLIC_ORIGIN=http://192.168.1.100:5173
|
||||
VITE_ALLOWED_HOSTS=192.168.1.100,ripster.local
|
||||
VITE_HMR_PROTOCOL=ws
|
||||
VITE_HMR_HOST=192.168.1.100
|
||||
VITE_HMR_CLIENT_PORT=5173
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nützliche Kommandos
|
||||
|
||||
```bash
|
||||
# Root dev (backend + frontend)
|
||||
npm run dev
|
||||
|
||||
# einzeln
|
||||
npm run dev:backend
|
||||
npm run dev:frontend
|
||||
|
||||
# Frontend Build
|
||||
npm run build:frontend
|
||||
```
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Anhang: Deployment
|
||||
|
||||
Technische Betriebsdokumentation für Entwicklung und Produktion.
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-laptop: **Entwicklungsumgebung**
|
||||
|
||||
---
|
||||
|
||||
Lokale Entwicklung mit Hot-Reload.
|
||||
|
||||
[:octicons-arrow-right-24: Entwicklung](development.md)
|
||||
|
||||
- :material-server: **Produktion**
|
||||
|
||||
---
|
||||
|
||||
Installation und Betrieb auf Servern.
|
||||
|
||||
[:octicons-arrow-right-24: Produktion](production.md)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,233 @@
|
||||
# Produktions-Deployment
|
||||
|
||||
---
|
||||
|
||||
## Automatische Installation (empfohlen)
|
||||
|
||||
Das mitgelieferte `install.sh` richtet Ripster vollautomatisch ein – inklusive Node.js, MakeMKV, HandBrake, nginx und systemd-Dienst.
|
||||
|
||||
**Unterstützte Systeme laut Script:** Debian, Ubuntu, Linux Mint, Pop!_OS
|
||||
**Voraussetzung:** root-Rechte, Internetzugang
|
||||
|
||||
### Schnellstart via curl
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Oder mit wget:
|
||||
|
||||
```bash
|
||||
wget -qO- https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
!!! warning "Optionen nur via Datei"
|
||||
Beim Pipen von curl/wget können keine Argumente übergeben werden. Für benutzerdefinierte Optionen zuerst herunterladen und dann mit `sudo bash install.sh [Optionen]` ausführen.
|
||||
|
||||
### Optionen
|
||||
|
||||
| Option | Standard | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| `--branch <branch>` | `dev` | Git-Branch für die Installation |
|
||||
| `--dir <pfad>` | `/opt/ripster` | Installationsverzeichnis |
|
||||
| `--user <benutzer>` | `ripster` | Systembenutzer für den Dienst |
|
||||
| `--port <port>` | `3001` | Backend-Port |
|
||||
| `--host <hostname>` | Auto (Maschinen-IP) | Hostname/IP für die Weboberfläche |
|
||||
| `--no-makemkv` | – | MakeMKV-Installation überspringen |
|
||||
| `--no-handbrake` | – | HandBrake-Installation überspringen |
|
||||
| `--no-nginx` | – | nginx-Einrichtung überspringen |
|
||||
| `--reinstall` | – | Bestehende Installation aktualisieren (Daten bleiben erhalten) |
|
||||
| `-h`, `--help` | – | Hilfe anzeigen |
|
||||
|
||||
### Beispiele
|
||||
|
||||
```bash
|
||||
# Standard-Installation
|
||||
sudo bash install.sh
|
||||
|
||||
# Anderen Branch und Port verwenden
|
||||
sudo bash install.sh --branch dev --port 8080
|
||||
|
||||
# Ohne MakeMKV (bereits installiert)
|
||||
sudo bash install.sh --no-makemkv
|
||||
|
||||
# Bestehende Installation aktualisieren
|
||||
sudo bash install.sh --reinstall
|
||||
|
||||
# Ohne nginx (eigener Reverse-Proxy)
|
||||
sudo bash install.sh --no-nginx --host mein-server.local
|
||||
```
|
||||
|
||||
### Was das Skript erledigt
|
||||
|
||||
1. **Systemprüfung** – OS-Erkennung und Root-Check
|
||||
2. **Systempakete** – `curl`, `wget`, `git`, `mediainfo`, `udev` u. a.
|
||||
3. **Node.js 20** – via NodeSource, falls noch nicht installiert
|
||||
4. **MakeMKV** – aktuelle Version wird aus dem offiziellen Forum ermittelt und aus dem Quellcode kompiliert (kann mit `--no-makemkv` übersprungen werden)
|
||||
5. **HandBrake** – interaktive Auswahl:
|
||||
- **Option 1**: Standard (`apt install handbrake-cli`)
|
||||
- **Option 2**: Gebündelte GPU-Version mit NVDEC aus `bin/HandBrakeCLI`
|
||||
6. **Systembenutzer** `ripster` – ohne Login-Shell und ohne Home-Verzeichnis; Gruppen werden (falls vorhanden) ergänzt: `cdrom`, `optical`, `disk`, `video`, `render`
|
||||
7. **Repository** – klont Branch nach `--dir` (bei `--reinstall`: sichert `backend/data`, aktualisiert Repo, stellt Daten wieder her)
|
||||
8. **Verzeichnisse** – stellt u. a. sicher: `backend/data`, `backend/logs`, `backend/data/output/raw`, `backend/data/output/movies`, `backend/data/logs`
|
||||
9. **npm-Abhängigkeiten** – Root, Backend (nur production), Frontend
|
||||
10. **Frontend-Build** – `npm run build` mit relativen API-URLs (nginx-kompatibel)
|
||||
11. **Backend `.env`** – wird automatisch generiert (bei `--reinstall` bleibt bestehende `.env` erhalten)
|
||||
12. **Berechtigungen** – zunächst `ripster:ripster` auf Installationsverzeichnis; bei sudo-Aufruf werden Output-/Log-Ordner auf `<aufrufender user>:ripster` mit `775` gesetzt; `.env` wird auf `600` gesetzt
|
||||
13. **MakeMKV User-Ordner** – erstellt bei sudo-Aufruf `~/.MakeMKV` für den aufrufenden Benutzer
|
||||
14. **systemd-Dienst** – `ripster-backend.service` erstellt, aktiviert und gestartet
|
||||
15. **nginx** – konfiguriert als Reverse-Proxy für Frontend, `/api/` und `/ws` (kann mit `--no-nginx` übersprungen werden)
|
||||
|
||||
### Nach der Installation
|
||||
|
||||
```bash
|
||||
# Status prüfen
|
||||
sudo systemctl status ripster-backend
|
||||
|
||||
# Logs verfolgen
|
||||
sudo journalctl -u ripster-backend -f
|
||||
|
||||
# Neustart
|
||||
sudo systemctl restart ripster-backend
|
||||
|
||||
# Aktualisieren
|
||||
sudo bash /opt/ripster/install.sh --reinstall
|
||||
```
|
||||
|
||||
**Zugriff:** `http://<Maschinen-IP>` (oder der mit `--host` angegebene Hostname)
|
||||
|
||||
### HandBrake-Modus (GPU/NVDEC)
|
||||
|
||||
Bei nicht-interaktiver Ausführung (Pipe von curl) wird automatisch die Standard-Version gewählt. Für die GPU-Version zuerst herunterladen:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh -o install.sh
|
||||
sudo bash install.sh
|
||||
# → Interaktive Auswahl: Option 2 für NVDEC
|
||||
```
|
||||
|
||||
Das gebündelte Binary liegt unter `bin/HandBrakeCLI` und wird nach `/usr/local/bin/HandBrakeCLI` kopiert.
|
||||
|
||||
---
|
||||
|
||||
## Manuelle Installation
|
||||
|
||||
Die folgenden Abschnitte beschreiben die einzelnen Schritte für manuelle oder angepasste Setups.
|
||||
|
||||
### Empfohlene Architektur
|
||||
|
||||
```text
|
||||
Client
|
||||
-> nginx (Reverse Proxy + statisches Frontend)
|
||||
-> Backend API/WebSocket (Node.js, Port 3001)
|
||||
```
|
||||
|
||||
Wichtig: Das Backend serviert im aktuellen Stand keine `frontend/dist`-Dateien automatisch.
|
||||
|
||||
---
|
||||
|
||||
## 1) Frontend builden
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Artefakte liegen in `frontend/dist/`.
|
||||
|
||||
---
|
||||
|
||||
## 2) Backend als systemd-Service
|
||||
|
||||
Beispiel `/etc/systemd/system/ripster-backend.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Ripster Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ripster
|
||||
WorkingDirectory=/opt/ripster/backend
|
||||
ExecStart=/usr/bin/env node src/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3001
|
||||
Environment=LOG_LEVEL=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Aktivieren:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now ripster-backend
|
||||
sudo systemctl status ripster-backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) nginx konfigurieren
|
||||
|
||||
Beispiel `/etc/nginx/sites-available/ripster`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name ripster.local;
|
||||
client_max_body_size 8G;
|
||||
|
||||
root /opt/ripster/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Aktivieren:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/
|
||||
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
|
||||
|
||||
```bash
|
||||
sqlite3 /opt/ripster/backend/data/ripster.db \
|
||||
".backup '/var/backups/ripster-$(date +%Y%m%d).db'"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Ripster hat keine eingebaute Authentifizierung.
|
||||
- Für externen Zugriff mindestens Basic Auth + TLS + Netzwerksegmentierung/VPN einsetzen.
|
||||
- Secrets nicht ins Repo committen (`.env`, Settings-Felder).
|
||||
@@ -0,0 +1,151 @@
|
||||
# Ersteinrichtung
|
||||
|
||||
Diese Seite ist die Betriebs-Checkliste vor dem ersten produktiven Lauf.
|
||||
|
||||
## Ziel der Ersteinrichtung
|
||||
|
||||
Vor dem ersten Job sollten fünf Bereiche sauber gesetzt sein:
|
||||
|
||||
1. Pfade und Templates
|
||||
2. Laufwerk und Disc-Erkennung
|
||||
3. Metadatenzugang über TMDb
|
||||
4. Encode-Standards und Queue-Regeln
|
||||
5. optionale Automatisierung und Benachrichtigungen
|
||||
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
### 1. Basis in `Settings -> Konfiguration`
|
||||
|
||||
Prüfe zuerst diese Kernfelder:
|
||||
|
||||
- RAW- und Zielordner für Blu-ray, DVD, CD, Audiobooks und Converter
|
||||
- `Log Ordner`
|
||||
- `TMDb Read Access Token`
|
||||
- `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`
|
||||
- `FFmpeg Kommando`, `FFprobe Kommando`, `cdparanoia Kommando`, `mkvmerge Kommando`, falls du die jeweiligen Workflows nutzt
|
||||
|
||||
Danach speichern.
|
||||
|
||||
### 2. Pfade und Templates bewusst festlegen
|
||||
|
||||
Wichtig für den Alltag:
|
||||
|
||||
- Film-Workflows nutzen die RAW- und Film-Ausgabeordner sowie die Film-Output-Templates
|
||||
- Serien-Workflows nutzen zusätzlich die Serien-RAW-Ordner, die Serien-Zielordner und die Episode-/Multi-Episode-Templates
|
||||
- Audiobooks nutzen `Audiobook RAW-Ordner`, `Audiobook Output-Ordner`, `Audiobook RAW Template`, `Output Template (Audiobook)` und `Kapitel Template (Audiobook)`
|
||||
- Converter nutzt `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)` sowie `Output-Template (Video)` und `Output-Template (Audio)`
|
||||
- Downloads nutzen `Download ZIP-Ordner`
|
||||
|
||||
Wenn du auf NAS, USB oder mit anderen Benutzern/Gruppen schreiben willst, prüfe auch die jeweils zugehörigen `Eigentümer ...`-Felder.
|
||||
|
||||
### 3. Laufwerk und automatische Erkennung prüfen
|
||||
|
||||
Relevant:
|
||||
|
||||
- `Laufwerksmodus`
|
||||
- `Explizite Laufwerke`, falls du nicht mit Auto-Discovery arbeiten willst
|
||||
- `Automatische Disk-Erkennung`
|
||||
- `Polling Intervall (ms)` im Expertenmodus
|
||||
- `Laufwerk nach Rip auswerfen`
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Einzelnes Standardlaufwerk: `auto`
|
||||
- mehrere stabile Laufwerke oder ungewöhnliche Device-Mappings: `Explizite Laufwerke`
|
||||
|
||||
### 4. TMDb und Metadatenverhalten festlegen
|
||||
|
||||
Relevant:
|
||||
|
||||
- `TMDb Read Access Token`
|
||||
- `Film-Sprache`
|
||||
- `Fallback Sprache`
|
||||
- `Coverart-Nachladen aktiv`
|
||||
- `Coverart-Prüfintervall (Stunden)`
|
||||
- `NFO nach Encode erzeugen`
|
||||
|
||||
Diese Einstellungen wirken direkt in Metadatensuche, Serienzuordnung, Nachpflege von Covern und im finalen Output.
|
||||
|
||||
### 5. Encode- und Review-Standards festlegen
|
||||
|
||||
Für Video-Workflows besonders wichtig:
|
||||
|
||||
- `Minimale Titellänge (Minuten)`
|
||||
- `TMDb Runtime-Abzug für Playlistfilter (%)`
|
||||
- `HandBrake Preset` und `HandBrake Extra Args` je Medium
|
||||
- `Review Audio-Sprachen (...)`
|
||||
- `Review UT-Sprachen (...)`
|
||||
- `Ausgabeformat`
|
||||
|
||||
Praxis:
|
||||
|
||||
- `Minimale Titellänge` filtert Bonusmaterial früh weg
|
||||
- `TMDb Runtime-Abzug ...` lockert bei problematischen Blu-rays die Laufzeitprüfung der Playlist-Kandidaten
|
||||
- Review-Sprachen bestimmen, welche Audio-/Untertitelspuren in der Review schon vorausgewählt sind
|
||||
|
||||
### 6. Queue-Verhalten definieren
|
||||
|
||||
Stelle diese vier Felder bewusst ein:
|
||||
|
||||
- `Parallele Jobs`
|
||||
- `Max. parallele Audio CD Jobs`
|
||||
- `Max. Encodes gesamt (medienunabhängig)`
|
||||
- `Audio CDs: Queue-Reihenfolge überspringen`
|
||||
|
||||
Damit steuerst du, wie aggressiv Ripster mehrere Jobs gleichzeitig startet.
|
||||
|
||||
### 7. Optional: User-Presets und Standard-Zuordnungen
|
||||
|
||||
Unter `Settings -> Encode-Presets` kannst du:
|
||||
|
||||
- eigene HandBrake-User-Presets anlegen
|
||||
- Standard-Zuordnungen für `Blu-ray Film`, `Blu-ray Serie`, `DVD Film`, `DVD Serie` setzen
|
||||
|
||||
Diese Defaults wirken später in der Review automatisch vorbefüllend.
|
||||
|
||||
### 8. Optional: Benachrichtigungen
|
||||
|
||||
Prüfe:
|
||||
|
||||
- `PushOver aktiviert`
|
||||
- Token/User/optional Device
|
||||
- Event-Toggles wie `Bei manueller Auswahl senden`, `Bei Rip-Start senden`, `Bei Erfolg senden`
|
||||
|
||||
Danach in `Settings` den Button `PushOver Test` ausführen.
|
||||
|
||||
### 9. Optional: Automatisierung
|
||||
|
||||
Erst wenn die Grundkonfiguration stabil ist:
|
||||
|
||||
1. Skripte anlegen und testen
|
||||
2. Skriptketten aufbauen
|
||||
3. Cronjobs aktivieren
|
||||
|
||||
## Funktionsprüfung
|
||||
|
||||
Ein sinnvoller Kurztest:
|
||||
|
||||
1. Disc in `Ripper` erkennen lassen
|
||||
2. `Analyse starten`
|
||||
3. TMDb-Metadaten übernehmen
|
||||
4. ggf. Playlist-/RAW-Entscheidung durchlaufen
|
||||
5. bis `Bereit zum Encodieren` kommen
|
||||
6. Encode starten
|
||||
7. Ergebnis in `Historie` prüfen
|
||||
|
||||
## Typische Konfigurationsfehler
|
||||
|
||||
| Symptom | Häufige Ursache |
|
||||
|---|---|
|
||||
| Disc wird nicht erkannt | Laufwerksmodus oder Auto-Erkennung passt nicht |
|
||||
| Playlist-Auswahl wirkt unplausibel | `Minimale Titellänge` oder `TMDb Runtime-Abzug ...` ist zu streng/zu locker |
|
||||
| falsche Spuren sind in der Review vorausgewählt | Review-Sprachfilter oder HandBrake-Args greifen unerwartet |
|
||||
| Ausgabe landet am falschen Ort | Profilpfade oder Templates greifen über Fallback |
|
||||
| Queue verhält sich unerwartet | `Parallele Jobs`, CD-Limit und Gesamtlimit widersprechen sich |
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Erster Lauf](quickstart.md)
|
||||
- [GUI-Seiten](../gui/index.md)
|
||||
- [Workflows aus Nutzersicht](../workflows/index.md)
|
||||
- [Alle Einstellungen](../configuration/settings-reference.md)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user