0.13.1-4 release snapshot

This commit is contained in:
2026-04-13 11:52:07 +00:00
parent 6115090da1
commit ba3732af6b
176 changed files with 103457 additions and 1 deletions
+52
View File
@@ -0,0 +1,52 @@
{
"permissions": {
"allow": [
"Bash(ls /home/michael/ripster/*.sh)",
"Bash(ls /home/michael/ripster/backend/*.sh)",
"Bash(systemctl list-units --type=service)",
"Bash(pip install -q -r requirements-docs.txt)",
"Bash(mkdocs build --strict)",
"Read(//mnt/external/media/**)",
"WebFetch(domain:www.makemkv.com)",
"Bash(node --check backend/src/services/pipelineService.js)",
"Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)",
"Bash(find /home/michael/ripster -name *.db)",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, category, label, type, default_value, options_json FROM settings_schema WHERE category=''Laufwerk'' ORDER BY order_index\")",
"Bash(node --check /home/michael/ripster/backend/src/services/diskDetectionService.js)",
"Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)",
"Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)",
"Bash(node --check /home/michael/ripster/backend/src/db/database.js)",
"Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)",
"Bash(sqlite3 /home/michael/ripster/backend/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")",
"Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e\\) for e in d.get\\(''''blockdevices'''',[]\\)]\")",
"Bash(lsblk -o NAME,TYPE,MODEL)",
"Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)",
"Bash(grep -n \"inferMediaProfile\" /home/michael/ripster/backend/src/services/*.js)",
"Bash(grep -rn \"PROCESS_LOG\\\\|LOG_UPDATE\\\\|processLog\" /home/michael/ripster/backend/src --include=*.js)",
"Bash(grep -n \"buildHandBrakeConfig\" /home/michael/ripster/backend/src/services/*.js)",
"Bash(grep -n \"class.*Plugin\\\\|get id\\(\\)\\\\|get name\\(\\)\" /home/michael/ripster/backend/src/plugins/*.js)",
"Bash(/home/michael/ripster/db/schema.sql:*)",
"Read(//home/michael/**)",
"Bash(find /home/michael/ripster/AddOns/klangkiste -name *.jsx -o -name *.js -o -name *.vue)",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, label, order_index FROM settings_schema WHERE key LIKE ''converter%'' ORDER BY order_index\")",
"Bash(find /home/michael/ripster/backend -name *.db)",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT count\\(*\\) FROM settings_schema\")",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key FROM settings_schema WHERE key LIKE ''%converter%''\")",
"Bash(grep -n \"renderMediaTree\\\\|renderMediaContent\\\\|activeSection === ''media''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)",
"Bash(grep -n \"activeModal === ''new-folder''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)",
"Bash(sqlite3 /home/michael/ripster_data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
"Bash(sqlite3 /opt/ripster_data/ripster.db \"SELECT id, status, last_state FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
"Read(//opt/**)",
"Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path, substr\\(encode_plan_json,1,500\\) as plan FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
"Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state FROM jobs ORDER BY id DESC LIMIT 10;\")",
"Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE title LIKE ''%Kill%'' OR id IN \\(69, 212, 213\\) ORDER BY id DESC;\")",
"Bash(find /home/michael/ripster -name *.log -newer /home/michael/ripster/debug/markus.log)",
"Bash(pm2 logs:*)",
"Bash(journalctl -u ripster --no-pager -n 50)",
"Bash(pm2 list:*)",
"Bash(journalctl -u ripster-smoketest-http --no-pager -n 40)"
]
}
}
+159
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
20.19.0
+1
View File
@@ -0,0 +1 @@
python3
+1
View File
@@ -0,0 +1 @@
/usr/bin/python3
+1
View File
@@ -0,0 +1 @@
python3
+1
View File
@@ -0,0 +1 @@
lib
+5
View File
@@ -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
+1
Submodule .worktrees/main added at 190f6fe1a5
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+257 -1
View File
@@ -1 +1,257 @@
# ripster # Ripster
Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Plugin-basierte Architektur mit MakeMKV + HandBrake + FFmpeg, inklusive Metadaten-Auswahl, Track-Review, Queue, Skripten/Ketten und Job-Historie.
---
## Was Ripster kann
### Disc-Ripping & Encoding
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
- Medienprofil-Erkennung (Blu-ray/DVD/CD/Sonstiges) aus Device-/Filesystem-Heuristik
- Metadaten-Suche und Zuordnung über OMDb
- MakeMKV-Analyse und Rip (`mkv` oder `backup`) mit profilspezifischen Settings
- HandBrake-Review und Encoding mit Track-Auswahl, User-Presets, Extra-Args
- **Audio-CD-Ripping** mit cdparanoia + Encoding nach FLAC/MP3/Opus/Vorbis (experimentell)
### Audiobook-Verarbeitung
- **AAX/Audible-Dateien** verarbeiten (Activation Bytes, DRM-Handling)
- FFmpeg-basiertes Kapitel-Splitting
- Ausgabeformate: M4B, MP3, FLAC mit Metadaten
- MusicBrainz-Metadaten-Lookup
### Datei-Converter
- **Generische Audio/Video-Dateien** konvertieren (MKV, MP4, FLAC, MP3, u. v. m.)
- Datei-Explorer mit Upload, Umbenennen, Verschieben, Löschen
- Automatischer Verzeichnis-Scan (Polling konfigurierbar)
### Automatisierung & Verwaltung
- Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten)
- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`)
- Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung)
- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Ripper
- Download-Queue: Ausgabedateien als ZIP herunterladen
- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Ripper
## Tech-Stack
- Backend: Node.js, Express, SQLite, WebSocket (`ws`) **Plugin-Architektur**
- Frontend: React, Vite, PrimeReact
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`, `ffmpeg`/`ffprobe`, `cdparanoia`
## Dokumentation
- Ausführliche Dokumentation: https://mboehmlaender.github.io/ripster/
## Voraussetzungen
- Debian 11/12 oder Ubuntu 22.04/24.04
- root-Rechte + Internetzugang
- optisches Laufwerk (oder gemountete Quelle) für Disc-Ripping
- Netzwerk-Zugang für Audiobook-Upload und Datei-Converter optional
## Schnellstart (`install.sh`)
Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich):
```bash
wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh
sudo bash install.sh
```
Alternativ direkt per Pipe:
```bash
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
```
`install.sh` übernimmt u. a.:
- Node.js 20 (falls nötig)
- Basistools inkl. `mediainfo`, `ffmpeg`, `ffprobe`
- CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
- MakeMKV
- HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding)
- nginx
- Repository-Checkout, npm-Install, Frontend-Build, systemd-Service (`ripster-backend`)
Danach ist Ripster unter `http://<Server-IP>` erreichbar.
Wichtige Optionen:
```bash
sudo bash install.sh --branch dev # Branch wählen (Default im Skript: dev)
sudo bash install.sh --dir /opt/ripster # Installationspfad
sudo bash install.sh --user ripster # Service-User
sudo bash install.sh --port 3001 # Backend-Port
sudo bash install.sh --host 192.168.1.10 # Host/IP für nginx/CORS
sudo bash install.sh --no-makemkv # MakeMKV überspringen
sudo bash install.sh --no-handbrake # HandBrake überspringen
sudo bash install.sh --no-nginx # nginx-Setup überspringen
sudo bash install.sh --reinstall # Update (Daten bleiben erhalten)
sudo bash install.sh --help # Hilfe anzeigen
```
## Konfiguration
### UI-Settings (empfohlen)
Die meisten Einstellungen werden in der App unter `Settings` gepflegt und in SQLite gespeichert:
- Pfade: `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` (jeweils mit Blu-ray/DVD/Audiobook-Varianten)
- Pfade Converter: `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)`
- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`, `FFmpeg Kommando`, `FFprobe Kommando`
- Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat)
- Audiobook: Output-Template, RAW-Template
- Converter: erlaubte Endungen, Auto-Scan-Intervall, Output-Templates
- Queue/Monitoring: `Parallele Jobs`, `Hardware Monitoring aktiviert`, `Hardware Monitoring Intervall (ms)`
- Benachrichtigungen: PushOver
### Umgebungsvariablen
Backend (`backend/src/config.js`):
- `PORT` (Default: `3001`)
- `DB_PATH` (Default: `backend/data/ripster.db`)
- `LOG_DIR` (Default: `backend/logs`)
- `CORS_ORIGIN` (Default: `*`)
- `LOG_LEVEL` (`debug|info|warn|error`, Default: `info`)
Frontend (Vite):
- `VITE_API_BASE` (Default: `/api`)
- `VITE_WS_URL` (optional, überschreibt automatische WS-URL)
- optional für Remote-Dev: `VITE_PUBLIC_ORIGIN`, `VITE_ALLOWED_HOSTS`, `VITE_HMR_PROTOCOL`, `VITE_HMR_HOST`, `VITE_HMR_CLIENT_PORT`
## Logs und Daten
Log-Ziel ist primär der in den Settings gepflegte `log_dir`.
- Backend-Logs: `<log_dir>/backend/backend-latest.log` und Tagesdateien
- Job-Logs: `<log_dir>/job-<id>.process.log`
- DB: `backend/data/ripster.db`
Hinweis: Beim DB-Init wird das Schema geprüft und fehlende Elemente werden migriert.
## Projektstruktur
```text
ripster/
backend/
src/
plugins/ # Plugin-System (BluRay, DVD, CD, Audiobook, Converter)
routes/
services/
db/
utils/
frontend/
src/
pages/ # Ripper, Settings, History, Converter, Downloads, Database
components/
api/
db/schema.sql
start.sh
install.sh
install-dev.sh
```
## API-Überblick
**Health**
- `GET /api/health`
**Pipeline**
- `GET /api/pipeline/state`
- `POST /api/pipeline/analyze`
- `POST /api/pipeline/rescan-disc`
- `POST /api/pipeline/select-metadata`
- `POST /api/pipeline/start/:jobId`
- `POST /api/pipeline/confirm-encode/:jobId`
- `POST /api/pipeline/cancel`
- `POST /api/pipeline/retry/:jobId`
- `POST /api/pipeline/reencode/:jobId`
- `POST /api/pipeline/restart-review/:jobId`
- `POST /api/pipeline/restart-encode/:jobId`
- `POST /api/pipeline/resume-ready/:jobId`
- `GET /api/pipeline/queue`
- `POST /api/pipeline/queue/reorder`
- `POST /api/pipeline/queue/entry`
- `DELETE /api/pipeline/queue/entry/:entryId`
**Converter**
- `GET /api/converter/tree`
- `GET /api/converter/browse`
- `POST /api/converter/scan`
- `POST /api/converter/create-jobs`
- `POST /api/converter/upload`
- `POST /api/converter/jobs/from-selection`
- `GET /api/converter/jobs`
- `GET /api/converter/jobs/:jobId`
- `POST /api/converter/jobs/:jobId/start`
- `POST /api/converter/jobs/:jobId/cancel`
- `DELETE /api/converter/jobs/:jobId`
- `DELETE /api/converter/files`
- `POST /api/converter/files/rename`
- `POST /api/converter/files/move`
- `POST /api/converter/files/folder`
**Downloads**
- `GET /api/downloads`
- `GET /api/downloads/summary`
- `POST /api/downloads/history/:jobId`
- `GET /api/downloads/:id/file`
- `DELETE /api/downloads/:id`
**History**
- `GET /api/history`
- `GET /api/history/:id`
- `GET /api/history/database`
- `GET /api/history/orphan-raw`
- `POST /api/history/orphan-raw/import`
- `POST /api/history/:id/omdb/assign`
- `POST /api/history/:id/delete-files`
- `POST /api/history/:id/delete`
**Settings**
- `GET /api/settings`
- `PUT /api/settings/:key`
- `PUT /api/settings`
- `GET/POST/PUT/DELETE /api/settings/scripts...`
- `GET/POST/PUT/DELETE /api/settings/script-chains...`
- `GET/POST/PUT/DELETE /api/settings/user-presets...`
- `POST /api/settings/pushover/test`
**Cron-Jobs**
- `GET /api/crons`
- `POST /api/crons`
- `GET /api/crons/:id`
- `PUT /api/crons/:id`
- `DELETE /api/crons/:id`
- `GET /api/crons/:id/logs`
- `POST /api/crons/:id/run`
- `POST /api/crons/validate-expression`
**Runtime-Aktivitäten**
- `GET /api/activities`
- `POST /api/activities/:id/cancel`
- `POST /api/activities/:id/next-step`
- `POST /api/activities/clear-recent`
## Troubleshooting
- WebSocket verbindet nicht:
- prüfen, ob Frontend über Vite-Proxy läuft (`/ws` -> Backend)
- bei Reverse-Proxy Upgrade-Header für `/ws` setzen
- Keine Disc erkannt:
- in den Settings `Laufwerksmodus` auf `Explizites Device` stellen und `Device Pfad` setzen (z. B. `/dev/sr0`)
- HandBrake/MakeMKV Fehler:
- CLI-Binaries im `PATH` prüfen
- Preset-Name mit `HandBrakeCLI -z` prüfen
- Startfehler wegen Schema:
- `db/schema.sql` vorhanden halten
## Sicherheit
- Keine echten Tokens/Passwörter ins Repository committen.
- Lokale Secrets in `.env` oder in Settings pflegen, aber nicht versionieren.
+13
View File
@@ -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=
+11
View File
@@ -0,0 +1,11 @@
{
"watch": [
"src",
".env"
],
"ext": "js,json,env",
"ignore": [
"data/**",
"logs/**"
]
}
+3526
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "ripster-backend",
"version": "0.13.1-4",
"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"
}
}
+34
View File
@@ -0,0 +1,34 @@
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,
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
+121
View File
@@ -0,0 +1,121 @@
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 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 logger = require('./services/logger').child('BOOT');
const { errorToMeta } = require('./utils/errorMeta');
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
async function start() {
logger.info('backend:start:init');
await initDatabase();
await pipelineService.init();
await 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();
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);
});
+5
View File
@@ -0,0 +1,5 @@
module.exports = function asyncHandler(fn) {
return function wrapped(req, res, next) {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
+23
View File
@@ -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
}
});
};
+53
View File
@@ -0,0 +1,53 @@
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();
req.reqId = reqId;
logger.info('request:start', {
reqId,
method: req.method,
url: req.originalUrl,
ip: req.ip,
query: req.query,
body: truncate(req.body)
});
res.on('close', () => {
if (!res.writableEnded) {
logger.warn('request:aborted', {
reqId,
method: req.method,
url: req.originalUrl,
durationMs: Date.now() - startedAt
});
}
});
next();
};
+460
View File
@@ -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 };
+70
View File
@@ -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 };
+346
View File
@@ -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 };
+667
View File
@@ -0,0 +1,667 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const { spawnTrackedProcess } = require('../services/processRunner');
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';
const registrationKey = String(settings?.makemkv_registration_key || '').trim() || null;
if (registrationKey) {
try {
await this._runCaptured(makemkvCmd, ['reg', registrationKey], { jobId: job?.id });
} catch (_err) {
// Registrierung ist optional
}
}
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 };
+66
View File
@@ -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 };
+105
View File
@@ -0,0 +1,105 @@
'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 scanText = String(ctx.extra?.scanText || '').trim();
if (!scanText) {
return {
parsedScan: null,
seriesAnalysis: null,
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'
}
]);
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 };
+159
View File
@@ -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 };
+194
View File
@@ -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 };
+132
View File
@@ -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 };
+530
View File
@@ -0,0 +1,530 @@
'use strict';
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const omdbService = require('../services/omdbService');
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() → OMDB-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 OMDB-Suche für den erkannten Disc-Titel durch.
* Gibt { detectedTitle, omdbCandidates } zurück.
*
* @param {string} devicePath
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
* @param {PluginContext} ctx
* @returns {Promise<{detectedTitle: string, omdbCandidates: 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 omdbCandidates = await omdbService.search(detectedTitle).catch((error) => {
ctx.logger.warn(`${this.id}:analyze:omdb-failed`, {
jobId: job?.id,
error: error?.message || String(error)
});
return [];
});
ctx.logger.info(`${this.id}:analyze:done`, {
jobId: job?.id,
detectedTitle,
omdbCandidates: omdbCandidates.length
});
return { detectedTitle, omdbCandidates };
}
/**
* 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 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,
// HandBrake scan details (duration/chapters/audio/subtitles) are often
// emitted on stderr. Collect both streams for reliable disc heuristics.
collectStderrLines: true,
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),
onStderrLine: (line) => scanLines.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: scanLines.length
});
// Return raw scan data — the Orchestrator (or legacy pipelineService)
// processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
return {
scanLines,
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,
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
});
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
});
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;
let runInfo;
if (runCommandFn) {
runInfo = await runCommandFn({
jobId: job?.id,
stage: 'RIPPING',
source: 'MAKEMKV_RIP',
cmd: ripConfig.cmd,
args: ripConfig.args,
parser: parseMakeMkvProgress
});
} 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 };
+410
View File
@@ -0,0 +1,410 @@
'use strict';
const express = require('express');
const fs = require('fs');
const os = require('os');
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');
/** 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 converterUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-converter-uploads')
});
// ── 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;
+101
View File
@@ -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;
+71
View File
@@ -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;
+247
View File
@@ -0,0 +1,247 @@
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();
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.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 job = await historyService.importOrphanRawFolder(rawPath);
const uiReset = await pipelineService.resetFrontendState('history_orphan_import');
res.json({ job, uiReset });
})
);
router.post(
'/:id/omdb/assign',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const payload = req.body || {};
logger.info('post:job:omdb:assign', {
reqId: req.reqId,
id,
imdbId: payload?.imdbId || null,
hasTitle: Boolean(payload?.title),
hasYear: Boolean(payload?.year)
});
const job = await historyService.assignOmdbMetadata(id, payload);
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
pipelineService.renameJobFolders(id).catch((err) => {
logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message });
});
res.json({ job });
})
);
router.post(
'/:id/cd/assign',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const payload = req.body || {};
logger.info('post:job:cd:assign', {
reqId: req.reqId,
id,
mbId: payload?.mbId || null,
hasTitle: Boolean(payload?.title),
hasArtist: Boolean(payload?.artist),
trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0
});
const job = await historyService.assignCdMetadata(id, payload);
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
pipelineService.renameJobFolders(id).catch((err) => {
logger.warn('post:job:cd:assign:rename-failed', { id, error: err.message });
});
res.json({ job });
})
);
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/delete-files',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.body?.target || 'both');
logger.warn('post:delete-files', {
reqId: req.reqId,
id,
target
});
const result = await historyService.deleteJobFiles(id, target);
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());
logger.info('get:delete-preview', {
reqId: req.reqId,
id,
includeRelated
});
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
res.json({ preview });
})
);
router.post(
'/:id/delete',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.body?.target || 'none');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
: !resetDriveState;
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,
resetDriveState,
keepDetectedDevice,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
const preview = resetDriveState
? await historyService.getJobDeletePreview(id, { includeRelated })
: null;
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
.map((row) => String(row?.discDevice || '').trim())
.filter(Boolean)
: [];
const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths });
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 });
})
);
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;
+656
View File
@@ -0,0 +1,656 @@
const express = require('express');
const fs = require('fs');
const os = require('os');
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 { defaultAudiobookDir } = require('../config');
const { getDb } = require('../db/database');
const router = express.Router();
const audiobookUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads')
});
const AUDIOBOOK_TREE_MAX_DEPTH = 8;
function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) {
if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) {
return [];
}
const absDir = relPath ? path.join(rootDir, relPath) : rootDir;
let dirents = [];
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch (_error) {
return [];
}
dirents.sort((left, right) => {
const leftOrder = left.isDirectory() ? 0 : 1;
const rightOrder = right.isDirectory() ? 0 : 1;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
const nodes = [];
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) {
continue;
}
const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name;
const childAbsPath = path.join(rootDir, childRelPath);
if (dirent.isDirectory()) {
const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
let mtime = null;
try {
mtime = fs.statSync(childAbsPath).mtime.toISOString();
} catch (_error) {
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'folder',
path: childRelPath,
size,
mtime,
children
});
continue;
}
if (dirent.isFile()) {
let size = 0;
let mtime = null;
try {
const stat = fs.statSync(childAbsPath);
size = Number(stat?.size || 0);
mtime = stat?.mtime ? stat.mtime.toISOString() : null;
} catch (_error) {
size = 0;
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'file',
path: childRelPath,
size,
mtime
});
}
}
return nodes;
}
router.get(
'/state',
asyncHandler(async (req, res) => {
logger.debug('get:state', { reqId: req.reqId });
res.json({
pipeline: pipelineService.getSnapshot(),
hardwareMonitoring: hardwareMonitorService.getSnapshot()
});
})
);
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(
'/omdb/search',
asyncHandler(async (req, res) => {
const query = req.query.q || '';
logger.info('get:omdb:search', { reqId: req.reqId, query });
const results = await pipelineService.searchOmdb(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 || '';
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query });
const results = await pipelineService.searchMusicBrainz(String(query));
res.json({ results });
})
);
router.get(
'/cd/musicbrainz/release/:mbId',
asyncHandler(async (req, res) => {
const mbId = String(req.params.mbId || '').trim();
if (!mbId) {
const error = new Error('mbId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('get:cd:musicbrainz:release', { reqId: req.reqId, mbId });
const release = await pipelineService.getMusicBrainzReleaseById(mbId);
res.json({ release });
})
);
router.post(
'/cd/select-metadata',
asyncHandler(async (req, res) => {
const { jobId, title, artist, year, mbId, coverUrl, tracks } = req.body;
if (!jobId) {
const error = new Error('jobId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('post:cd:select-metadata', { reqId: req.reqId, jobId, title, artist, year, mbId });
const job = await pipelineService.selectCdMetadata({
jobId: Number(jobId),
title,
artist,
year,
mbId,
coverUrl,
tracks
});
res.json({ job });
})
);
router.post(
'/cd/start/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const ripConfig = req.body || {};
logger.info('post:cd:start', {
reqId: req.reqId,
jobId,
format: ripConfig.format,
selectedPreEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPreEncodeScriptIds)
? ripConfig.selectedPreEncodeScriptIds.length
: 0,
selectedPostEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPostEncodeScriptIds)
? ripConfig.selectedPostEncodeScriptIds.length
: 0,
selectedPreEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPreEncodeChainIds)
? ripConfig.selectedPreEncodeChainIds.length
: 0,
selectedPostEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPostEncodeChainIds)
? ripConfig.selectedPostEncodeChainIds.length
: 0
});
const result = await pipelineService.enqueueOrStartCdAction(
jobId,
ripConfig,
() => pipelineService.startCdRip(jobId, ripConfig)
);
res.json({ result });
})
);
router.post(
'/audiobook/upload',
audiobookUpload.single('file'),
asyncHandler(async (req, res) => {
if (!req.file) {
const error = new Error('Upload-Datei fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('post:audiobook:upload', {
reqId: req.reqId,
originalName: req.file.originalname,
sizeBytes: Number(req.file.size || 0),
mimeType: String(req.file.mimetype || '').trim() || null,
tempPath: String(req.file.path || '').trim() || null
});
const result = await pipelineService.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.get(
'/audiobook/output-tree',
asyncHandler(async (_req, res) => {
const settings = await settingsService.getEffectiveSettingsMap('audiobook');
const configuredOutputDir = String(settings?.movie_dir || '').trim();
const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null;
const outputDir = rawOutputDir
? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir))
: null;
if (!outputDir || !fs.existsSync(outputDir)) {
res.json({ outputDir, tree: null });
return;
}
const children = buildAudiobookOutputTree(outputDir, '', 0);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
res.json({
outputDir,
tree: {
name: path.basename(outputDir) || 'audiobooks',
type: 'folder',
path: '',
size,
mtime: null,
children
}
});
})
);
router.post(
'/select-metadata',
asyncHandler(async (req, res) => {
const {
jobId,
title,
year,
imdbId,
poster,
fromOmdb,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds,
metadataProvider,
providerId,
tmdbId,
metadataKind,
seasonNumber,
seasonName,
episodeCount,
episodes,
discNumber
} = 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,
year,
imdbId,
poster,
fromOmdb,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
metadataProvider,
providerId,
tmdbId,
seasonNumber,
discNumber
});
const job = await pipelineService.selectMetadata({
jobId: Number(jobId),
title,
year,
imdbId,
poster,
fromOmdb,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds,
metadataProvider,
providerId,
tmdbId,
metadataKind,
seasonNumber,
seasonName,
episodeCount,
episodes,
discNumber
});
res.json({ job });
})
);
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(
'/confirm-encode/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
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 = req.body?.selectedUserPresetId ?? null;
const selectedHandBrakePreset = req.body?.selectedHandBrakePreset ?? null;
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);
logger.info('post:retry', { reqId: req.reqId, jobId });
const result = await pipelineService.retry(jobId);
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 reuseCurrentJob = Boolean(req.body?.reuseCurrentJob);
logger.info('post:restart-review', {
reqId: req.reqId,
jobId,
keepBoth,
reuseCurrentJob,
deleteFolderCount: deleteFolders.length
});
const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders, reuseCurrentJob });
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';
logger.info('post:restart-encode', {
reqId: req.reqId,
jobId,
keepBoth,
restartMode,
deleteFolderCount: deleteFolders.length
});
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
keepBoth,
deleteFolders,
restartMode
});
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;
+69
View File
@@ -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;
+544
View File
@@ -0,0 +1,544 @@
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 activationBytesService = require('../services/activationBytesService');
const diskDetectionService = require('../services/diskDetectionService');
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
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(
'/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.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.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()
});
})
);
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 });
})
);
// ── 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.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.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 };
+819
View File
@@ -0,0 +1,819 @@
const path = require('path');
const { sanitizeFileName } = require('../utils/files');
const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']);
const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']);
const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})';
const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})';
const DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}';
const AUDIOBOOK_FORMAT_DEFAULTS = {
m4b: {},
flac: {
flacCompression: 5
},
mp3: {
mp3Mode: 'cbr',
mp3Bitrate: 192,
mp3Quality: 4
}
};
function normalizeText(value) {
return String(value || '')
.normalize('NFC')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function parseOptionalYear(value) {
const text = normalizeText(value);
if (!text) {
return null;
}
const match = text.match(/\b(19|20)\d{2}\b/);
if (!match) {
return null;
}
return Number(match[0]);
}
function parseOptionalNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseTimebaseToSeconds(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
if (/^\d+\/\d+$/u.test(raw)) {
const [num, den] = raw.split('/').map(Number);
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
return num / den;
}
}
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function secondsToMs(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return Math.max(0, Math.round(parsed * 1000));
}
function ticksToMs(value, timebase) {
const ticks = Number(value);
const factor = parseTimebaseToSeconds(timebase);
if (!Number.isFinite(ticks) || ticks < 0 || !Number.isFinite(factor) || factor <= 0) {
return null;
}
return Math.max(0, Math.round(ticks * factor * 1000));
}
function normalizeOutputFormat(value) {
const format = String(value || '').trim().toLowerCase();
return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3';
}
function clonePlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
}
function clampInteger(value, min, max, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.trunc(parsed)));
}
function getDefaultFormatOptions(format) {
const normalizedFormat = normalizeOutputFormat(format);
return clonePlainObject(AUDIOBOOK_FORMAT_DEFAULTS[normalizedFormat]);
}
function normalizeFormatOptions(format, formatOptions = {}) {
const normalizedFormat = normalizeOutputFormat(format);
const source = clonePlainObject(formatOptions);
const defaults = getDefaultFormatOptions(normalizedFormat);
if (normalizedFormat === 'flac') {
return {
flacCompression: clampInteger(source.flacCompression, 0, 8, defaults.flacCompression)
};
}
if (normalizedFormat === 'mp3') {
const mp3Mode = String(source.mp3Mode || defaults.mp3Mode || 'cbr').trim().toLowerCase() === 'vbr'
? 'vbr'
: 'cbr';
const allowedBitrates = new Set([128, 160, 192, 256, 320]);
const normalizedBitrate = clampInteger(source.mp3Bitrate, 96, 320, defaults.mp3Bitrate);
return {
mp3Mode,
mp3Bitrate: allowedBitrates.has(normalizedBitrate) ? normalizedBitrate : defaults.mp3Bitrate,
mp3Quality: clampInteger(source.mp3Quality, 0, 9, defaults.mp3Quality)
};
}
return {};
}
function normalizeInputExtension(filePath) {
return path.extname(String(filePath || '')).trim().toLowerCase();
}
function isSupportedInputFile(filePath) {
return SUPPORTED_INPUT_EXTENSIONS.has(normalizeInputExtension(filePath));
}
function normalizeTagMap(tags = null) {
const source = tags && typeof tags === 'object' ? tags : {};
const result = {};
for (const [key, value] of Object.entries(source)) {
const normalizedKey = String(key || '').trim().toLowerCase();
if (!normalizedKey) {
continue;
}
const normalizedValue = normalizeText(value);
if (!normalizedValue) {
continue;
}
result[normalizedKey] = normalizedValue;
}
return result;
}
function pickTag(tags, keys = []) {
const normalized = normalizeTagMap(tags);
for (const key of keys) {
const value = normalized[String(key || '').trim().toLowerCase()];
if (value) {
return value;
}
}
return null;
}
function sanitizeTemplateValue(value, fallback = '') {
const normalized = normalizeText(value);
if (!normalized) {
return fallback;
}
return sanitizeFileName(normalized);
}
function normalizeChapterTitle(value, index) {
const normalized = normalizeText(value);
return normalized || `Kapitel ${index}`;
}
function buildChapterList(probe = null) {
const chapters = Array.isArray(probe?.chapters) ? probe.chapters : [];
return chapters.map((chapter, index) => {
const chapterIndex = index + 1;
const tags = normalizeTagMap(chapter?.tags);
const startSeconds = parseOptionalNumber(chapter?.start_time);
const endSeconds = parseOptionalNumber(chapter?.end_time);
const startMs = secondsToMs(startSeconds) ?? ticksToMs(chapter?.start, chapter?.time_base) ?? 0;
const endMs = secondsToMs(endSeconds) ?? ticksToMs(chapter?.end, chapter?.time_base) ?? 0;
const title = normalizeChapterTitle(tags.title || tags.chapter, chapterIndex);
return {
index: chapterIndex,
title,
startSeconds: Number((startMs / 1000).toFixed(3)),
endSeconds: Number((endMs / 1000).toFixed(3)),
startMs,
endMs,
timeBase: String(chapter?.time_base || '').trim() || null
};
});
}
function normalizeChapterList(chapters = [], options = {}) {
const source = Array.isArray(chapters) ? chapters : [];
const durationMs = Number(options?.durationMs || 0);
const fallbackTitle = normalizeText(options?.fallbackTitle || '');
const createFallback = options?.createFallback === true;
const normalized = source.map((chapter, index) => {
const chapterIndex = Number(chapter?.index);
const safeIndex = Number.isFinite(chapterIndex) && chapterIndex > 0
? Math.trunc(chapterIndex)
: index + 1;
const rawStartMs = parseOptionalNumber(chapter?.startMs)
?? secondsToMs(chapter?.startSeconds)
?? ticksToMs(chapter?.start, chapter?.timeBase || chapter?.time_base)
?? 0;
const rawEndMs = parseOptionalNumber(chapter?.endMs)
?? secondsToMs(chapter?.endSeconds)
?? ticksToMs(chapter?.end, chapter?.timeBase || chapter?.time_base)
?? 0;
return {
index: safeIndex,
title: normalizeChapterTitle(chapter?.title, safeIndex),
startMs: Math.max(0, rawStartMs),
endMs: Math.max(0, rawEndMs)
};
});
const repaired = normalized.map((chapter, index) => {
const nextStartMs = normalized[index + 1]?.startMs ?? null;
let endMs = chapter.endMs;
if (!(endMs > chapter.startMs)) {
if (Number.isFinite(nextStartMs) && nextStartMs > chapter.startMs) {
endMs = nextStartMs;
} else if (durationMs > chapter.startMs) {
endMs = durationMs;
} else {
endMs = chapter.startMs;
}
}
return {
...chapter,
endMs,
startSeconds: Number((chapter.startMs / 1000).toFixed(3)),
endSeconds: Number((endMs / 1000).toFixed(3)),
durationMs: Math.max(0, endMs - chapter.startMs)
};
}).filter((chapter) => chapter.endMs > chapter.startMs || normalized.length === 1);
if (repaired.length > 0) {
return repaired;
}
if (createFallback && durationMs > 0) {
return [{
index: 1,
title: fallbackTitle || 'Kapitel 1',
startMs: 0,
endMs: durationMs,
startSeconds: 0,
endSeconds: Number((durationMs / 1000).toFixed(3)),
durationMs
}];
}
return [];
}
function looksLikeDescription(value) {
const normalized = normalizeText(value);
if (!normalized) {
return false;
}
return normalized.length >= 120 || /[.!?]\s/u.test(normalized);
}
function detectCoverStream(probe = null) {
const streams = Array.isArray(probe?.streams) ? probe.streams : [];
for (const stream of streams) {
const codecType = String(stream?.codec_type || '').trim().toLowerCase();
const codecName = String(stream?.codec_name || '').trim().toLowerCase();
const dispositionAttachedPic = Number(stream?.disposition?.attached_pic || 0) === 1;
const mimetype = String(stream?.tags?.mimetype || '').trim().toLowerCase();
const looksLikeImageStream = codecType === 'video'
&& (dispositionAttachedPic || mimetype.startsWith('image/') || ['jpeg', 'jpg', 'png', 'mjpeg'].includes(codecName));
if (!looksLikeImageStream) {
continue;
}
const streamIndex = Number(stream?.index);
return {
streamIndex: Number.isFinite(streamIndex) ? Math.trunc(streamIndex) : 0,
codecName: codecName || null,
mimetype: mimetype || null,
attachedPic: dispositionAttachedPic
};
}
return null;
}
function parseProbeOutput(rawOutput) {
if (!rawOutput) {
return null;
}
try {
return JSON.parse(rawOutput);
} catch (_error) {
return null;
}
}
function buildMetadataFromProbe(probe = null, originalName = null) {
const format = probe?.format && typeof probe.format === 'object' ? probe.format : {};
const tags = normalizeTagMap(format.tags);
const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || '')));
const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook';
const title = pickTag(tags, ['title', 'album']) || fallbackTitle;
const author = pickTag(tags, ['author', 'artist', 'writer', 'album_artist', 'composer']) || 'Unknown Author';
const description = pickTag(tags, [
'description',
'synopsis',
'summary',
'long_description',
'longdescription',
'publisher_summary',
'publishersummary',
'comment'
]) || null;
let narrator = pickTag(tags, ['narrator', 'performer', 'album_artist']) || null;
if (narrator && (narrator === author || narrator === description || looksLikeDescription(narrator))) {
narrator = null;
}
const series = pickTag(tags, ['series', 'grouping', 'series_title', 'show']) || null;
const part = pickTag(tags, ['part', 'part_number', 'disc', 'discnumber', 'volume']) || null;
const year = parseOptionalYear(pickTag(tags, ['date', 'year', 'creation_time']));
const durationSeconds = Number(format.duration || 0);
const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0
? Math.round(durationSeconds * 1000)
: 0;
const chapters = normalizeChapterList(buildChapterList(probe), {
durationMs,
fallbackTitle: title,
createFallback: false
});
const cover = detectCoverStream(probe);
return {
title,
author,
narrator,
description,
series,
part,
year,
album: title,
artist: author,
durationMs,
chapters,
cover,
hasEmbeddedCover: Boolean(cover),
tags
};
}
function normalizeTemplateTokenKey(rawKey) {
const key = String(rawKey || '').trim().toLowerCase();
if (!key) {
return '';
}
if (key === 'artist') {
return 'author';
}
if (key === 'chapternr' || key === 'chapternumberpadded' || key === 'chapternopadded') {
return 'chapterNr';
}
if (key === 'chapterno' || key === 'chapternumber' || key === 'chapternum') {
return 'chapterNo';
}
if (key === 'chaptertitle') {
return 'chapterTitle';
}
return key;
}
function cleanupRenderedTemplate(value) {
return String(value || '')
.replace(/\(\s*\)/g, '')
.replace(/\[\s*]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function renderTemplate(template, values) {
const source = String(template || DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE).trim()
|| DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
const rawValue = values?.[normalizedKey];
if (rawValue === undefined || rawValue === null || rawValue === '') {
return '';
}
return String(rawValue);
});
return cleanupRenderedTemplate(rendered);
}
function buildTemplateValues(metadata = {}, format = null, chapter = null) {
const chapterIndex = Number(chapter?.index || chapter?.chapterNo || 0);
const safeChapterIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 ? Math.trunc(chapterIndex) : 1;
const author = sanitizeTemplateValue(metadata.author || metadata.artist || 'Unknown Author', 'Unknown Author');
const title = sanitizeTemplateValue(metadata.title || metadata.album || 'Unknown Audiobook', 'Unknown Audiobook');
const narrator = sanitizeTemplateValue(metadata.narrator || '');
const series = sanitizeTemplateValue(metadata.series || '');
const part = sanitizeTemplateValue(metadata.part || '');
const chapterTitle = sanitizeTemplateValue(chapter?.title || `Kapitel ${safeChapterIndex}`, `Kapitel ${safeChapterIndex}`);
const year = metadata.year ? String(metadata.year) : '';
return {
author,
title,
narrator,
series,
part,
year,
format: format ? String(format).trim().toLowerCase() : '',
chapterNr: String(safeChapterIndex).padStart(2, '0'),
chapterNo: String(safeChapterIndex),
chapterTitle
};
}
function splitRenderedPath(value) {
return String(value || '')
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '')
.split('/')
.map((segment) => sanitizeFileName(segment))
.filter(Boolean);
}
function resolveTemplatePathParts(template, values, fallbackBaseName) {
const rendered = renderTemplate(template, values);
const parts = splitRenderedPath(rendered);
if (parts.length === 0) {
return {
folderParts: [],
baseName: sanitizeFileName(fallbackBaseName || 'untitled')
};
}
return {
folderParts: parts.slice(0, -1),
baseName: parts[parts.length - 1]
};
}
function buildRawStoragePaths(metadata, jobId, rawBaseDir, rawTemplate = DEFAULT_AUDIOBOOK_RAW_TEMPLATE, inputFileName = 'input.aax') {
const ext = normalizeInputExtension(inputFileName) || '.aax';
const values = buildTemplateValues(metadata);
const fallbackBaseName = path.basename(String(inputFileName || 'input.aax'), ext);
const { folderParts, baseName } = resolveTemplatePathParts(rawTemplate, values, fallbackBaseName);
const rawDirName = `${baseName} - RAW - job-${jobId}`;
const rawDir = path.join(String(rawBaseDir || ''), ...folderParts, rawDirName);
const rawFilePath = path.join(rawDir, `${baseName}${ext}`);
return {
rawDir,
rawFilePath,
rawFileName: `${baseName}${ext}`,
rawDirName
};
}
function buildOutputPath(metadata, movieBaseDir, outputTemplate = DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, outputFormat = 'mp3') {
const normalizedFormat = normalizeOutputFormat(outputFormat);
const values = buildTemplateValues(metadata, normalizedFormat);
const fallbackBaseName = values.title || 'audiobook';
const { folderParts, baseName } = resolveTemplatePathParts(outputTemplate, values, fallbackBaseName);
return path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`);
}
function findCommonDirectory(paths = []) {
const segmentsList = (Array.isArray(paths) ? paths : [])
.map((entry) => String(entry || '').trim())
.filter(Boolean)
.map((entry) => path.resolve(entry).split(path.sep).filter((segment, index, list) => !(index === 0 && list[0] === '')));
if (segmentsList.length === 0) {
return null;
}
const common = [...segmentsList[0]];
for (let index = 1; index < segmentsList.length; index += 1) {
const next = segmentsList[index];
let matchLength = 0;
while (matchLength < common.length && matchLength < next.length && common[matchLength] === next[matchLength]) {
matchLength += 1;
}
common.length = matchLength;
if (common.length === 0) {
break;
}
}
if (common.length === 0) {
return null;
}
return path.join(path.sep, ...common);
}
function buildChapterOutputPlan(
metadata,
chapters,
movieBaseDir,
chapterTemplate = DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
outputFormat = 'mp3'
) {
const normalizedFormat = normalizeOutputFormat(outputFormat);
const normalizedChapters = normalizeChapterList(chapters, {
durationMs: metadata?.durationMs,
fallbackTitle: metadata?.title || metadata?.album || 'Audiobook',
createFallback: true
});
const outputFiles = normalizedChapters.map((chapter, index) => {
const values = buildTemplateValues(metadata, normalizedFormat, chapter);
const fallbackBaseName = `${values.chapterNr} ${values.chapterTitle}`.trim() || `Kapitel ${index + 1}`;
const { folderParts, baseName } = resolveTemplatePathParts(chapterTemplate, values, fallbackBaseName);
const outputPath = path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`);
return {
chapter,
outputPath
};
});
const outputDir = findCommonDirectory(outputFiles.map((entry) => path.dirname(entry.outputPath)))
|| String(movieBaseDir || '').trim()
|| '.';
return {
outputDir,
outputFiles,
chapters: normalizedChapters,
format: normalizedFormat
};
}
function buildProbeCommand(ffprobeCommand, inputPath) {
const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe';
return {
cmd,
args: [
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
'-show_chapters',
inputPath
]
};
}
function pushMetadataArg(args, key, value) {
const normalizedKey = String(key || '').trim();
const normalizedValue = normalizeText(value);
if (!normalizedKey || !normalizedValue) {
return;
}
args.push('-metadata', `${normalizedKey}=${normalizedValue}`);
}
function buildMetadataArgs(metadata = {}, options = {}) {
const source = metadata && typeof metadata === 'object' ? metadata : {};
const titleOverride = normalizeText(options?.title || '');
const albumOverride = normalizeText(options?.album || '');
const trackNo = Number(options?.trackNo || 0);
const trackTotal = Number(options?.trackTotal || 0);
const args = [];
const bookTitle = normalizeText(source.title || source.album || '');
const author = normalizeText(source.author || source.artist || '');
pushMetadataArg(args, 'title', titleOverride || bookTitle);
pushMetadataArg(args, 'album', albumOverride || bookTitle);
pushMetadataArg(args, 'artist', author);
pushMetadataArg(args, 'album_artist', author);
pushMetadataArg(args, 'author', author);
pushMetadataArg(args, 'narrator', source.narrator);
pushMetadataArg(args, 'performer', source.narrator);
pushMetadataArg(args, 'grouping', source.series);
pushMetadataArg(args, 'series', source.series);
pushMetadataArg(args, 'disc', source.part);
pushMetadataArg(args, 'description', source.description);
pushMetadataArg(args, 'comment', source.description);
if (source.year) {
pushMetadataArg(args, 'date', String(source.year));
pushMetadataArg(args, 'year', String(source.year));
}
if (Number.isFinite(trackNo) && trackNo > 0) {
const formattedTrack = Number.isFinite(trackTotal) && trackTotal > 0
? `${Math.trunc(trackNo)}/${Math.trunc(trackTotal)}`
: String(Math.trunc(trackNo));
pushMetadataArg(args, 'track', formattedTrack);
}
return args;
}
function buildCodecArgs(format, normalizedOptions) {
if (format === 'm4b') {
return ['-c:a', 'copy'];
}
if (format === 'flac') {
return ['-codec:a', 'flac', '-compression_level', String(normalizedOptions.flacCompression)];
}
if (normalizedOptions.mp3Mode === 'vbr') {
return ['-codec:a', 'libmp3lame', '-q:a', String(normalizedOptions.mp3Quality)];
}
return ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`];
}
function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}, options = {}) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const format = normalizeOutputFormat(outputFormat);
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
const extra = options && typeof options === 'object' ? options : {};
const commonArgs = [
'-y',
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
'-i', inputPath
];
if (extra.chapterMetadataPath) {
commonArgs.push('-f', 'ffmetadata', '-i', extra.chapterMetadataPath);
}
commonArgs.push(
'-map', '0:a:0?',
'-map_metadata', '0',
'-map_chapters', extra.chapterMetadataPath ? '1' : '0',
'-vn',
'-sn',
'-dn'
);
const metadataArgs = buildMetadataArgs(extra.metadata, extra.metadataOptions);
const codecArgs = buildCodecArgs(format, normalizedOptions);
return {
cmd,
args: [...commonArgs, ...codecArgs, ...metadataArgs, outputPath],
metadataArgs,
formatOptions: normalizedOptions
};
}
function formatSecondsArg(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return '0';
}
return parsed.toFixed(3).replace(/\.?0+$/u, '');
}
function buildChapterEncodeCommand(
ffmpegCommand,
inputPath,
outputPath,
outputFormat = 'mp3',
formatOptions = {},
metadata = {},
chapter = {},
chapterTotal = 1,
options = {}
) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const format = normalizeOutputFormat(outputFormat);
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
const extra = options && typeof options === 'object' ? options : {};
const safeChapter = normalizeChapterList([chapter], {
durationMs: metadata?.durationMs,
fallbackTitle: metadata?.title || 'Kapitel',
createFallback: true
})[0];
const durationSeconds = Number(((safeChapter?.durationMs || 0) / 1000).toFixed(3));
const metadataArgs = buildMetadataArgs(metadata, {
title: safeChapter?.title,
album: metadata?.title || metadata?.album || null,
trackNo: safeChapter?.index || 1,
trackTotal: chapterTotal
});
const codecArgs = buildCodecArgs(format, normalizedOptions);
return {
cmd,
args: [
'-y',
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
'-i', inputPath,
'-ss', formatSecondsArg(safeChapter?.startSeconds),
'-t', formatSecondsArg(durationSeconds),
'-map', '0:a:0?',
'-map_metadata', '-1',
'-map_chapters', '-1',
'-vn',
'-sn',
'-dn',
...codecArgs,
...metadataArgs,
outputPath
],
metadataArgs,
formatOptions: normalizedOptions
};
}
function escapeFfmetadataValue(value) {
return String(value == null ? '' : value)
.replace(/\\/g, '\\\\')
.replace(/=/g, '\\=')
.replace(/;/g, '\\;')
.replace(/#/g, '\\#')
.replace(/\r?\n/g, ' ');
}
function buildChapterMetadataContent(chapters = [], metadata = {}) {
const normalizedChapters = normalizeChapterList(chapters, {
durationMs: metadata?.durationMs,
fallbackTitle: metadata?.title || metadata?.album || 'Audiobook',
createFallback: true
});
const chapterBlocks = normalizedChapters.map((chapter) => {
const startMs = Math.max(0, Math.round(chapter.startMs || 0));
const endMs = Math.max(startMs, Math.round(chapter.endMs || startMs));
return [
'[CHAPTER]',
'TIMEBASE=1/1000',
`START=${startMs}`,
`END=${endMs}`,
`title=${escapeFfmetadataValue(chapter.title || `Kapitel ${chapter.index || 1}`)}`
].join('\n');
}).join('\n\n');
return `;FFMETADATA1\n\n${chapterBlocks}`;
}
function buildCoverExtractionCommand(ffmpegCommand, inputPath, outputPath, cover = null) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const streamIndex = Number(cover?.streamIndex);
const streamSpecifier = Number.isFinite(streamIndex) && streamIndex >= 0
? `0:${Math.trunc(streamIndex)}`
: '0:v:0';
return {
cmd,
args: [
'-y',
'-i', inputPath,
'-map', streamSpecifier,
'-an',
'-sn',
'-dn',
'-frames:v', '1',
'-c:v', 'mjpeg',
'-q:v', '2',
outputPath
]
};
}
function parseFfmpegTimestampToMs(rawValue) {
const value = String(rawValue || '').trim();
const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const fraction = match[4] ? Number(`0.${match[4]}`) : 0;
if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) {
return null;
}
return Math.round((((hours * 60) + minutes) * 60 + seconds + fraction) * 1000);
}
function buildProgressParser(totalDurationMs) {
const durationMs = Number(totalDurationMs || 0);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return null;
}
return (line) => {
const match = String(line || '').match(/time=(\d+:\d{2}:\d{2}(?:\.\d+)?)/i);
if (!match) {
return null;
}
const currentMs = parseFfmpegTimestampToMs(match[1]);
if (!Number.isFinite(currentMs)) {
return null;
}
const percent = Math.max(0, Math.min(100, Number(((currentMs / durationMs) * 100).toFixed(2))));
return {
percent,
eta: null
};
};
}
module.exports = {
SUPPORTED_INPUT_EXTENSIONS,
SUPPORTED_OUTPUT_FORMATS,
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
AUDIOBOOK_FORMAT_DEFAULTS,
normalizeOutputFormat,
getDefaultFormatOptions,
normalizeFormatOptions,
isSupportedInputFile,
buildMetadataFromProbe,
normalizeChapterList,
buildRawStoragePaths,
buildOutputPath,
buildChapterOutputPlan,
buildProbeCommand,
parseProbeOutput,
buildEncodeCommand,
buildChapterEncodeCommand,
buildChapterMetadataContent,
buildCoverExtractionCommand,
buildProgressParser
};
+196
View File
@@ -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
};
+784
View File
@@ -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,393 @@
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');
}
const omdbInfo = parseJsonSafe(row?.omdb_json, {});
const omdbPoster = String(omdbInfo?.Poster || '').trim();
if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') {
push(omdbPoster, 'omdb_json.Poster');
}
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,
omdb_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();
+662
View File
@@ -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
+572
View File
@@ -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,498 @@
'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(/\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 seasonMatch = compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\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 = compactValue.match(/(?:^|\s)(?:disc|dvd|cd)\s*(\d{1,2})(?=\s|$)/i);
const discNumber = discMatch
? Number(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+scanning 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(/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(/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
+46
View File
@@ -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
};
+151
View File
@@ -0,0 +1,151 @@
const fs = require('fs');
const path = require('path');
const { logLevel } = require('../config');
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
const LEVELS = {
debug: 10,
info: 20,
warn: 30,
error: 40
};
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
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 = new Date().toISOString();
const payload = {
timestamp,
level: normLevel,
scope,
message,
meta: sanitizeMeta(meta)
};
const line = safeJson(payload);
writeLine(line);
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 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);
}
};
}
module.exports = {
child,
emit
};
+169
View File
@@ -0,0 +1,169 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('MUSICBRAINZ');
const MB_BASE = 'https://musicbrainz.org/ws/2';
const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)';
const MB_TIMEOUT_MS = 10000;
async function mbFetch(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': MB_USER_AGENT
},
signal: controller.signal
});
clearTimeout(timer);
if (!response.ok) {
throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`);
}
return response.json();
} catch (error) {
clearTimeout(timer);
throw error;
}
}
function normalizeRelease(release) {
if (!release) {
return null;
}
const artistCredit = Array.isArray(release['artist-credit'])
? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ')
: null;
const date = String(release.date || '').trim();
const yearMatch = date.match(/\b(\d{4})\b/);
const year = yearMatch ? Number(yearMatch[1]) : null;
const media = Array.isArray(release.media) ? release.media : [];
const normalizedTracks = media.flatMap((medium, mediumIdx) => {
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
return mediumTracks.map((track, trackIdx) => {
const rawPosition = String(track.position || track.number || '').trim();
const parsedPosition = Number.parseInt(rawPosition, 10);
const fallbackPosition = mediumIdx * 100 + trackIdx + 1;
const position = Number.isFinite(parsedPosition) && parsedPosition > 0
? parsedPosition
: fallbackPosition;
return {
position,
number: String(track.number || track.position || ''),
title: String(track.title || ''),
durationMs: Number(track.length || 0) || null,
rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [],
rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : []
};
});
}).map((track) => {
const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit)
? track.rawTrackArtistCredit
: [];
const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit)
? track.rawRecordingArtistCredit
: [];
const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
return {
position: track.position,
number: track.number,
title: track.title,
durationMs: track.durationMs,
artist: artistFromTrack || artistFromRecording || artistCredit || null
};
});
// Always generate the CAA URL when an id is present; the browser/onError
// handles 404s for releases that have no front cover.
const coverArtUrl = release.id
? `https://coverartarchive.org/release/${release.id}/front-250`
: null;
return {
mbId: String(release.id || ''),
title: String(release.title || ''),
artist: artistCredit || null,
year,
date,
country: String(release.country || '').trim() || null,
label: Array.isArray(release['label-info'])
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
: null,
coverArtUrl,
tracks: normalizedTracks
};
}
class MusicBrainzService {
async isEnabled() {
const settings = await settingsService.getSettingsMap();
return settings.musicbrainz_enabled !== 'false';
}
async searchByTitle(query) {
const q = String(query || '').trim();
if (!q) {
return [];
}
const enabled = await this.isEnabled();
if (!enabled) {
return [];
}
logger.info('search:start', { query: q });
const url = new URL(`${MB_BASE}/release`);
url.searchParams.set('query', q);
url.searchParams.set('fmt', 'json');
url.searchParams.set('limit', '10');
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
try {
const data = await mbFetch(url.toString());
const releases = Array.isArray(data.releases) ? data.releases : [];
const results = releases.map(normalizeRelease).filter(Boolean);
logger.info('search:done', { query: q, count: results.length });
return results;
} catch (error) {
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
return [];
}
}
async searchByDiscLabel(discLabel) {
return this.searchByTitle(discLabel);
}
async getReleaseById(mbId) {
const id = String(mbId || '').trim();
if (!id) {
return null;
}
const enabled = await this.isEnabled();
if (!enabled) {
return null;
}
logger.info('getById:start', { mbId: id });
const url = new URL(`${MB_BASE}/release/${id}`);
url.searchParams.set('fmt', 'json');
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
try {
const data = await mbFetch(url.toString());
const result = normalizeRelease(data);
logger.info('getById:done', { mbId: id, title: result?.title });
return result;
} catch (error) {
logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) });
return null;
}
}
}
module.exports = new MusicBrainzService();
+165
View File
@@ -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();
+225
View File
@@ -0,0 +1,225 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('OMDB');
const OMDB_BASE_URL = 'https://www.omdbapi.com/';
const OMDB_TIMEOUT_MS = 10000;
const OMDB_MAX_ATTEMPTS = 2;
const OMDB_RETRY_BASE_DELAY_MS = 300;
function normalizeOmdbTimeoutMs(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return OMDB_TIMEOUT_MS;
}
return Math.max(1000, Math.trunc(parsed));
}
function isRetryableOmdbError(error, aborted = false) {
if (aborted) {
return true;
}
const code = String(error?.code || '').trim().toUpperCase();
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') {
return true;
}
const causeCode = String(error?.cause?.code || '').trim().toUpperCase();
if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') {
return true;
}
const message = String(error?.message || '').toLowerCase();
return message.includes('timed out') || message.includes('timeout');
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
class OmdbService {
mapSearchResults(data) {
if (!data || typeof data !== 'object') {
return [];
}
if (data.Response === 'False' || !Array.isArray(data.Search)) {
return [];
}
return data.Search.map((item) => ({
title: item.Title,
year: item.Year,
imdbId: item.imdbID,
type: item.Type,
poster: item.Poster
}));
}
async requestJson(url, meta = {}, options = {}) {
const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs);
const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS)));
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
if (!response.ok) {
logger.warn('request:http-failed', {
...meta,
status: response.status,
attempt,
maxAttempts
});
return null;
}
return await response.json();
} catch (error) {
const aborted = error?.name === 'AbortError';
const retryable = isRetryableOmdbError(error, aborted);
const willRetry = retryable && attempt < maxAttempts;
logger[willRetry ? 'info' : 'warn']('request:failed', {
...meta,
timeoutMs,
aborted,
retryable,
attempt,
maxAttempts,
willRetry,
message: error?.message || String(error)
});
if (willRetry) {
await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt);
continue;
}
return null;
} finally {
clearTimeout(timer);
}
}
return null;
}
async search(query) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery) {
return [];
}
logger.info('search:start', { query: normalizedQuery });
// Allow direct IMDb-ID lookups in the search field (useful for History remapping).
const imdbLike = normalizedQuery.toLowerCase();
if (/^tt\d{6,12}$/.test(imdbLike)) {
const byId = await this.fetchByImdbId(imdbLike);
return byId
? [{
title: byId.title,
year: byId.year != null ? String(byId.year) : null,
imdbId: byId.imdbId,
type: byId.type,
poster: byId.poster
}]
: [];
}
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return [];
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie';
const requestSearch = async (type = null) => {
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('s', normalizedQuery);
if (type) {
url.searchParams.set('type', type);
}
const data = await this.requestJson(
url,
{ query: normalizedQuery, action: 'search', type: type || null },
{ timeoutMs }
);
return {
data,
results: this.mapSearchResults(data)
};
};
let { data, results } = await requestSearch(configuredType);
if (results.length === 0 && configuredType) {
logger.info('search:fallback-no-type', {
query: normalizedQuery,
configuredType,
response: data?.Response || null,
error: data?.Error || null
});
({ data, results } = await requestSearch(null));
}
if (results.length === 0) {
logger.warn('search:no-results', {
query: normalizedQuery,
response: data?.Response || null,
error: data?.Error || null,
configuredType
});
return [];
}
logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType });
return results;
}
async fetchByImdbId(imdbId) {
const normalizedId = String(imdbId || '').trim().toLowerCase();
if (!/^tt\d{6,12}$/.test(normalizedId)) {
return null;
}
logger.info('fetchByImdbId:start', { imdbId: normalizedId });
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return null;
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('i', normalizedId);
url.searchParams.set('plot', 'full');
const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
if (!data || typeof data !== 'object') {
return null;
}
if (data.Response === 'False') {
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
return null;
}
const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/);
const year = yearMatch ? Number(yearMatch[0]) : null;
const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null;
const result = {
title: data.Title || null,
year: Number.isFinite(year) ? year : null,
imdbId: String(data.imdbID || normalizedId),
type: data.Type || null,
poster,
raw: data
};
logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title });
return result;
}
}
module.exports = new OmdbService();
File diff suppressed because it is too large Load Diff
+114
View File
@@ -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();
+927
View File
@@ -0,0 +1,927 @@
const { getDb } = require('../db/database');
const logger = require('./logger').child('SCRIPT_CHAINS');
const runtimeActivityService = require('./runtimeActivityService');
const { spawnTrackedProcess } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
const CHAIN_NAME_MAX_LENGTH = 120;
const STEP_TYPE_SCRIPT = 'script';
const STEP_TYPE_WAIT = 'wait';
const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]);
function normalizeChainId(rawValue) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value <= 0) {
return null;
}
return Math.trunc(value);
}
function createValidationError(message, details = null) {
const error = new Error(message);
error.statusCode = 400;
if (details) {
error.details = details;
}
return error;
}
function mapChainRow(row, steps = []) {
if (!row) {
return null;
}
return {
id: Number(row.id),
name: String(row.name || ''),
orderIndex: Number(row.order_index || 0),
steps: steps.map(mapStepRow),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function mapStepRow(row) {
if (!row) {
return null;
}
return {
id: Number(row.id),
position: Number(row.position),
stepType: String(row.step_type || ''),
scriptId: row.script_id != null ? Number(row.script_id) : null,
scriptName: row.script_name != null ? String(row.script_name) : null,
waitSeconds: row.wait_seconds != null ? Number(row.wait_seconds) : null
};
}
function terminateChildProcess(child, { immediate = false } = {}) {
if (!child) {
return;
}
const signal = immediate ? 'SIGKILL' : 'SIGTERM';
const pid = Number(child.pid);
if (Number.isFinite(pid) && pid > 0) {
try {
// For detached children this targets the full process group.
process.kill(-pid, signal);
return;
} catch (_error) {
// Fall through to direct child signal.
}
}
try {
child.kill(signal);
} catch (_error) {
return;
}
}
function appendTailText(currentValue, nextChunk, maxChars = 12000) {
const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
if (!chunk) {
return {
value: currentValue || '',
truncated: false
};
}
const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`;
const combined = `${String(currentValue || '')}${normalizedChunk}`;
if (combined.length <= maxChars) {
return {
value: combined,
truncated: false
};
}
return {
value: combined.slice(-maxChars),
truncated: true
};
}
function validateSteps(rawSteps) {
const steps = Array.isArray(rawSteps) ? rawSteps : [];
const errors = [];
const normalized = [];
for (let i = 0; i < steps.length; i++) {
const step = steps[i] && typeof steps[i] === 'object' ? steps[i] : {};
const stepType = String(step.stepType || step.step_type || '').trim();
if (!VALID_STEP_TYPES.has(stepType)) {
errors.push({ field: `steps[${i}].stepType`, message: `Ungültiger Schritt-Typ: '${stepType}'. Erlaubt: script, wait.` });
continue;
}
if (stepType === STEP_TYPE_SCRIPT) {
const scriptId = Number(step.scriptId ?? step.script_id);
if (!Number.isFinite(scriptId) || scriptId <= 0) {
errors.push({ field: `steps[${i}].scriptId`, message: 'scriptId fehlt oder ist ungültig.' });
continue;
}
normalized.push({ stepType, scriptId: Math.trunc(scriptId), waitSeconds: null });
} else if (stepType === STEP_TYPE_WAIT) {
const waitSeconds = Number(step.waitSeconds ?? step.wait_seconds);
if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) {
errors.push({ field: `steps[${i}].waitSeconds`, message: 'waitSeconds muss zwischen 1 und 3600 liegen.' });
continue;
}
normalized.push({ stepType, scriptId: null, waitSeconds: Math.round(waitSeconds) });
}
}
if (errors.length > 0) {
throw createValidationError('Ungültige Schritte in der Skriptkette.', errors);
}
return normalized;
}
async function getStepsForChain(db, chainId) {
return db.all(
`
SELECT
s.id,
s.chain_id,
s.position,
s.step_type,
s.script_id,
s.wait_seconds,
sc.name AS script_name
FROM script_chain_steps s
LEFT JOIN scripts sc ON sc.id = s.script_id
WHERE s.chain_id = ?
ORDER BY s.position ASC, s.id ASC
`,
[chainId]
);
}
class ScriptChainService {
async listChains() {
const db = await getDb();
const rows = await db.all(
`
SELECT id, name, order_index, created_at, updated_at
FROM script_chains
ORDER BY order_index ASC, id ASC
`
);
if (rows.length === 0) {
return [];
}
const chainIds = rows.map((row) => Number(row.id));
const placeholders = chainIds.map(() => '?').join(', ');
const stepRows = await db.all(
`
SELECT
s.id,
s.chain_id,
s.position,
s.step_type,
s.script_id,
s.wait_seconds,
sc.name AS script_name
FROM script_chain_steps s
LEFT JOIN scripts sc ON sc.id = s.script_id
WHERE s.chain_id IN (${placeholders})
ORDER BY s.chain_id ASC, s.position ASC, s.id ASC
`,
chainIds
);
const stepsByChain = new Map();
for (const step of stepRows) {
const cid = Number(step.chain_id);
if (!stepsByChain.has(cid)) {
stepsByChain.set(cid, []);
}
stepsByChain.get(cid).push(step);
}
return rows.map((row) => mapChainRow(row, stepsByChain.get(Number(row.id)) || []));
}
async getChainById(chainId) {
const normalizedId = normalizeChainId(chainId);
if (!normalizedId) {
throw createValidationError('Ungültige chainId.');
}
const db = await getDb();
const row = await db.get(
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`,
[normalizedId]
);
if (!row) {
const error = new Error(`Skriptkette #${normalizedId} wurde nicht gefunden.`);
error.statusCode = 404;
throw error;
}
const steps = await getStepsForChain(db, normalizedId);
return mapChainRow(row, steps);
}
async getChainsByIds(rawIds = []) {
const ids = Array.isArray(rawIds)
? rawIds.map(normalizeChainId).filter(Boolean)
: [];
if (ids.length === 0) {
return [];
}
const db = await getDb();
const placeholders = ids.map(() => '?').join(', ');
const rows = await db.all(
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`,
ids
);
const stepRows = await db.all(
`
SELECT
s.id, s.chain_id, s.position, s.step_type, s.script_id, s.wait_seconds,
sc.name AS script_name
FROM script_chain_steps s
LEFT JOIN scripts sc ON sc.id = s.script_id
WHERE s.chain_id IN (${placeholders})
ORDER BY s.chain_id ASC, s.position ASC, s.id ASC
`,
ids
);
const stepsByChain = new Map();
for (const step of stepRows) {
const cid = Number(step.chain_id);
if (!stepsByChain.has(cid)) {
stepsByChain.set(cid, []);
}
stepsByChain.get(cid).push(step);
}
const byId = new Map(rows.map((row) => [
Number(row.id),
mapChainRow(row, stepsByChain.get(Number(row.id)) || [])
]));
return ids.map((id) => byId.get(id)).filter(Boolean);
}
async createChain(payload = {}) {
const body = payload && typeof payload === 'object' ? payload : {};
const name = String(body.name || '').trim();
if (!name) {
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
}
if (name.length > CHAIN_NAME_MAX_LENGTH) {
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
}
const steps = validateSteps(body.steps);
const db = await getDb();
try {
const nextOrderIndex = await this._getNextOrderIndex(db);
const result = await db.run(
`
INSERT INTO script_chains (name, order_index, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`,
[name, nextOrderIndex]
);
const chainId = result.lastID;
await this._saveSteps(db, chainId, steps);
return this.getChainById(chainId);
} catch (error) {
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]);
}
throw error;
}
}
async updateChain(chainId, payload = {}) {
const normalizedId = normalizeChainId(chainId);
if (!normalizedId) {
throw createValidationError('Ungültige chainId.');
}
const body = payload && typeof payload === 'object' ? payload : {};
const name = String(body.name || '').trim();
if (!name) {
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
}
if (name.length > CHAIN_NAME_MAX_LENGTH) {
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
}
const steps = validateSteps(body.steps);
await this.getChainById(normalizedId);
const db = await getDb();
try {
await db.run(
`UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[name, normalizedId]
);
await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]);
await this._saveSteps(db, normalizedId, steps);
return this.getChainById(normalizedId);
} catch (error) {
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]);
}
throw error;
}
}
async deleteChain(chainId) {
const normalizedId = normalizeChainId(chainId);
if (!normalizedId) {
throw createValidationError('Ungültige chainId.');
}
const existing = await this.getChainById(normalizedId);
const db = await getDb();
await db.run(`DELETE FROM script_chains WHERE id = ?`, [normalizedId]);
return existing;
}
async reorderChains(orderedIds = []) {
const providedIds = Array.isArray(orderedIds)
? orderedIds.map(normalizeChainId).filter(Boolean)
: [];
const db = await getDb();
const rows = await db.all(
`
SELECT id
FROM script_chains
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 script_chains
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.listChains();
}
async _getNextOrderIndex(db) {
const row = await db.get(
`
SELECT COALESCE(MAX(order_index), 0) AS max_order_index
FROM script_chains
`
);
const maxOrder = Number(row?.max_order_index || 0);
if (!Number.isFinite(maxOrder) || maxOrder < 0) {
return 1;
}
return Math.trunc(maxOrder) + 1;
}
async _saveSteps(db, chainId, steps) {
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
await db.run(
`
INSERT INTO script_chain_steps (chain_id, position, step_type, script_id, wait_seconds, created_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`,
[chainId, i + 1, step.stepType, step.scriptId ?? null, step.waitSeconds ?? null]
);
}
}
async executeChain(chainId, context = {}, { appendLog = null } = {}) {
const chain = await this.getChainById(chainId);
logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length });
const totalSteps = chain.steps.length;
const activityId = runtimeActivityService.startActivity('chain', {
name: chain.name,
source: context?.source || 'chain',
chainId: chain.id,
jobId: context?.jobId || null,
cronJobId: context?.cronJobId || null,
parentActivityId: context?.runtimeParentActivityId || null,
currentStep: totalSteps > 0 ? `Schritt 1/${totalSteps}` : 'Keine Schritte'
});
const controlState = {
cancelRequested: false,
cancelReason: null,
currentStepType: null,
activeWaitResolve: null,
activeChild: null,
activeChildTermination: null
};
const emitRuntimeStep = (payload = {}) => {
if (typeof context?.onRuntimeStep !== 'function') {
return;
}
try {
context.onRuntimeStep({
chainId: chain.id,
chainName: chain.name,
...payload
});
} catch (_error) {
// ignore runtime callback errors
}
};
const requestCancel = 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',
currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`);
} catch (_error) {
// ignore appendLog failures for control actions
}
}
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
controlState.activeWaitResolve('cancel');
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'cancel';
terminateChildProcess(controlState.activeChild, { immediate: true });
}
return { accepted: true, message: 'Abbruch angefordert.' };
};
const requestNextStep = async () => {
if (controlState.cancelRequested) {
return { accepted: false, message: 'Kette wird bereits abgebrochen.' };
}
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
controlState.activeWaitResolve('skip');
runtimeActivityService.updateActivity(activityId, {
message: 'Nächster Schritt angefordert (Wait übersprungen)'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Wait-Schritt manuell übersprungen.`);
} catch (_error) {
// ignore appendLog failures for control actions
}
}
return { accepted: true, message: 'Wait-Schritt übersprungen.' };
}
if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'skip';
terminateChildProcess(controlState.activeChild, { immediate: true });
runtimeActivityService.updateActivity(activityId, {
message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript-Schritt manuell übersprungen.`);
} catch (_error) {
// ignore appendLog failures for control actions
}
}
return { accepted: true, message: 'Skript-Schritt wird übersprungen.' };
}
return { accepted: false, message: 'Kein aktiver Schritt zum Überspringen.' };
};
runtimeActivityService.setControls(activityId, {
cancel: requestCancel,
nextStep: requestNextStep
});
const results = [];
let completionPayload = null;
let abortedByUser = false;
try {
for (let index = 0; index < chain.steps.length; index += 1) {
if (controlState.cancelRequested) {
abortedByUser = true;
break;
}
const step = chain.steps[index];
const stepIndex = index + 1;
if (step.stepType === STEP_TYPE_WAIT) {
const seconds = Math.max(1, Number(step.waitSeconds || 1));
const waitLabel = `Warte ${seconds} Sekunde(n)`;
controlState.currentStepType = STEP_TYPE_WAIT;
runtimeActivityService.updateActivity(activityId, {
currentStepType: 'wait',
currentStep: waitLabel,
currentScriptName: null,
stepIndex,
stepTotal: totalSteps
});
emitRuntimeStep({
stepType: 'wait',
stepIndex,
stepTotal: totalSteps,
currentStep: waitLabel
});
logger.info('chain:step:wait', { chainId, seconds });
if (typeof appendLog === 'function') {
await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`);
}
const waitOutcome = await new Promise((resolve) => {
const timer = setTimeout(() => {
controlState.activeWaitResolve = null;
resolve('done');
}, seconds * 1000);
controlState.activeWaitResolve = (mode = 'done') => {
clearTimeout(timer);
controlState.activeWaitResolve = null;
resolve(mode);
};
});
controlState.currentStepType = null;
if (waitOutcome === 'skip') {
results.push({ stepType: 'wait', waitSeconds: seconds, success: true, skipped: true, reason: 'skipped_by_user' });
continue;
}
if (waitOutcome === 'cancel' || controlState.cancelRequested) {
abortedByUser = true;
results.push({ stepType: 'wait', waitSeconds: seconds, success: false, aborted: true, reason: 'cancelled_by_user' });
break;
}
results.push({ stepType: 'wait', waitSeconds: seconds, success: true });
} else if (step.stepType === STEP_TYPE_SCRIPT) {
if (!step.scriptId) {
logger.warn('chain:step:script-missing', { chainId, stepId: step.id });
results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' });
continue;
}
const scriptService = require('./scriptService');
let script;
try {
script = await scriptService.getScriptById(step.scriptId);
} catch (error) {
logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' });
continue;
}
controlState.currentStepType = STEP_TYPE_SCRIPT;
runtimeActivityService.updateActivity(activityId, {
currentStepType: 'script',
currentStep: `Skript: ${script.name}`,
currentScriptName: script.name,
stepIndex,
stepTotal: totalSteps,
scriptId: script.id
});
emitRuntimeStep({
stepType: 'script',
stepIndex,
stepTotal: totalSteps,
scriptId: script.id,
scriptName: script.name,
currentScriptName: script.name,
currentStep: `Skript: ${script.name}`
});
if (typeof appendLog === 'function') {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`);
}
const scriptActivityId = runtimeActivityService.startActivity('script', {
name: script.name,
source: context?.source || 'chain',
scriptId: script.id,
chainId: chain.id,
jobId: context?.jobId || null,
cronJobId: context?.cronJobId || null,
parentActivityId: activityId,
currentStep: `Kette: ${chain.name}`
});
let prepared = null;
try {
prepared = await scriptService.createExecutableScriptFile(script, {
...context,
scriptId: script.id,
scriptName: script.name,
source: context?.source || 'chain'
});
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
const processHandle = spawnTrackedProcess({
cmd: prepared.cmd,
args: prepared.args,
context: { source: context?.source || 'chain', chainId: chain.id, scriptId: script.id },
onStart: (child) => {
controlState.activeChild = child;
controlState.activeChildTermination = null;
},
onStdoutLine: (line) => {
const next = appendTailText(stdout, line);
stdout = next.value;
stdoutTruncated = stdoutTruncated || next.truncated;
runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
},
onStderrLine: (line) => {
const next = appendTailText(stderr, line);
stderr = next.value;
stderrTruncated = stderrTruncated || next.truncated;
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
}
});
let runError = null;
let exitCode = 0;
let signal = null;
try {
const result = await processHandle.promise;
exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
signal = result?.signal || null;
} catch (error) {
runError = error;
exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
signal = error?.signal || null;
}
const termination = controlState.activeChildTermination;
controlState.activeChild = null;
controlState.activeChildTermination = null;
if (runError && exitCode === null && !termination) {
throw runError;
}
const run = {
code: exitCode,
signal,
stdout,
stderr,
stdoutTruncated,
stderrTruncated,
termination
};
controlState.currentStepType = null;
if (run.termination === 'skip') {
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'success',
success: true,
outcome: 'skipped',
skipped: true,
currentStep: null,
message: 'Schritt übersprungen',
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
stdout: run.stdout || null,
stderr: run.stderr || null,
stdoutTruncated: Boolean(run.stdoutTruncated),
stderrTruncated: Boolean(run.stderrTruncated)
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" übersprungen.`);
} catch (_error) {
// ignore appendLog failures on skip path
}
}
results.push({
stepType: 'script',
scriptId: script.id,
scriptName: script.name,
success: true,
skipped: true,
reason: 'skipped_by_user'
});
continue;
}
if (run.termination === 'cancel' || controlState.cancelRequested) {
abortedByUser = true;
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'error',
success: false,
outcome: 'cancelled',
cancelled: true,
currentStep: null,
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
stdout: run.stdout || null,
stderr: run.stderr || null,
stdoutTruncated: Boolean(run.stdoutTruncated),
stderrTruncated: Boolean(run.stderrTruncated),
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`);
} catch (_error) {
// ignore appendLog failures on cancel path
}
}
results.push({
stepType: 'script',
scriptId: script.id,
scriptName: script.name,
success: false,
aborted: true,
reason: 'cancelled_by_user'
});
break;
}
const success = run.code === 0;
runtimeActivityService.completeActivity(scriptActivityId, {
status: success ? 'success' : 'error',
success,
outcome: success ? 'success' : 'error',
exitCode: run.code,
currentStep: null,
message: success ? null : `Fehler (Exit ${run.code})`,
output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
stderr: success ? null : (run.stderr || null),
stdout: success ? null : (run.stdout || null),
stdoutTruncated: Boolean(run.stdoutTruncated),
stderrTruncated: Boolean(run.stderrTruncated),
errorMessage: success ? null : `Fehler (Exit ${run.code})`
});
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
if (typeof appendLog === 'function') {
await appendLog(
success ? 'SYSTEM' : 'ERROR',
`Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}`
);
}
results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' });
if (!success) {
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
break;
}
} catch (error) {
controlState.currentStepType = null;
if (controlState.cancelRequested) {
abortedByUser = true;
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'error',
success: false,
outcome: 'cancelled',
cancelled: true,
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`);
} catch (_error) {
// ignore appendLog failures on cancel path
}
}
results.push({
stepType: 'script',
scriptId: script.id,
scriptName: script.name,
success: false,
aborted: true,
reason: 'cancelled_by_user'
});
break;
}
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'error',
success: false,
outcome: 'error',
message: error?.message || 'unknown',
errorMessage: error?.message || 'unknown'
});
logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
if (typeof appendLog === 'function') {
await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`);
}
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message });
break;
} finally {
controlState.activeChild = null;
controlState.activeChildTermination = null;
if (prepared?.cleanup) {
await prepared.cleanup();
}
}
}
}
const succeeded = results.filter((r) => r.success).length;
const skipped = results.filter((r) => r.skipped).length;
const failed = results.filter((r) => !r.success && !r.skipped && !r.aborted).length;
logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed, skipped, abortedByUser });
if (abortedByUser) {
completionPayload = {
status: 'error',
success: false,
outcome: 'cancelled',
cancelled: true,
currentStep: null,
currentScriptName: null,
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
};
emitRuntimeStep({
finished: true,
success: false,
aborted: true,
failed,
succeeded
});
return {
chainId,
chainName: chain.name,
steps: results.length,
succeeded,
failed,
skipped,
aborted: true,
abortedByUser: true,
results
};
}
completionPayload = {
status: failed > 0 ? 'error' : 'success',
success: failed === 0,
outcome: failed > 0 ? 'error' : (skipped > 0 ? 'skipped' : 'success'),
skipped: skipped > 0,
currentStep: null,
currentScriptName: null,
message: failed > 0
? `${failed} Schritt(e) fehlgeschlagen`
: (skipped > 0
? `${succeeded} Schritt(e) erfolgreich, ${skipped} übersprungen`
: `${succeeded} Schritt(e) erfolgreich`)
};
emitRuntimeStep({
finished: true,
success: failed === 0,
failed,
succeeded
});
return {
chainId,
chainName: chain.name,
steps: results.length,
succeeded,
failed,
skipped,
aborted: failed > 0,
results
};
} catch (error) {
completionPayload = {
status: 'error',
success: false,
outcome: 'error',
message: error?.message || 'unknown',
errorMessage: error?.message || 'unknown',
currentStep: null
};
throw error;
} finally {
runtimeActivityService.completeActivity(activityId, completionPayload || {
status: 'error',
success: false,
outcome: 'error',
message: 'Kette unerwartet beendet',
errorMessage: 'Kette unerwartet beendet',
currentStep: null
});
}
}
}
module.exports = new ScriptChainService();
+688
View File
@@ -0,0 +1,688 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { getDb } = require('../db/database');
const logger = require('./logger').child('SCRIPTS');
const settingsService = require('./settingsService');
const runtimeActivityService = require('./runtimeActivityService');
const { streamLines } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
const SCRIPT_NAME_MAX_LENGTH = 120;
const SCRIPT_BODY_MAX_LENGTH = 200000;
const SCRIPT_TEST_TIMEOUT_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 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 || ''),
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, 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, 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, order_index, created_at, updated_at)
VALUES (?, ?, ?, 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 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, 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);
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-'));
const scriptPath = path.join(tempDir, '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(tempDir, { recursive: true, force: true });
} catch (error) {
logger.warn('script:temp-cleanup-failed', {
scriptId: script?.id ?? null,
scriptName: name,
tempDir,
error: errorToMeta(error)
});
}
};
return {
tempDir,
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
+316
View File
@@ -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
};
+428
View File
@@ -0,0 +1,428 @@
'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 = 10000;
class TmdbService {
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();
return {
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE'
};
}
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';
}
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 language = await this.resolveLanguage(options.language);
const 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) => {
logger.warn('search:failed', {
query: normalizedQuery,
error: error?.message || String(error)
});
return null;
});
const rows = Array.isArray(data?.results) ? data.results : [];
return 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);
}
async getSeriesDetails(seriesId, options = {}) {
const id = Number(seriesId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
const appendToResponse = Array.isArray(options.appendToResponse)
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return 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),
error: error?.message || String(error)
});
return null;
});
}
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 language = await this.resolveLanguage(options.language);
return this.request(`/tv/episode_group/${normalizedId}`, {
query: {
language
}
}).catch((error) => {
logger.warn('episode-group:details:failed', {
groupId: normalizedId,
error: error?.message || String(error)
});
return null;
});
}
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 language = await this.resolveLanguage(options.language);
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
query: {
language
}
}).catch(() => null);
}
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.normalizeNameList(
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
{ maxItems: 5 }
);
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
};
}
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 : []
};
}
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);
if (candidates.length === 0) {
return [];
}
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
}));
}));
return expanded.flat().filter((item) => item?.tmdbId && item?.title);
}
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
const normalizedSeason = Number(seasonNumber);
if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) {
return [];
}
const candidates = await this.searchSeries(query, options);
if (candidates.length === 0) {
return [];
}
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)
};
}));
return withSeasons
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
seasonNumber: normalizedSeason
}));
}
}
module.exports = new TmdbService();
+133
View File
@@ -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
};
+121
View File
@@ -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();
+57
View File
@@ -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
+18
View File
@@ -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
};
+146
View File
@@ -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
};
+742
View File
@@ -0,0 +1,742 @@
const LARGE_JUMP_THRESHOLD = 20;
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2;
const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024;
function parseDurationSeconds(raw) {
const text = String(raw || '').trim();
if (!text) {
return 0;
}
const hms = text.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/);
if (hms) {
const h = Number(hms[1]);
const m = Number(hms[2]);
const s = Number(hms[3]);
return (h * 3600) + (m * 60) + s;
}
const hm = text.match(/^(\d{1,2}):(\d{2})(?:\.\d+)?$/);
if (hm) {
const m = Number(hm[1]);
const s = Number(hm[2]);
return (m * 60) + s;
}
const asNumber = Number(text);
if (Number.isFinite(asNumber) && asNumber > 0) {
return Math.round(asNumber);
}
return 0;
}
function formatDuration(seconds) {
const total = Number(seconds || 0);
if (!Number.isFinite(total) || total <= 0) {
return '-';
}
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function parseSizeBytes(raw) {
const text = String(raw || '').trim();
if (!text) {
return 0;
}
if (/^\d+$/.test(text)) {
const direct = Number(text);
return Number.isFinite(direct) ? Math.max(0, Math.round(direct)) : 0;
}
const match = text.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
if (!match) {
return 0;
}
const value = Number(match[1]);
if (!Number.isFinite(value)) {
return 0;
}
const unit = String(match[2] || '').toUpperCase();
const factorByUnit = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4
};
const factor = factorByUnit[unit] || 1;
return Math.max(0, Math.round(value * factor));
}
function normalizePlaylistId(raw) {
const value = String(raw || '').trim().toLowerCase();
if (!value) {
return null;
}
const match = value.match(/(\d{1,5})(?:\.mpls)?$/i);
if (!match) {
return null;
}
return String(match[1]).padStart(5, '0');
}
function toSegmentFile(segmentNumber) {
const value = Number(segmentNumber);
if (!Number.isFinite(value) || value < 0) {
return null;
}
return `${String(Math.trunc(value)).padStart(5, '0')}.m2ts`;
}
function parseSegmentNumbers(raw) {
const text = String(raw || '').trim();
if (!text) {
return [];
}
const matches = text.match(/\d{1,6}/g) || [];
return matches
.map((item) => Number(item))
.filter((value) => Number.isFinite(value) && value >= 0)
.map((value) => Math.trunc(value));
}
function extractPlaylistMapping(line) {
const raw = String(line || '');
// Robot message typically maps playlist to title id.
const msgMatch = raw.match(/MSG:3016.*,"(\d{5}\.mpls)","(\d+)"/i);
if (msgMatch) {
return {
playlistId: normalizePlaylistId(msgMatch[1]),
titleId: Number(msgMatch[2])
};
}
const textMatch = raw.match(/(?:file|datei)\s+(\d{5}\.mpls).*?(?:title\s*#|titel\s*#?\s*)(\d+)/i);
if (textMatch) {
return {
playlistId: normalizePlaylistId(textMatch[1]),
titleId: Number(textMatch[2])
};
}
return null;
}
function parseAnalyzeTitles(lines) {
const titleMap = new Map();
const ensureTitle = (titleId) => {
if (!titleMap.has(titleId)) {
titleMap.set(titleId, {
titleId,
playlistId: null,
playlistIdFromMap: null,
playlistIdFromField16: null,
playlistFile: null,
durationSeconds: 0,
durationLabel: null,
sizeBytes: 0,
sizeLabel: null,
chapters: 0,
segmentNumbers: [],
segmentFiles: [],
streams: {},
fields: {}
});
}
return titleMap.get(titleId);
};
for (const line of lines || []) {
const mapping = extractPlaylistMapping(line);
if (mapping && Number.isFinite(mapping.titleId) && mapping.titleId >= 0) {
const title = ensureTitle(mapping.titleId);
title.playlistIdFromMap = normalizePlaylistId(mapping.playlistId);
}
const sinfo = String(line || '').match(/^SINFO:(\d+),(\d+),(\d+),\d+,"([^"]*)"/i);
if (sinfo) {
const titleId = Number(sinfo[1]);
const streamIndex = Number(sinfo[2]);
const fieldId = Number(sinfo[3]);
const value = String(sinfo[4] || '').trim();
if (
Number.isFinite(titleId) && titleId >= 0
&& Number.isFinite(streamIndex) && streamIndex >= 0
&& Number.isFinite(fieldId)
) {
const title = ensureTitle(titleId);
const streamKey = String(Math.trunc(streamIndex));
if (!title.streams[streamKey]) {
title.streams[streamKey] = {
index: Math.trunc(streamIndex),
type: null,
language: null,
languageLabel: null,
format: null,
channels: null,
description: null
};
}
const stream = title.streams[streamKey];
if (fieldId === 1) {
const lowered = value.toLowerCase();
if (lowered.includes('audio')) {
stream.type = 'audio';
} else if (lowered.includes('subtitle') || lowered.includes('untertitel') || lowered.includes('text')) {
stream.type = 'subtitle';
}
} else if (fieldId === 3) {
stream.language = value ? value.toLowerCase() : null;
} else if (fieldId === 4) {
stream.languageLabel = value || null;
} else if (fieldId === 6 || fieldId === 7) {
if (!stream.format || fieldId === 6) {
stream.format = value || null;
}
} else if (fieldId === 14 || fieldId === 40) {
if (!stream.channels || fieldId === 40) {
stream.channels = value || null;
}
} else if (fieldId === 30) {
stream.description = value || null;
}
}
continue;
}
const tinfo = String(line || '').match(/^TINFO:(\d+),(\d+),\d+,"([^"]*)"/i);
if (!tinfo) {
continue;
}
const titleId = Number(tinfo[1]);
const fieldId = Number(tinfo[2]);
const value = String(tinfo[3] || '').trim();
if (!Number.isFinite(titleId) || titleId < 0) {
continue;
}
const title = ensureTitle(titleId);
title.fields[fieldId] = value;
if (fieldId === 16) {
const fromField = normalizePlaylistId(value);
if (fromField) {
title.playlistIdFromField16 = fromField;
}
continue;
}
if (fieldId === 26) {
const segmentNumbers = parseSegmentNumbers(value);
if (segmentNumbers.length > 0) {
title.segmentNumbers = segmentNumbers;
}
continue;
}
if (fieldId === 9) {
const seconds = parseDurationSeconds(value);
if (seconds > 0) {
title.durationSeconds = seconds;
title.durationLabel = formatDuration(seconds);
}
continue;
}
if (fieldId === 10 || fieldId === 11) {
const bytes = parseSizeBytes(value);
if (bytes > 0) {
title.sizeBytes = bytes;
title.sizeLabel = value;
}
continue;
}
if (fieldId === 8 || fieldId === 7) {
const chapters = Number(value);
if (Number.isFinite(chapters) && chapters >= 0) {
title.chapters = Math.trunc(chapters);
}
}
if (!title.durationSeconds && /\d+:\d{2}:\d{2}/.test(value)) {
const seconds = parseDurationSeconds(value);
if (seconds > 0) {
title.durationSeconds = seconds;
title.durationLabel = formatDuration(seconds);
}
}
if (!title.sizeBytes && /(kb|mb|gb|tb)\b/i.test(value)) {
const bytes = parseSizeBytes(value);
if (bytes > 0) {
title.sizeBytes = bytes;
title.sizeLabel = value;
}
}
}
return Array.from(titleMap.values())
.map((item) => {
const playlistId = normalizePlaylistId(item.playlistId);
const playlistIdFromMap = normalizePlaylistId(item.playlistIdFromMap);
const playlistIdFromField16 = normalizePlaylistId(item.playlistIdFromField16);
const field16Raw = String(item?.fields?.[16] || '').trim();
const hasField16 = field16Raw.length > 0;
const field16LooksPlaylist = /\.mpls$/i.test(field16Raw) || /^\d{1,5}$/i.test(field16Raw);
const field16LooksClip = /\.(?:m2ts|m2t|mts)$/i.test(field16Raw);
let resolvedPlaylistId = null;
// TINFO:16 is part of the final title block and is more reliable than MSG:3307
// lines, which can include pre-dedup title ids.
if (field16LooksPlaylist && playlistIdFromField16) {
resolvedPlaylistId = playlistIdFromField16;
} else if (!hasField16) {
resolvedPlaylistId = playlistIdFromField16 || playlistIdFromMap || playlistId;
} else if (!field16LooksClip && playlistIdFromField16) {
resolvedPlaylistId = playlistIdFromField16;
}
const segmentNumbers = Array.isArray(item.segmentNumbers) ? item.segmentNumbers : [];
const segmentFiles = segmentNumbers
.map((number) => toSegmentFile(number))
.filter(Boolean);
const streams = item?.streams && typeof item.streams === 'object' ? Object.values(item.streams) : [];
const sortedStreams = streams
.filter((stream) => Number.isFinite(Number(stream?.index)))
.sort((a, b) => Number(a.index) - Number(b.index));
const audioTracks = sortedStreams
.filter((stream) => String(stream?.type || '').toLowerCase() === 'audio')
.map((stream) => ({
id: Number(stream.index) + 1,
sourceTrackId: Number(stream.index) + 1,
language: stream.language || 'und',
languageLabel: stream.languageLabel || stream.language || 'und',
title: stream.description || null,
format: stream.format || null,
channels: stream.channels || null
}));
const subtitleTracks = sortedStreams
.filter((stream) => String(stream?.type || '').toLowerCase() === 'subtitle')
.map((stream) => ({
id: Number(stream.index) + 1,
sourceTrackId: Number(stream.index) + 1,
language: stream.language || 'und',
languageLabel: stream.languageLabel || stream.language || 'und',
title: stream.description || null,
format: stream.format || null,
channels: null
}));
const { streams: _omitStreams, ...restItem } = item;
return {
...restItem,
playlistId: resolvedPlaylistId,
playlistIdFromMap,
playlistIdFromField16,
playlistFile: resolvedPlaylistId ? `${resolvedPlaylistId}.mpls` : null,
durationLabel: item.durationLabel || formatDuration(item.durationSeconds),
audioTracks,
subtitleTracks,
audioTrackCount: audioTracks.length,
subtitleTrackCount: subtitleTracks.length,
segmentNumbers,
segmentFiles
};
})
.sort((a, b) => a.titleId - b.titleId);
}
function uniqueOrdered(values) {
const seen = new Set();
const output = [];
for (const value of values || []) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
output.push(String(value).trim());
}
return output;
}
function parseReportedTitleCount(lines) {
for (let index = (Array.isArray(lines) ? lines.length : 0) - 1; index >= 0; index -= 1) {
const line = String(lines[index] || '').trim();
const match = line.match(/^TCOUNT:(\d+)/i);
if (!match) {
continue;
}
const value = Number(match[1]);
if (Number.isFinite(value) && value >= 0) {
return Math.trunc(value);
}
}
return null;
}
function likelyRawMirrorOfPlaylist(rawTitle, playlistTitle) {
const rawDuration = Number(rawTitle?.durationSeconds || 0);
const playlistDuration = Number(playlistTitle?.durationSeconds || 0);
const rawSize = Number(rawTitle?.sizeBytes || 0);
const playlistSize = Number(playlistTitle?.sizeBytes || 0);
if (!Number.isFinite(rawDuration) || !Number.isFinite(playlistDuration) || rawDuration <= 0 || playlistDuration <= 0) {
return false;
}
if (Math.abs(rawDuration - playlistDuration) > RAW_MIRROR_DURATION_TOLERANCE_SECONDS) {
return false;
}
if (rawSize > 0 && playlistSize > 0) {
return Math.abs(rawSize - playlistSize) <= RAW_MIRROR_SIZE_TOLERANCE_BYTES;
}
return true;
}
function suppressRawMirrorCandidates(candidates) {
const rows = Array.isArray(candidates) ? candidates : [];
if (rows.length <= 1) {
return rows;
}
const playlistRows = rows.filter((item) => normalizePlaylistId(item?.playlistId));
if (playlistRows.length === 0) {
return rows;
}
return rows.filter((item) => {
if (normalizePlaylistId(item?.playlistId)) {
return true;
}
return !playlistRows.some((playlistRow) => likelyRawMirrorOfPlaylist(item, playlistRow));
});
}
function buildSimilarityGroups(candidates, durationSimilaritySeconds) {
const list = Array.isArray(candidates) ? [...candidates] : [];
const tolerance = Math.max(0, Math.round(Number(durationSimilaritySeconds || 0)));
const groups = [];
const used = new Set();
for (let i = 0; i < list.length; i += 1) {
if (used.has(i)) {
continue;
}
const base = list[i];
const currentGroup = [base];
used.add(i);
for (let j = i + 1; j < list.length; j += 1) {
if (used.has(j)) {
continue;
}
const candidate = list[j];
if (Math.abs(Number(candidate.durationSeconds || 0) - Number(base.durationSeconds || 0)) <= tolerance) {
currentGroup.push(candidate);
used.add(j);
}
}
if (currentGroup.length > 1) {
const sortedTitles = currentGroup
.slice()
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
const referenceDuration = Number(sortedTitles[0]?.durationSeconds || 0);
groups.push({
durationSeconds: referenceDuration,
durationLabel: formatDuration(referenceDuration),
titles: sortedTitles
});
}
}
return groups.sort((a, b) =>
b.durationSeconds - a.durationSeconds || b.titles.length - a.titles.length
);
}
function computeSegmentMetrics(segmentNumbers) {
const numbers = Array.isArray(segmentNumbers)
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
if (numbers.length === 0) {
return {
segmentCount: 0,
segmentNumbers: [],
directSequenceSteps: 0,
backwardJumps: 0,
largeJumps: 0,
alternatingJumps: 0,
alternatingPairs: 0,
alternatingRatio: 0,
sequenceCoherence: 0,
monotonicRatio: 0,
score: 0
};
}
let directSequenceSteps = 0;
let backwardJumps = 0;
let largeJumps = 0;
let alternatingJumps = 0;
let alternatingPairs = 0;
let prevDiff = null;
for (let i = 1; i < numbers.length; i += 1) {
const current = numbers[i - 1];
const next = numbers[i];
const diff = next - current;
if (next < current) {
backwardJumps += 1;
}
if (Math.abs(diff) > LARGE_JUMP_THRESHOLD) {
largeJumps += 1;
}
if (diff === 1) {
directSequenceSteps += 1;
}
if (prevDiff !== null) {
const largePair = Math.abs(prevDiff) > LARGE_JUMP_THRESHOLD && Math.abs(diff) > LARGE_JUMP_THRESHOLD;
if (largePair) {
alternatingPairs += 1;
const signChanged = (prevDiff < 0 && diff > 0) || (prevDiff > 0 && diff < 0);
if (signChanged) {
alternatingJumps += 1;
}
}
}
prevDiff = diff;
}
const transitions = Math.max(1, numbers.length - 1);
const sequenceCoherence = Number((directSequenceSteps / transitions).toFixed(4));
const alternatingRatio = alternatingPairs > 0
? Number((alternatingJumps / alternatingPairs).toFixed(4))
: 0;
const score = (directSequenceSteps * 2) - (backwardJumps * 3) - (largeJumps * 2);
return {
segmentCount: numbers.length,
segmentNumbers: numbers,
directSequenceSteps,
backwardJumps,
largeJumps,
alternatingJumps,
alternatingPairs,
alternatingRatio,
sequenceCoherence,
monotonicRatio: sequenceCoherence,
score
};
}
function buildEvaluationLabel(metrics) {
if (!metrics || metrics.segmentCount === 0) {
return 'Keine Segmentliste aus TINFO:26 verfügbar';
}
if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) {
return 'Fake-Struktur (alternierendes Sprungmuster)';
}
if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) {
return 'Auffällige Segmentreihenfolge';
}
return 'wahrscheinlich korrekt (lineare Segmentfolge)';
}
function scoreCandidates(groupTitles) {
const titles = Array.isArray(groupTitles) ? groupTitles : [];
if (titles.length === 0) {
return [];
}
return titles
.map((title) => {
const metrics = computeSegmentMetrics(title.segmentNumbers);
const reasons = [
`sequence_steps=${metrics.directSequenceSteps}`,
`sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`,
`backward_jumps=${metrics.backwardJumps}`,
`large_jumps=${metrics.largeJumps}`,
`alternating_ratio=${metrics.alternatingRatio.toFixed(3)}`
];
return {
...title,
score: Number(metrics.score || 0),
reasons,
structuralMetrics: metrics,
evaluationLabel: buildEvaluationLabel(metrics)
};
})
.sort((a, b) =>
b.score - a.score
|| b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence
|| b.durationSeconds - a.durationSeconds
|| b.sizeBytes - a.sizeBytes
|| a.titleId - b.titleId
)
.map((item, index) => ({
...item,
recommended: index === 0
}));
}
function buildPlaylistSegmentMap(titles) {
const map = {};
for (const title of titles || []) {
const playlistId = normalizePlaylistId(title?.playlistId);
if (!playlistId || map[playlistId]) {
continue;
}
map[playlistId] = {
playlistId,
playlistFile: `${playlistId}.mpls`,
playlistPath: `BDMV/PLAYLIST/${playlistId}.mpls`,
segmentCommand: `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
segmentFiles: Array.isArray(title?.segmentFiles) ? title.segmentFiles : [],
segmentNumbers: Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [],
fileExists: null,
source: 'makemkv_tinfo_26'
};
}
return map;
}
function buildPlaylistToTitleIdMap(titles) {
const map = {};
for (const title of titles || []) {
const playlistId = normalizePlaylistId(title?.playlistId || title?.playlistFile || null);
const titleId = Number(title?.titleId);
if (!playlistId || !Number.isFinite(titleId) || titleId < 0) {
continue;
}
const normalizedTitleId = Math.trunc(titleId);
if (map[playlistId] === undefined) {
map[playlistId] = normalizedTitleId;
}
const playlistFile = `${playlistId}.mpls`;
if (map[playlistFile] === undefined) {
map[playlistFile] = normalizedTitleId;
}
}
return map;
}
function extractWarningLines(lines) {
return (Array.isArray(lines) ? lines : [])
.filter((line) => /warn|warning|error|fehler|decode|decoder|timeout|corrupt/i.test(String(line || '')))
.slice(0, 40)
.map((line) => String(line || '').slice(0, 260));
}
function extractPlaylistMismatchWarnings(titles) {
return (Array.isArray(titles) ? titles : [])
.filter((title) => title?.playlistIdFromMap && title?.playlistIdFromField16)
.filter((title) => String(title.playlistIdFromMap) !== String(title.playlistIdFromField16))
.slice(0, 25)
.map((title) =>
`Titel #${title.titleId}: MSG-Playlist=${title.playlistIdFromMap}.mpls, TINFO16=${title.playlistIdFromField16}.mpls (TINFO16 bevorzugt)`
);
}
function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) {
const parsedTitles = parseAnalyzeTitles(lines);
const reportedTitleCount = parseReportedTitleCount(lines);
const minSeconds = Math.max(0, Math.round(Number(minLengthMinutes || 0) * 60));
const durationSimilaritySeconds = Math.max(
0,
Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS))
);
const candidatesRaw = parsedTitles
.filter((item) => Number(item.durationSeconds || 0) >= minSeconds)
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
const candidates = suppressRawMirrorCandidates(candidatesRaw)
.slice()
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
const playlistBackedCandidates = candidates
.filter((item) => normalizePlaylistId(item?.playlistId));
const candidatePlaylistsAll = uniqueOrdered(
playlistBackedCandidates.map((item) => item.playlistId).filter(Boolean)
);
const similarityGroups = buildSimilarityGroups(playlistBackedCandidates, durationSimilaritySeconds);
const obfuscationDetected = similarityGroups.length > 0;
const multipleCandidatesDetected = candidatePlaylistsAll.length > 1;
const manualDecisionRequired = multipleCandidatesDetected;
const decisionPool = manualDecisionRequired ? playlistBackedCandidates : [];
const evaluatedCandidates = decisionPool.length > 0 ? scoreCandidates(decisionPool) : [];
const recommendation = evaluatedCandidates[0] || null;
const candidatePlaylists = manualDecisionRequired ? candidatePlaylistsAll : [];
const playlistSegments = buildPlaylistSegmentMap(decisionPool);
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles);
return {
generatedAt: new Date().toISOString(),
reportedTitleCount,
minLengthMinutes: Number(minLengthMinutes || 0),
minLengthSeconds: minSeconds,
durationSimilaritySeconds,
titles: parsedTitles,
candidates,
duplicateDurationGroups: similarityGroups,
obfuscationDetected,
manualDecisionRequired,
manualDecisionReason: manualDecisionRequired
? (obfuscationDetected ? 'multiple_similar_candidates' : 'multiple_candidates_after_min_length')
: null,
candidatePlaylists,
candidatePlaylistFiles: candidatePlaylists.map((item) => `${item}.mpls`),
playlistToTitleId,
recommendation: recommendation
? {
titleId: recommendation.titleId,
playlistId: recommendation.playlistId,
score: Number(recommendation.score || 0),
reason: Array.isArray(recommendation.reasons) && recommendation.reasons.length > 0
? recommendation.reasons.join('; ')
: 'höchster Struktur-Score'
}
: null,
evaluatedCandidates,
playlistSegments,
structuralAnalysis: {
method: 'makemkv_tinfo_26',
sourceCommand: 'makemkvcon -r info disc:0 --robot',
analyzedPlaylists: Object.keys(playlistSegments).length
},
warningLines: [
...extractWarningLines(lines),
...(reportedTitleCount !== null && reportedTitleCount !== parsedTitles.length
? [`Titel-Anzahl abweichend: TCOUNT=${reportedTitleCount}, geparst=${parsedTitles.length}`]
: []),
...extractPlaylistMismatchWarnings(parsedTitles)
].slice(0, 60)
};
}
module.exports = {
normalizePlaylistId,
analyzePlaylistObfuscation
};
+100
View File
@@ -0,0 +1,100 @@
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;
}
module.exports = {
parseMakeMkvProgress,
parseHandBrakeProgress,
parseCdParanoiaProgress
};
+112
View File
@@ -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
};
BIN
View File
Binary file not shown.
+712
View File
@@ -0,0 +1,712 @@
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,
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,
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 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,
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,
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 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 IF NOT EXISTS aax_activation_bytes (
checksum TEXT PRIMARY KEY,
activation_bytes TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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_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 ('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_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 ('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');
-- 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 vor Analyze/Rip automatisch per "makemkvcon reg" gesetzt.', 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 ('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 ('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');
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})');
-- 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 ('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');
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}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{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}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}');
-- 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 ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true');
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 ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
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');
-- 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 Metadaten-Auswahl senden', 'boolean', 1, 'Sendet wenn Metadaten zur Auswahl bereitstehen.', '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');
+290
View File
@@ -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" }
```
+182
View File
@@ -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`)
+143
View File
@@ -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).
+221
View File
@@ -0,0 +1,221 @@
# History API
Endpunkte für Job-Historie, Orphan-Import und Löschoperationen.
---
## GET /api/history
Liefert Jobs (optionale Filter).
**Query-Parameter:**
| Parameter | Typ | Beschreibung |
|----------|-----|-------------|
| `status` | string | Filter nach Job-Status |
| `search` | string | Suche in Titel-Feldern |
**Beispiel:**
```text
GET /api/history?status=FINISHED&search=Inception
```
**Response:**
```json
{
"jobs": [
{
"id": 42,
"status": "FINISHED",
"title": "Inception",
"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` | alias-artig ebenfalls Prozesslog laden |
| `includeAllLogs` | bool | `false` | vollständiges Log statt Tail |
| `logTailLines` | number | `800` | Tail-Länge falls nicht `includeAllLogs` |
**Response:**
```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.
**Response:**
```json
{
"rawDir": "/mnt/raw",
"rawDirs": ["/mnt/raw", "/mnt/raw-bluray"],
"rows": [
{
"rawPath": "/mnt/raw/Inception (2010) [tt1375666] - RAW - job-99",
"folderName": "Inception (2010) [tt1375666] - RAW - job-99",
"title": "Inception",
"year": 2010,
"imdbId": "tt1375666",
"folderJobId": 99,
"entryCount": 4,
"hasBlurayStructure": true,
"lastModifiedAt": "2026-03-10T09:00:00.000Z"
}
]
}
```
---
## POST /api/history/orphan-raw/import
Importiert RAW-Ordner als FINISHED-Job.
**Request:**
```json
{ "rawPath": "/mnt/raw/Inception (2010) [tt1375666] - RAW - job-99" }
```
**Response:**
```json
{
"job": { "id": 77, "status": "FINISHED" },
"uiReset": { "reset": true, "state": "IDLE" }
}
```
---
## POST /api/history/:id/omdb/assign
Weist OMDb-/Metadaten nachträglich zu.
**Request:**
```json
{
"imdbId": "tt1375666",
"title": "Inception",
"year": 2010,
"poster": "https://...",
"fromOmdb": true
}
```
**Response:**
```json
{ "job": { "id": 42, "imdb_id": "tt1375666" } }
```
---
## POST /api/history/:id/delete-files
Löscht Dateien eines Jobs, behält DB-Eintrag.
**Request:**
```json
{ "target": "both" }
```
`target`: `raw` | `movie` | `both`
**Response:**
```json
{
"summary": {
"target": "both",
"raw": { "attempted": true, "deleted": true, "filesDeleted": 12, "dirsRemoved": 3, "reason": null },
"movie": { "attempted": true, "deleted": false, "filesDeleted": 0, "dirsRemoved": 0, "reason": "Movie-Datei/Pfad existiert nicht." }
},
"job": { "id": 42 }
}
```
---
## POST /api/history/:id/delete
Löscht Job aus DB; optional auch Dateien.
**Request:**
```json
{ "target": "none" }
```
`target`: `none` | `raw` | `movie` | `both`
**Response:**
```json
{
"deleted": true,
"jobId": 42,
"fileTarget": "both",
"fileSummary": {
"target": "both",
"raw": { "filesDeleted": 10 },
"movie": { "filesDeleted": 1 }
},
"uiReset": {
"reset": true,
"state": "IDLE"
}
}
```
---
## Hinweise
- Ein aktiver Pipeline-Job kann nicht gelöscht werden (`409`).
- Alle Löschoperationen sind irreversibel.
+93
View File
@@ -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.
+369
View File
@@ -0,0 +1,369 @@
# 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"
}
}
}
```
---
## GET /api/pipeline/omdb/search?q=<query>
OMDb-Titelsuche.
**Response:**
```json
{
"results": [
{
"imdbId": "tt1375666",
"title": "Inception",
"year": "2010",
"type": "movie",
"poster": "https://..."
}
]
}
```
---
## 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"
}
```
**Response:**
```json
{ "job": { "id": 42, "status": "READY_TO_START" } }
```
---
## 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/resume-ready/:jobId
Lädt `READY_TO_ENCODE`-Job nach Neustart wieder in aktive Session.
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` | Playlist-Entscheidung erforderlich |
| `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 |
| `FINISHED` | Abgeschlossen |
| `CANCELLED` | Abgebrochen |
| `ERROR` | Fehler |
+235
View File
@@ -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/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/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/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/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/activities` nur beim initialen Laden aufzurufen.
+338
View File
@@ -0,0 +1,338 @@
# 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": { ... } }`.
+294
View File
@@ -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
+30
View File
@@ -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
+241
View File
@@ -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-Registration-Command aus `makemkv_registration_key`
- 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
- OMDb-Nachzuweisung
- 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)
- `omdbService.js` (OMDb-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)
+186
View File
@@ -0,0 +1,186 @@
# 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`)
- Prüfsumme: `aax_checksum` (für AAX-Dateien)
- Audit: `created_at`, `updated_at`
---
## `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 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;
```
+117
View File
@@ -0,0 +1,117 @@
# 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
- OMDb-Nachzuweisung
- 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
### `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` — OMDb-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
```
+52
View File
@@ -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>
+94
View File
@@ -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
+67
View File
@@ -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
+29
View File
@@ -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)
+181
View File
@@ -0,0 +1,181 @@
# Einstellungsreferenz
Diese Seite listet die Felder so, wie sie in der GUI unter `Settings` angezeigt werden.
Hinweis: Interne Schlüsselnamen werden hier bewusst nicht verwendet. Falls du sie für Integrationen brauchst, nutze die API-Dokumentation.
---
## Profil-System
Viele Felder sind pro Medientyp getrennt vorhanden:
- Blu-ray
- DVD
- Sonstiges
- Audiobook (neu ab v0.12.0)
---
## Template-Platzhalter
### Video-Templates
- `${title}` — Titel
- `${year}` — Erscheinungsjahr
- `${imdbId}` — IMDb-ID
### Audiobook-Templates
- `{author}` — Autor
- `{title}` — Titel
- `{year}` — Erscheinungsjahr
- `{narrator}` — Sprecher
- `{series}` — Buchreihe
- `{part}` — Teil-Nummer
- `{format}` — Ausgabeformat
### Converter-Templates (Video)
- `{title}`, `{year}`
### Converter-Templates (Audio)
- `{artist}`, `{title}`, `{album}`, `{year}`, `{trackNr}`
Nicht gesetzte Werte werden zu `unknown`.
---
## Kategorie: Pfade
| Feldname in der GUI | Typ | Default |
|---|---|---|
| `Raw Ausgabeordner` | path | `data/output/raw` |
| `Raw Ausgabeordner (Blu-ray)` | path | `null` |
| `Raw Ausgabeordner (DVD)` | path | `null` |
| `Raw Ausgabeordner (Sonstiges)` | path | `null` |
| `Eigentümer Raw-Ordner (Blu-ray)` | string | `null` |
| `Eigentümer Raw-Ordner (DVD)` | string | `null` |
| `Eigentümer Raw-Ordner (Sonstiges)` | string | `null` |
| `Film Ausgabeordner` | path | `data/output/movies` |
| `Film Ausgabeordner (Blu-ray)` | path | `null` |
| `Film Ausgabeordner (DVD)` | path | `null` |
| `Film Ausgabeordner (Sonstiges)` | path | `null` |
| `Eigentümer Film-Ordner (Blu-ray)` | string | `null` |
| `Eigentümer Film-Ordner (DVD)` | string | `null` |
| `Eigentümer Film-Ordner (Sonstiges)` | string | `null` |
| `Log Ordner` | path | `data/logs` |
| `Audiobook RAW-Ordner` | path | `null` |
| `Eigentümer Audiobook RAW-Ordner` | string | `null` |
| `Audiobook Output-Ordner` | path | `null` |
| `Eigentümer Audiobook Output-Ordner` | string | `null` |
| `Output Template (Audiobook)` | string | `{author}/{author} - {title} ({year})` |
| `Audiobook RAW Template` | string | `{author} - {title} ({year})` |
| `Converter Raw-Ordner` | path | `null` |
| `Converter Ausgabe (Video)` | path | `null` |
| `Converter Ausgabe (Audio)` | path | `null` |
| `Output-Template (Video)` | string | `{title}` |
| `Output-Template (Audio)` | string | `{artist} - {title}` |
---
## Kategorie: Laufwerk
| Feldname in der GUI | Typ | Default | Hinweis |
|---|---|---|---|
| `Laufwerksmodus` | select | `auto` | `Auto Discovery` oder `Explizites Device` |
| `Device Pfad` | path | `/dev/sr0` | relevant bei `Explizites Device` |
| `MakeMKV Source Index` | number | `0` | Disc-Index im Auto-Modus |
| `Disc Auto-Erkennung aktiviert` | boolean | `true` | Automatische Disc-Erkennung per Polling |
| `Polling Intervall (ms)` | number | `4000` | 1000..60000 |
---
## Kategorie: Monitoring
| Feldname in der GUI | Typ | Default |
|---|---|---|
| `Hardware Monitoring aktiviert` | boolean | `true` |
| `Hardware Monitoring Intervall (ms)` | number | `5000` |
---
## Kategorie: Tools (global)
| Feldname in der GUI | Typ | Default |
|---|---|---|
| `MakeMKV Kommando` | string | `makemkvcon` |
| `MakeMKV Key` | string | `null` |
| `Mediainfo Kommando` | string | `mediainfo` |
| `Minimale Titellaenge (Minuten)` | number | `60` |
| `HandBrake Kommando` | string | `HandBrakeCLI` |
| `FFmpeg Kommando` | string | `ffmpeg` |
| `FFprobe Kommando` | string | `ffprobe` |
| `Encode-Neustart: unvollständige Ausgabe löschen` | boolean | `true` |
| `Parallele Jobs` | number | `1` |
### Blu-ray-spezifisch
| Feldname in der GUI | Typ | Default |
|---|---|---|
| `Mediainfo Extra Args` (Blu-ray) | string | `null` |
| `MakeMKV Rip Modus` (Blu-ray) | select | `backup` |
| `MakeMKV Analyze Extra Args` (Blu-ray) | string | `null` |
| `MakeMKV Rip Extra Args` (Blu-ray) | string | `null` |
| `HandBrake Preset` (Blu-ray) | string | `H.264 MKV 1080p30` |
| `HandBrake Extra Args` (Blu-ray) | string | `null` |
| `Ausgabeformat` (Blu-ray) | select | `mkv` |
| `Dateiname Template` (Blu-ray) | string | `${title} (${year})` |
| `Ordnername Template` (Blu-ray) | string | `null` |
### DVD-spezifisch
| Feldname in der GUI | Typ | Default |
|---|---|---|
| `Mediainfo Extra Args` (DVD) | string | `null` |
| `MakeMKV Rip Modus` (DVD) | select | `mkv` |
| `MakeMKV Analyze Extra Args` (DVD) | string | `null` |
| `MakeMKV Rip Extra Args` (DVD) | string | `null` |
| `HandBrake Preset` (DVD) | string | `H.264 MKV 480p30` |
| `HandBrake Extra Args` (DVD) | string | `null` |
| `Ausgabeformat` (DVD) | select | `mkv` |
| `Dateiname Template` (DVD) | string | `${title} (${year})` |
| `Ordnername Template` (DVD) | string | `null` |
---
## Kategorie: Converter
| Feldname in der GUI | Typ | Default | Hinweis |
|---|---|---|---|
| `Erlaubte Datei-Endungen*` | string | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus` | In der UI als Checkbox-Liste, ohne Punkt |
| `Auto-Scan (Polling)` | boolean | `false` | Converter-Ordner automatisch scannen |
| `Polling-Intervall (Sekunden)` | number | `300` | 30..86400 |
---
## Kategorie: Metadaten
| Feldname in der GUI | Typ | Default |
|---|---|---|
| `OMDb API Key` | string | `null` |
| `OMDb Typ` | select | `movie` |
---
## Kategorie: Benachrichtigungen (PushOver)
| Feldname in der GUI | Typ | Default |
|---|---|---|
| `PushOver aktiviert` | boolean | `false` |
| `PushOver Token` | string | `null` |
| `PushOver User` | string | `null` |
| `PushOver Device (optional)` | string | `null` |
| `PushOver Titel-Präfix` | string | `Ripster` |
| `PushOver Priority` | number | `0` |
| `PushOver Timeout (ms)` | number | `7000` |
| `Bei Metadaten-Auswahl senden` | boolean | `true` |
| `Bei Rip-Start senden` | boolean | `true` |
| `Bei Encode-Start senden` | boolean | `true` |
| `Bei Erfolg senden` | boolean | `true` |
| `Bei Fehler senden` | boolean | `true` |
| `Bei Abbruch senden` | boolean | `true` |
| `Bei Re-Encode Start senden` | boolean | `true` |
| `Bei Re-Encode Erfolg senden` | boolean | `true` |
+85
View File
@@ -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
```
+23
View File
@@ -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>
+233
View File
@@ -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).
+71
View File
@@ -0,0 +1,71 @@
# Ersteinrichtung
Nach der Installation erfolgt die tägliche Konfiguration fast vollständig in der GUI unter `Settings`.
## Ziel
Vor dem ersten echten Job müssen Pfade, Tools und Metadatenzugriff sauber gesetzt sein.
## Reihenfolge (empfohlen)
### 1. `Settings` -> Tab `Konfiguration`
Setze zuerst diese Pflichtwerte:
| Bereich | Wichtige Felder |
|---|---|
| Pfade | `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` |
| Tools | `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando` |
| Metadaten | `OMDb API Key`, optional `OMDb Typ` |
Danach `Änderungen speichern`.
### 2. Medienprofile prüfen
Wenn du Blu-ray und DVD unterschiedlich behandeln willst, pflege die profilbezogenen Felder:
- `*_bluray`
- `*_dvd`
- optional `*_other`
Typische Beispiele:
- `HandBrake Preset` (Blu-ray und DVD)
- `Raw Ausgabeordner` (Blu-ray und DVD)
- `Dateiname Template` (Blu-ray und DVD)
### 3. Queue und Monitoring festlegen
- `Parallele Jobs` für den gleichzeitigen Durchsatz
- `Hardware Monitoring aktiviert` + `Hardware Monitoring Intervall (ms)` für Live-Metriken im Ripper
### 4. Optional: Push-Benachrichtigungen
In den Benachrichtigungsfeldern setzen:
- `PushOver aktiviert`
- `PushOver Token`
- `PushOver User`
Dann über `PushOver Test` direkt prüfen.
## 2-Minuten-Funktionstest
1. `Ripper` öffnen
2. Disc einlegen
3. `Analyse starten`
4. Metadaten übernehmen
5. Bis `Bereit zum Encodieren` laufen lassen
Wenn diese Schritte funktionieren, ist die Grundkonfiguration korrekt.
## Wenn Werte nicht gespeichert werden
- Feld mit Fehler markieren lassen (rote Validierung im Formular)
- Pfadangaben und numerische Werte prüfen
- bei Tool-Pfaden direkt CLI-Aufruf im Terminal testen
## Weiter
- [Erster Lauf](quickstart.md)
- [GUI-Seiten im Detail](../gui/index.md)

Some files were not shown because too many files have changed in this diff Show More