Compare commits
93 Commits
dev
...
1fb8dd85fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fb8dd85fb | |||
| 5d83d02d9f | |||
| f52befd4c4 | |||
| b5ca397d98 | |||
| 6378a9a4cb | |||
| c3875803ff | |||
| d75d9eb4c8 | |||
| 59bcb54492 | |||
| 241b097ea9 | |||
| e140a9fa8c | |||
| 5580d3be98 | |||
| 43dfdbf33e | |||
| 24b63d390a | |||
| 49fcca72af | |||
| ba91f83722 | |||
| 466e7a7a3d | |||
| e67c0d316d | |||
| 1da5ee3e34 | |||
| 4d377f3eb4 | |||
| df708485b5 | |||
| b6cac5efb4 | |||
| f38081649f | |||
| 5b41f728c5 | |||
| 7948dd298c | |||
| ee6603ffad | |||
| 93ed0e6eb2 | |||
| 778fabb2e5 | |||
| 5d8796404c | |||
| a2b9f3625c | |||
| 1e30f00b45 | |||
| 519b7a30ef | |||
| 3ae883326a | |||
| 46f415d778 | |||
| d473f296ff | |||
| e0bba8cbfc | |||
| 715dfbbc38 | |||
| 5cf869eaca | |||
| 3dd043689e | |||
| 882dad57e2 | |||
| 15f1a49378 | |||
| 871e39bba2 | |||
| d8a6b4c56d | |||
| 16e76d70c9 | |||
| 45a19c7a12 | |||
| 8af4da5a08 | |||
| c13ce5a50b | |||
| 190f6fe1a5 | |||
| 14162879c0 | |||
| e14599fa4d | |||
| 5576a92c5c | |||
| 70592d2825 | |||
| a095688d11 | |||
| aabb28ee06 | |||
| 3b66ca64f8 | |||
| 0f0cfffa7a | |||
| 6f6d4f1e58 | |||
| 275ded04b8 | |||
| e70c50ae87 | |||
| c23fbb53b7 | |||
| e0e89890fa | |||
| 40fa30b532 | |||
| f565f83aea | |||
| 6591c33caa | |||
| 5e6ec13c06 | |||
| 2cf523b8e3 | |||
| 7979b353aa | |||
| 2fdf54d2e6 | |||
| b93f5da8d7 | |||
| 5703a8d00a | |||
| ac4d77dddf | |||
| 3516ff8486 | |||
| 4c879d2485 | |||
| 1b07fa4f14 | |||
| 78ceb23a27 | |||
| fa6594b163 | |||
| 8e3c67565d | |||
| ac29c68de0 | |||
| c56b7bc13e | |||
| 05f29028f6 | |||
| 66184325d6 | |||
| 7204dbb65b | |||
| e1a87af16a | |||
| 3abb53fb8e | |||
| afca677b1c | |||
| 6836892907 | |||
| c3d1df42b0 | |||
| e3d890c071 | |||
| 23acea4773 | |||
| fe2ffbbe07 | |||
| 922d3e753d | |||
| 3957773854 | |||
| 3b293bb743 | |||
| 31d3e36597 |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Deploy Docs to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
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
|
||||||
|
run: mkdocs build --strict --verbose
|
||||||
|
|
||||||
|
- name: Upload Pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: 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
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
# ----------------------------
|
||||||
|
# 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/
|
||||||
|
/setup.sh
|
||||||
|
/Audible_Tool
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/usr/bin/python3
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
lib
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
home = /usr/bin
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.12.3
|
||||||
|
executable = /usr/bin/python3.12
|
||||||
|
command = /usr/bin/python3 -m venv /home/michael/ripster/.venv-docs
|
||||||
@@ -1 +1,219 @@
|
|||||||
# ripster
|
# Ripster
|
||||||
|
|
||||||
|
Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit MakeMKV + HandBrake inklusive Metadaten-Auswahl, Track-Review, Queue, Skripten/Ketten und Job-Historie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statushinweis: CD-Ripping (experimentell)
|
||||||
|
|
||||||
|
- Die grundlegende CD-Ripping-Funktion im ersten Durchgang funktioniert.
|
||||||
|
- Das Frontend ist dafür noch nicht vollständig angepasst.
|
||||||
|
- Funktionen wie Restart, bestimmte Ansichten und Folge-Workflows können aktuell eingeschränkt sein oder fehlschlagen.
|
||||||
|
- CD-Ripping wird weiterentwickelt und sollte derzeit als experimentell betrachtet werden.
|
||||||
|
|
||||||
|
## Was Ripster kann
|
||||||
|
|
||||||
|
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
|
||||||
|
- Medienprofil-Erkennung (Blu-ray/DVD/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
|
||||||
|
- 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 (Skripte, Ketten, Cron, Tasks) in Echtzeit im Dashboard
|
||||||
|
- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import
|
||||||
|
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Dashboard
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
- Backend: Node.js, Express, SQLite, WebSocket (`ws`)
|
||||||
|
- Frontend: React, Vite, PrimeReact
|
||||||
|
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- 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/Sonstiges-Varianten)
|
||||||
|
- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`
|
||||||
|
- Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat)
|
||||||
|
- 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/
|
||||||
|
routes/
|
||||||
|
services/
|
||||||
|
db/
|
||||||
|
utils/
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
pages/
|
||||||
|
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`
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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_CD_DIR=
|
||||||
Generated
+2509
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "ripster-backend",
|
||||||
|
"version": "0.9.1-6",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "nodemon src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"sqlite": "^5.1.1",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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'),
|
||||||
|
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd')
|
||||||
|
};
|
||||||
@@ -0,0 +1,902 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3');
|
||||||
|
const { open } = require('sqlite');
|
||||||
|
const { dbPath } = require('../config');
|
||||||
|
const logger = require('../services/logger').child('DB');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
const { setLogRootDir, getJobLogDir } = require('../services/logPathService');
|
||||||
|
|
||||||
|
const schemaFilePath = path.resolve(__dirname, '../../../db/schema.sql');
|
||||||
|
const LEGACY_PROFILE_SETTING_MIGRATIONS = [
|
||||||
|
{
|
||||||
|
legacyKey: 'mediainfo_extra_args',
|
||||||
|
profileKeys: ['mediainfo_extra_args_bluray', 'mediainfo_extra_args_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'makemkv_rip_mode',
|
||||||
|
profileKeys: ['makemkv_rip_mode_bluray', 'makemkv_rip_mode_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'makemkv_analyze_extra_args',
|
||||||
|
profileKeys: ['makemkv_analyze_extra_args_bluray', 'makemkv_analyze_extra_args_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'makemkv_rip_extra_args',
|
||||||
|
profileKeys: ['makemkv_rip_extra_args_bluray', 'makemkv_rip_extra_args_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'handbrake_preset',
|
||||||
|
profileKeys: ['handbrake_preset_bluray', 'handbrake_preset_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'handbrake_extra_args',
|
||||||
|
profileKeys: ['handbrake_extra_args_bluray', 'handbrake_extra_args_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'output_extension',
|
||||||
|
profileKeys: ['output_extension_bluray', 'output_extension_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'output_template',
|
||||||
|
profileKeys: ['output_template_bluray', 'output_template_dvd']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const INSTALL_PATH_SETTING_DEFAULTS = [
|
||||||
|
{
|
||||||
|
key: 'log_dir',
|
||||||
|
pathParts: ['logs'],
|
||||||
|
legacyDefaults: ['data/logs', './data/logs']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let dbInstance;
|
||||||
|
|
||||||
|
function nowFileStamp() {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSqliteCorruptionError(error) {
|
||||||
|
if (!error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(error.code || '').toUpperCase();
|
||||||
|
const msg = String(error.message || '').toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
code === 'SQLITE_CORRUPT' ||
|
||||||
|
msg.includes('database disk image is malformed') ||
|
||||||
|
msg.includes('file is not a database')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveIfExists(sourcePath, targetPath) {
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fs.renameSync(sourcePath, targetPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quarantineCorruptDatabaseFiles() {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
const base = path.basename(dbPath);
|
||||||
|
const stamp = nowFileStamp();
|
||||||
|
const archiveDir = path.join(dir, 'corrupt-backups');
|
||||||
|
|
||||||
|
fs.mkdirSync(archiveDir, { recursive: true });
|
||||||
|
|
||||||
|
const moved = [];
|
||||||
|
const candidates = [
|
||||||
|
dbPath,
|
||||||
|
`${dbPath}-wal`,
|
||||||
|
`${dbPath}-shm`
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sourcePath of candidates) {
|
||||||
|
const fileName = path.basename(sourcePath);
|
||||||
|
const targetPath = path.join(archiveDir, `${fileName}.${stamp}.corrupt`);
|
||||||
|
if (moveIfExists(sourcePath, targetPath)) {
|
||||||
|
moved.push({
|
||||||
|
from: sourcePath,
|
||||||
|
to: targetPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('recovery:quarantine-complete', {
|
||||||
|
dbPath,
|
||||||
|
base,
|
||||||
|
movedCount: moved.length,
|
||||||
|
moved
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteIdentifier(identifier) {
|
||||||
|
return `"${String(identifier || '').replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSqlType(value) {
|
||||||
|
return String(value || '').trim().replace(/\s+/g, ' ').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDefault(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value).trim().replace(/\s+/g, ' ').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTableShape(current = [], desired = []) {
|
||||||
|
if (current.length !== desired.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < current.length; i += 1) {
|
||||||
|
const left = current[i];
|
||||||
|
const right = desired[i];
|
||||||
|
if (!left || !right) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.name || '') !== String(right.name || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizeSqlType(left.type) !== normalizeSqlType(right.type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(left.notnull || 0) !== Number(right.notnull || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(left.pk || 0) !== Number(right.pk || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizeDefault(left.dflt_value) !== normalizeDefault(right.dflt_value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameForeignKeys(current = [], desired = []) {
|
||||||
|
if (current.length !== desired.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < current.length; i += 1) {
|
||||||
|
const left = current[i];
|
||||||
|
const right = desired[i];
|
||||||
|
if (!left || !right) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(left.id || 0) !== Number(right.id || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(left.seq || 0) !== Number(right.seq || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.table || '') !== String(right.table || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.from || '') !== String(right.from || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.to || '') !== String(right.to || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.on_update || '') !== String(right.on_update || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.on_delete || '') !== String(right.on_delete || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.match || '') !== String(right.match || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(db, tableName) {
|
||||||
|
const row = await db.get(
|
||||||
|
`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTableInfo(db, tableName) {
|
||||||
|
return db.all(`PRAGMA table_info(${quoteIdentifier(tableName)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getForeignKeyInfo(db, tableName) {
|
||||||
|
return db.all(`PRAGMA foreign_key_list(${quoteIdentifier(tableName)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readConfiguredLogDirSetting(db) {
|
||||||
|
const hasSchemaTable = await tableExists(db, 'settings_schema');
|
||||||
|
const hasValuesTable = await tableExists(db, 'settings_values');
|
||||||
|
if (!hasSchemaTable || !hasValuesTable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await db.get(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COALESCE(v.value, s.default_value, '') AS value
|
||||||
|
FROM settings_schema s
|
||||||
|
LEFT JOIN settings_values v ON v.key = s.key
|
||||||
|
WHERE s.key = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
['log_dir']
|
||||||
|
);
|
||||||
|
const value = String(row?.value || '').trim();
|
||||||
|
return value || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('log-root:read-setting-failed', {
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureRuntimeLogRootFromSettings(db, options = {}) {
|
||||||
|
const ensure = Boolean(options.ensure);
|
||||||
|
const configured = await readConfiguredLogDirSetting(db);
|
||||||
|
let resolved = setLogRootDir(configured);
|
||||||
|
if (ensure) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(resolved, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackResolved = setLogRootDir(null);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(fallbackResolved, { recursive: true });
|
||||||
|
} catch (_fallbackError) {
|
||||||
|
// ignored: logger itself is hardened and may still write to console only
|
||||||
|
}
|
||||||
|
logger.warn('log-root:ensure-failed', {
|
||||||
|
configured: configured || null,
|
||||||
|
resolved,
|
||||||
|
fallbackResolved,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
resolved = fallbackResolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured,
|
||||||
|
resolved
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSchemaModel() {
|
||||||
|
if (!fs.existsSync(schemaFilePath)) {
|
||||||
|
const error = new Error(`Schema-Datei fehlt: ${schemaFilePath}`);
|
||||||
|
error.code = 'SCHEMA_FILE_MISSING';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaSql = fs.readFileSync(schemaFilePath, 'utf-8');
|
||||||
|
const memDb = await open({
|
||||||
|
filename: ':memory:',
|
||||||
|
driver: sqlite3.Database
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await memDb.exec(schemaSql);
|
||||||
|
const tables = await memDb.all(`
|
||||||
|
SELECT name, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY rowid ASC
|
||||||
|
`);
|
||||||
|
const indexes = await memDb.all(`
|
||||||
|
SELECT name, tbl_name AS tableName, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'index'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
AND sql IS NOT NULL
|
||||||
|
ORDER BY rowid ASC
|
||||||
|
`);
|
||||||
|
const tableInfos = {};
|
||||||
|
const tableForeignKeys = {};
|
||||||
|
for (const table of tables) {
|
||||||
|
tableInfos[table.name] = await getTableInfo(memDb, table.name);
|
||||||
|
tableForeignKeys[table.name] = await getForeignKeyInfo(memDb, table.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaSql,
|
||||||
|
tables,
|
||||||
|
indexes,
|
||||||
|
tableInfos,
|
||||||
|
tableForeignKeys
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await memDb.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildTable(db, tableName, createSql) {
|
||||||
|
const oldName = `${tableName}__old_${Date.now()}`;
|
||||||
|
const tableNameQuoted = quoteIdentifier(tableName);
|
||||||
|
const oldNameQuoted = quoteIdentifier(oldName);
|
||||||
|
const beforeInfo = await getTableInfo(db, tableName);
|
||||||
|
|
||||||
|
await db.exec(`ALTER TABLE ${tableNameQuoted} RENAME TO ${oldNameQuoted}`);
|
||||||
|
await db.exec(createSql);
|
||||||
|
|
||||||
|
const afterInfo = await getTableInfo(db, tableName);
|
||||||
|
const beforeColumns = new Set(beforeInfo.map((column) => String(column.name)));
|
||||||
|
const commonColumns = afterInfo
|
||||||
|
.map((column) => String(column.name))
|
||||||
|
.filter((name) => beforeColumns.has(name));
|
||||||
|
|
||||||
|
if (commonColumns.length > 0) {
|
||||||
|
const columnList = commonColumns.map((name) => quoteIdentifier(name)).join(', ');
|
||||||
|
await db.exec(`
|
||||||
|
INSERT INTO ${tableNameQuoted} (${columnList})
|
||||||
|
SELECT ${columnList}
|
||||||
|
FROM ${oldNameQuoted}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.exec(`DROP TABLE ${oldNameQuoted}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSchemaToModel(db, model) {
|
||||||
|
const desiredTables = Array.isArray(model?.tables) ? model.tables : [];
|
||||||
|
const desiredIndexes = Array.isArray(model?.indexes) ? model.indexes : [];
|
||||||
|
const desiredTableInfo = model?.tableInfos && typeof model.tableInfos === 'object'
|
||||||
|
? model.tableInfos
|
||||||
|
: {};
|
||||||
|
const desiredTableForeignKeys = model?.tableForeignKeys && typeof model.tableForeignKeys === 'object'
|
||||||
|
? model.tableForeignKeys
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const currentTables = await db.all(`
|
||||||
|
SELECT name, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY rowid ASC
|
||||||
|
`);
|
||||||
|
const currentByName = new Map(currentTables.map((table) => [table.name, table]));
|
||||||
|
const desiredTableNameSet = new Set(desiredTables.map((table) => table.name));
|
||||||
|
|
||||||
|
for (const table of desiredTables) {
|
||||||
|
const tableName = String(table.name || '');
|
||||||
|
const createSql = String(table.sql || '').trim();
|
||||||
|
if (!tableName || !createSql) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentByName.has(tableName)) {
|
||||||
|
await db.exec(createSql);
|
||||||
|
logger.info('schema:create-table', { table: tableName });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentInfo = await getTableInfo(db, tableName);
|
||||||
|
const wantedInfo = Array.isArray(desiredTableInfo[tableName]) ? desiredTableInfo[tableName] : [];
|
||||||
|
const currentFks = await getForeignKeyInfo(db, tableName);
|
||||||
|
const wantedFks = Array.isArray(desiredTableForeignKeys[tableName]) ? desiredTableForeignKeys[tableName] : [];
|
||||||
|
const shapeMatches = sameTableShape(currentInfo, wantedInfo);
|
||||||
|
const foreignKeysMatch = sameForeignKeys(currentFks, wantedFks);
|
||||||
|
if (!shapeMatches || !foreignKeysMatch) {
|
||||||
|
await rebuildTable(db, tableName, createSql);
|
||||||
|
logger.warn('schema:rebuild-table', {
|
||||||
|
table: tableName,
|
||||||
|
reason: !shapeMatches ? 'shape-mismatch' : 'foreign-key-mismatch'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of currentTables) {
|
||||||
|
if (desiredTableNameSet.has(table.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(table.name)}`);
|
||||||
|
logger.warn('schema:drop-table', { table: table.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndexes = await db.all(`
|
||||||
|
SELECT name, tbl_name AS tableName, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'index'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
AND sql IS NOT NULL
|
||||||
|
ORDER BY rowid ASC
|
||||||
|
`);
|
||||||
|
const desiredIndexNameSet = new Set(desiredIndexes.map((index) => index.name));
|
||||||
|
|
||||||
|
for (const index of currentIndexes) {
|
||||||
|
if (desiredIndexNameSet.has(index.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await db.exec(`DROP INDEX IF EXISTS ${quoteIdentifier(index.name)}`);
|
||||||
|
logger.warn('schema:drop-index', { index: index.name, table: index.tableName });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const index of desiredIndexes) {
|
||||||
|
let sql = String(index.sql || '').trim();
|
||||||
|
if (!sql) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^CREATE\s+UNIQUE\s+INDEX\s+/i.test(sql)) {
|
||||||
|
sql = sql.replace(/^CREATE\s+UNIQUE\s+INDEX\s+/i, 'CREATE UNIQUE INDEX IF NOT EXISTS ');
|
||||||
|
} else if (/^CREATE\s+INDEX\s+/i.test(sql)) {
|
||||||
|
sql = sql.replace(/^CREATE\s+INDEX\s+/i, 'CREATE INDEX IF NOT EXISTS ');
|
||||||
|
}
|
||||||
|
await db.exec(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportLegacyJobLogsToFiles(db) {
|
||||||
|
const hasJobLogsTable = await tableExists(db, 'job_logs');
|
||||||
|
if (!hasJobLogsTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.all(`
|
||||||
|
SELECT job_id, source, message, timestamp
|
||||||
|
FROM job_logs
|
||||||
|
ORDER BY job_id ASC, id ASC
|
||||||
|
`);
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
logger.info('legacy-job-logs:export:skip-empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDir = getJobLogDir();
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
const streams = new Map();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const row of rows) {
|
||||||
|
const jobId = Number(row?.job_id);
|
||||||
|
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(Math.trunc(jobId));
|
||||||
|
if (!streams.has(key)) {
|
||||||
|
const filePath = path.join(targetDir, `job-${key}.process.log`);
|
||||||
|
const stream = fs.createWriteStream(filePath, {
|
||||||
|
flags: 'w',
|
||||||
|
encoding: 'utf-8'
|
||||||
|
});
|
||||||
|
streams.set(key, stream);
|
||||||
|
}
|
||||||
|
const line = `[${String(row?.timestamp || '')}] [${String(row?.source || 'SYSTEM')}] ${String(row?.message || '')}\n`;
|
||||||
|
streams.get(key).write(line);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await Promise.all(
|
||||||
|
[...streams.values()].map(
|
||||||
|
(stream) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
stream.end(resolve);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('legacy-job-logs:exported', {
|
||||||
|
lines: rows.length,
|
||||||
|
jobs: streams.size,
|
||||||
|
targetDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySchemaModel(db, model) {
|
||||||
|
await db.exec('PRAGMA foreign_keys = OFF;');
|
||||||
|
await db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
await syncSchemaToModel(db, model);
|
||||||
|
await db.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await db.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAndPrepareDatabase() {
|
||||||
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
logger.info('init:open', { dbPath });
|
||||||
|
|
||||||
|
dbInstance = await open({
|
||||||
|
filename: dbPath,
|
||||||
|
driver: sqlite3.Database
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbInstance.exec('PRAGMA journal_mode = WAL;');
|
||||||
|
await dbInstance.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
const initialLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
||||||
|
logger.info('log-root:initialized', {
|
||||||
|
configured: initialLogRoot.configured || null,
|
||||||
|
resolved: initialLogRoot.resolved
|
||||||
|
});
|
||||||
|
await exportLegacyJobLogsToFiles(dbInstance);
|
||||||
|
const schemaModel = await loadSchemaModel();
|
||||||
|
await applySchemaModel(dbInstance, schemaModel);
|
||||||
|
|
||||||
|
await seedFromSchemaFile(dbInstance);
|
||||||
|
await syncInstallPathSettingDefaults(dbInstance);
|
||||||
|
await migrateLegacyProfiledToolSettings(dbInstance);
|
||||||
|
await migrateOutputTemplates(dbInstance);
|
||||||
|
await removeDeprecatedSettings(dbInstance);
|
||||||
|
await migrateSettingsSchemaMetadata(dbInstance);
|
||||||
|
await ensurePipelineStateRow(dbInstance);
|
||||||
|
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
||||||
|
logger.info('log-root:synced', {
|
||||||
|
configured: syncedLogRoot.configured || null,
|
||||||
|
resolved: syncedLogRoot.resolved
|
||||||
|
});
|
||||||
|
logger.info('init:done');
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDatabase({ allowRecovery = true } = {}) {
|
||||||
|
if (dbInstance) {
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await openAndPrepareDatabase();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('init:failed', { error: errorToMeta(error), allowRecovery });
|
||||||
|
|
||||||
|
if (dbInstance) {
|
||||||
|
try {
|
||||||
|
await dbInstance.close();
|
||||||
|
} catch (_closeError) {
|
||||||
|
// ignore close errors during failed init
|
||||||
|
}
|
||||||
|
dbInstance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowRecovery && isSqliteCorruptionError(error)) {
|
||||||
|
logger.warn('recovery:corrupt-db-detected', { dbPath });
|
||||||
|
quarantineCorruptDatabaseFiles();
|
||||||
|
return initDatabase({ allowRecovery: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedFromSchemaFile(db) {
|
||||||
|
const schemaSql = fs.readFileSync(schemaFilePath, 'utf-8');
|
||||||
|
// Kommentarzeilen vor dem Split entfernen, damit der erste INSERT-Block nicht
|
||||||
|
// mit vorangehenden Kommentaren in einem Chunk landet und durch den
|
||||||
|
// /^INSERT\b/-Filter herausfällt.
|
||||||
|
const strippedSql = schemaSql.replace(/^--[^\n]*$/gm, '');
|
||||||
|
const statements = strippedSql
|
||||||
|
.split(/;\s*\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => /^INSERT\b/i.test(s));
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await db.run(stmt);
|
||||||
|
}
|
||||||
|
logger.info('seed:settings', { count: statements.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInstallPathSettingDefaults(db) {
|
||||||
|
const dataDir = path.dirname(dbPath);
|
||||||
|
const updates = INSTALL_PATH_SETTING_DEFAULTS.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
value: path.join(dataDir, ...item.pathParts),
|
||||||
|
legacyDefaults: Array.isArray(item.legacyDefaults) ? item.legacyDefaults : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const update of updates) {
|
||||||
|
const placeholders = update.legacyDefaults.map(() => '?').join(', ');
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
UPDATE settings_schema
|
||||||
|
SET default_value = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = ?
|
||||||
|
AND (
|
||||||
|
default_value IS NULL
|
||||||
|
OR TRIM(default_value) = ''
|
||||||
|
OR default_value IN (${placeholders})
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[update.value, update.key, ...update.legacyDefaults]
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO settings_values (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO NOTHING
|
||||||
|
`,
|
||||||
|
[update.key, update.value]
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
UPDATE settings_values
|
||||||
|
SET value = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = ?
|
||||||
|
AND (
|
||||||
|
value IS NULL
|
||||||
|
OR TRIM(value) = ''
|
||||||
|
OR value IN (${placeholders})
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[update.value, update.key, ...update.legacyDefaults]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('seed:path-defaults-synced', {
|
||||||
|
dataDir,
|
||||||
|
settings: updates.map((item) => ({ key: item.key, value: item.value }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCurrentOrDefaultSettingValue(db, key) {
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return db.get(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.default_value AS defaultValue,
|
||||||
|
v.value AS currentValue,
|
||||||
|
COALESCE(v.value, s.default_value) AS effectiveValue
|
||||||
|
FROM settings_schema s
|
||||||
|
LEFT JOIN settings_values v ON v.key = s.key
|
||||||
|
WHERE s.key = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyProfiledToolSettings(db) {
|
||||||
|
let copiedCount = 0;
|
||||||
|
for (const migration of LEGACY_PROFILE_SETTING_MIGRATIONS) {
|
||||||
|
const legacyRow = await readCurrentOrDefaultSettingValue(db, migration.legacyKey);
|
||||||
|
if (!legacyRow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetKey of migration.profileKeys || []) {
|
||||||
|
const targetRow = await readCurrentOrDefaultSettingValue(db, targetKey);
|
||||||
|
if (!targetRow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = targetRow.currentValue;
|
||||||
|
const defaultValue = targetRow.defaultValue;
|
||||||
|
const shouldCopy = (
|
||||||
|
currentValue === null
|
||||||
|
|| currentValue === undefined
|
||||||
|
|| String(currentValue) === String(defaultValue ?? '')
|
||||||
|
);
|
||||||
|
if (!shouldCopy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO settings_values (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`,
|
||||||
|
[targetKey, legacyRow.effectiveValue ?? null]
|
||||||
|
);
|
||||||
|
copiedCount += 1;
|
||||||
|
logger.info('migrate:legacy-tool-setting-copied', {
|
||||||
|
from: migration.legacyKey,
|
||||||
|
to: targetKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copiedCount > 0) {
|
||||||
|
logger.info('migrate:legacy-tool-settings:done', { copiedCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePipelineStateRow(db) {
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO pipeline_state (id, state, active_job_id, progress, eta, status_text, context_json)
|
||||||
|
VALUES (1, 'IDLE', NULL, 0, NULL, NULL, '{}')
|
||||||
|
ON CONFLICT(id) DO NOTHING
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateOutputTemplates(db) {
|
||||||
|
// Combine legacy filename_template_X + output_folder_template_X into output_template_X.
|
||||||
|
// Only sets the new key if it has no user value yet (preserves any existing value).
|
||||||
|
// The last "/" in the combined template separates folder from filename.
|
||||||
|
for (const profile of ['bluray', 'dvd']) {
|
||||||
|
const newKey = `output_template_${profile}`;
|
||||||
|
const filenameKey = `filename_template_${profile}`;
|
||||||
|
const folderKey = `output_folder_template_${profile}`;
|
||||||
|
|
||||||
|
const existing = await db.get(
|
||||||
|
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||||
|
[newKey]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
continue; // already set, don't overwrite
|
||||||
|
}
|
||||||
|
|
||||||
|
const filenameRow = await db.get(
|
||||||
|
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||||
|
[filenameKey]
|
||||||
|
);
|
||||||
|
const folderRow = await db.get(
|
||||||
|
`SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
|
||||||
|
[folderKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filenameVal = filenameRow ? String(filenameRow.value || '').trim() : '';
|
||||||
|
const folderVal = folderRow ? String(folderRow.value || '').trim() : '';
|
||||||
|
|
||||||
|
if (!filenameVal) {
|
||||||
|
continue; // nothing to migrate
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = folderVal ? `${folderVal}/${filenameVal}` : `${filenameVal}/${filenameVal}`;
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO settings_values (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[newKey, combined]
|
||||||
|
);
|
||||||
|
logger.info('migrate:output-template-combined', { profile, combined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDeprecatedSettings(db) {
|
||||||
|
const deprecatedKeys = [
|
||||||
|
'pushover_notify_disc_detected',
|
||||||
|
'mediainfo_extra_args',
|
||||||
|
'makemkv_rip_mode',
|
||||||
|
'makemkv_analyze_extra_args',
|
||||||
|
'makemkv_rip_extra_args',
|
||||||
|
'handbrake_preset',
|
||||||
|
'handbrake_extra_args',
|
||||||
|
'output_extension',
|
||||||
|
'filename_template',
|
||||||
|
'output_folder_template',
|
||||||
|
'makemkv_backup_mode',
|
||||||
|
'raw_dir',
|
||||||
|
'movie_dir',
|
||||||
|
'raw_dir_other',
|
||||||
|
'raw_dir_other_owner',
|
||||||
|
'movie_dir_other',
|
||||||
|
'movie_dir_other_owner',
|
||||||
|
'filename_template_bluray',
|
||||||
|
'filename_template_dvd',
|
||||||
|
'output_folder_template_bluray',
|
||||||
|
'output_folder_template_dvd'
|
||||||
|
];
|
||||||
|
for (const key of deprecatedKeys) {
|
||||||
|
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||||
|
const valuesResult = await db.run('DELETE FROM settings_values WHERE key = ?', [key]);
|
||||||
|
if (schemaResult?.changes > 0 || valuesResult?.changes > 0) {
|
||||||
|
logger.info('migrate:remove-deprecated-setting', { key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset raw_dir_cd if it still holds the old hardcoded absolute path from a prior install
|
||||||
|
await db.run(
|
||||||
|
`UPDATE settings_values SET value = NULL, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = 'raw_dir_cd' AND value = '/opt/ripster/backend/data/output/cd'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktualisiert settings_schema-Metadaten (required, description, validation_json)
|
||||||
|
// für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten.
|
||||||
|
const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||||
|
{
|
||||||
|
key: 'handbrake_preset_bluray',
|
||||||
|
required: 0,
|
||||||
|
description: 'Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
|
||||||
|
validation_json: '{}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handbrake_preset_dvd',
|
||||||
|
required: 0,
|
||||||
|
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
|
||||||
|
validation_json: '{}'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Settings, die von einer Kategorie in eine andere verschoben werden
|
||||||
|
const SETTINGS_CATEGORY_MOVES = [
|
||||||
|
{ key: 'cd_output_template', category: 'Pfade' },
|
||||||
|
{ key: 'output_template_bluray', category: 'Pfade' },
|
||||||
|
{ key: 'output_template_dvd', category: 'Pfade' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function migrateSettingsSchemaMetadata(db) {
|
||||||
|
for (const update of SETTINGS_SCHEMA_METADATA_UPDATES) {
|
||||||
|
const result = await db.run(
|
||||||
|
`UPDATE settings_schema
|
||||||
|
SET required = ?, description = ?, validation_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = ? AND (required != ? OR description != ? OR validation_json != ?)`,
|
||||||
|
[
|
||||||
|
update.required, update.description, update.validation_json,
|
||||||
|
update.key,
|
||||||
|
update.required, update.description, update.validation_json
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (result?.changes > 0) {
|
||||||
|
logger.info('migrate:settings-schema-metadata', { key: update.key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const move of SETTINGS_CATEGORY_MOVES) {
|
||||||
|
const result = await db.run(
|
||||||
|
`UPDATE settings_schema SET category = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = ? AND category != ?`,
|
||||||
|
[move.category, move.key, move.category]
|
||||||
|
);
|
||||||
|
if (result?.changes > 0) {
|
||||||
|
logger.info('migrate:settings-schema-category-moved', { key: move.key, category: move.category });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDirCdLabel = 'CD RAW-Ordner';
|
||||||
|
const rawDirCdDescription = 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).';
|
||||||
|
const rawDirCdResult = await db.run(
|
||||||
|
`UPDATE settings_schema
|
||||||
|
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = 'raw_dir_cd' AND (label != ? OR description != ?)`,
|
||||||
|
[rawDirCdLabel, rawDirCdDescription, rawDirCdLabel, rawDirCdDescription]
|
||||||
|
);
|
||||||
|
if (rawDirCdResult?.changes > 0) {
|
||||||
|
logger.info('migrate:settings-schema-cd-raw-updated', {
|
||||||
|
key: 'raw_dir_cd',
|
||||||
|
label: rawDirCdLabel
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate raw_dir_cd_owner label
|
||||||
|
await db.run(
|
||||||
|
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add movie_dir_cd if not already present
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`);
|
||||||
|
|
||||||
|
// Add movie_dir_cd_owner if not already present
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDb() {
|
||||||
|
return initDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initDatabase,
|
||||||
|
getDb
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 cronRoutes = require('./routes/cronRoutes');
|
||||||
|
const runtimeRoutes = require('./routes/runtimeRoutes');
|
||||||
|
const wsService = require('./services/websocketService');
|
||||||
|
const pipelineService = require('./services/pipelineService');
|
||||||
|
const cronService = require('./services/cronService');
|
||||||
|
const diskDetectionService = require('./services/diskDetectionService');
|
||||||
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
|
const logger = require('./services/logger').child('BOOT');
|
||||||
|
const { errorToMeta } = require('./utils/errorMeta');
|
||||||
|
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
logger.info('backend:start:init');
|
||||||
|
await initDatabase();
|
||||||
|
await pipelineService.init();
|
||||||
|
await cronService.init();
|
||||||
|
|
||||||
|
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/crons', cronRoutes);
|
||||||
|
app.use('/api/runtime', runtimeRoutes);
|
||||||
|
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');
|
||||||
|
diskDetectionService.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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = function asyncHandler(fn) {
|
||||||
|
return function wrapped(req, res, next) {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const logger = require('../services/logger').child('ERROR_HANDLER');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
module.exports = function errorHandler(error, req, res, next) {
|
||||||
|
const statusCode = error.statusCode || 500;
|
||||||
|
|
||||||
|
logger.error('request:error', {
|
||||||
|
reqId: req?.reqId,
|
||||||
|
method: req?.method,
|
||||||
|
url: req?.originalUrl,
|
||||||
|
statusCode,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Interner Fehler',
|
||||||
|
statusCode,
|
||||||
|
reqId: req?.reqId,
|
||||||
|
details: Array.isArray(error.details) ? error.details : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,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();
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
|
const cronService = require('../services/cronService');
|
||||||
|
const wsService = require('../services/websocketService');
|
||||||
|
const logger = require('../services/logger').child('CRON_ROUTE');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/crons – alle Cronjobs auflisten
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:crons', { reqId: req.reqId });
|
||||||
|
const jobs = await cronService.listJobs();
|
||||||
|
res.json({ jobs });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/crons/validate-expression – Cron-Ausdruck validieren
|
||||||
|
router.post(
|
||||||
|
'/validate-expression',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const expr = String(req.body?.cronExpression || '').trim();
|
||||||
|
const validation = cronService.validateExpression(expr);
|
||||||
|
const nextRunAt = validation.valid ? cronService.getNextRunTime(expr) : null;
|
||||||
|
res.json({ ...validation, nextRunAt });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/crons – neuen Cronjob anlegen
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const payload = req.body || {};
|
||||||
|
logger.info('post:crons:create', { reqId: req.reqId, name: payload?.name });
|
||||||
|
const job = await cronService.createJob(payload);
|
||||||
|
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'created', id: job.id });
|
||||||
|
res.status(201).json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/crons/:id – einzelnen Cronjob abrufen
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
logger.debug('get:crons:one', { reqId: req.reqId, cronJobId: id });
|
||||||
|
const job = await cronService.getJobById(id);
|
||||||
|
res.json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/crons/:id – Cronjob aktualisieren
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const payload = req.body || {};
|
||||||
|
logger.info('put:crons:update', { reqId: req.reqId, cronJobId: id });
|
||||||
|
const job = await cronService.updateJob(id, payload);
|
||||||
|
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'updated', id: job.id });
|
||||||
|
res.json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/crons/:id – Cronjob löschen
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
logger.info('delete:crons', { reqId: req.reqId, cronJobId: id });
|
||||||
|
const removed = await cronService.deleteJob(id);
|
||||||
|
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'deleted', id: removed.id });
|
||||||
|
res.json({ removed });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/crons/:id/logs – Ausführungs-Logs eines Cronjobs
|
||||||
|
router.get(
|
||||||
|
'/:id/logs',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const limit = Math.min(Number(req.query?.limit) || 20, 100);
|
||||||
|
logger.debug('get:crons:logs', { reqId: req.reqId, cronJobId: id, limit });
|
||||||
|
const logs = await cronService.getJobLogs(id, limit);
|
||||||
|
res.json({ logs });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/crons/:id/run – Cronjob manuell auslösen
|
||||||
|
router.post(
|
||||||
|
'/:id/run',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
logger.info('post:crons:run', { reqId: req.reqId, cronJobId: id });
|
||||||
|
const result = await cronService.triggerJobManually(id);
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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());
|
||||||
|
logger.info('get:jobs', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
status: req.query.status,
|
||||||
|
statuses: statuses.length > 0 ? statuses : null,
|
||||||
|
search: req.query.search,
|
||||||
|
limit,
|
||||||
|
lite
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobs = await historyService.getJobs({
|
||||||
|
status: req.query.status,
|
||||||
|
statuses,
|
||||||
|
search: req.query.search,
|
||||||
|
limit,
|
||||||
|
includeFsChecks: !lite
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ jobs });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/database',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.info('get:database', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
status: req.query.status,
|
||||||
|
search: req.query.search
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await historyService.getDatabaseRows({
|
||||||
|
status: req.query.status,
|
||||||
|
search: req.query.search
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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/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());
|
||||||
|
|
||||||
|
logger.warn('post:delete-job', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
id,
|
||||||
|
target,
|
||||||
|
includeRelated
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await historyService.deleteJob(id, target, { includeRelated });
|
||||||
|
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
||||||
|
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;
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
|
const pipelineService = require('../services/pipelineService');
|
||||||
|
const diskDetectionService = require('../services/diskDetectionService');
|
||||||
|
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||||
|
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
logger.info('post:analyze', { reqId: req.reqId });
|
||||||
|
const result = await pipelineService.analyzeDisc();
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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.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(
|
||||||
|
'/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(
|
||||||
|
'/select-metadata',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist } = 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
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = await pipelineService.selectMetadata({
|
||||||
|
jobId: Number(jobId),
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
imdbId,
|
||||||
|
poster,
|
||||||
|
fromOmdb,
|
||||||
|
selectedPlaylist
|
||||||
|
});
|
||||||
|
|
||||||
|
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 selectedTrackSelection = req.body?.selectedTrackSelection ?? 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;
|
||||||
|
logger.info('post:confirm-encode', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
jobId,
|
||||||
|
selectedEncodeTitleId,
|
||||||
|
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||||
|
skipPipelineStateUpdate,
|
||||||
|
selectedUserPresetId,
|
||||||
|
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,
|
||||||
|
selectedTrackSelection,
|
||||||
|
selectedPostEncodeScriptIds,
|
||||||
|
selectedPreEncodeScriptIds,
|
||||||
|
selectedPostEncodeChainIds,
|
||||||
|
selectedPreEncodeChainIds,
|
||||||
|
skipPipelineStateUpdate,
|
||||||
|
selectedUserPresetId
|
||||||
|
});
|
||||||
|
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.post(
|
||||||
|
'/reencode/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
logger.info('post:reencode', { reqId: req.reqId, jobId });
|
||||||
|
const result = await pipelineService.reencodeFromRaw(jobId);
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/restart-review/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
logger.info('post:restart-review', { reqId: req.reqId, jobId });
|
||||||
|
const result = await pipelineService.restartReviewFromRaw(jobId);
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/restart-encode/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
logger.info('post:restart-encode', { reqId: req.reqId, jobId });
|
||||||
|
const result = await pipelineService.restartEncodeWithLastSettings(jobId);
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/queue',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:queue', { reqId: req.reqId });
|
||||||
|
const queue = await pipelineService.getQueueSnapshot();
|
||||||
|
res.json({ queue });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/queue/reorder',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
// Accept orderedEntryIds (new) or orderedJobIds (legacy fallback for job-only queues).
|
||||||
|
const orderedEntryIds = Array.isArray(req.body?.orderedEntryIds)
|
||||||
|
? req.body.orderedEntryIds
|
||||||
|
: (Array.isArray(req.body?.orderedJobIds) ? req.body.orderedJobIds : []);
|
||||||
|
logger.info('post:queue:reorder', { reqId: req.reqId, orderedEntryIds });
|
||||||
|
const queue = await pipelineService.reorderQueue(orderedEntryIds);
|
||||||
|
res.json({ queue });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/queue/entry',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { type, scriptId, chainId, waitSeconds, insertAfterEntryId } = req.body || {};
|
||||||
|
logger.info('post:queue:entry', { reqId: req.reqId, type });
|
||||||
|
const result = await pipelineService.enqueueNonJobEntry(
|
||||||
|
type,
|
||||||
|
{ scriptId, chainId, waitSeconds },
|
||||||
|
insertAfterEntryId ?? null
|
||||||
|
);
|
||||||
|
const queue = await pipelineService.getQueueSnapshot();
|
||||||
|
res.json({ result, queue });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/queue/entry/:entryId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const entryId = req.params.entryId;
|
||||||
|
logger.info('delete:queue:entry', { reqId: req.reqId, entryId });
|
||||||
|
const queue = await pipelineService.removeQueueEntry(entryId);
|
||||||
|
res.json({ queue });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
|
const runtimeActivityService = require('../services/runtimeActivityService');
|
||||||
|
const logger = require('../services/logger').child('RUNTIME_ROUTE');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/activities',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:runtime:activities', { reqId: req.reqId });
|
||||||
|
const snapshot = runtimeActivityService.getSnapshot();
|
||||||
|
res.json(snapshot);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/activities/:id/cancel',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const activityId = Number(req.params.id);
|
||||||
|
const reason = String(req.body?.reason || '').trim() || null;
|
||||||
|
logger.info('post:runtime:activities:cancel', { reqId: req.reqId, activityId, reason });
|
||||||
|
const action = await runtimeActivityService.requestCancel(activityId, { reason });
|
||||||
|
if (!action?.ok) {
|
||||||
|
const error = new Error(action?.message || 'Abbrechen fehlgeschlagen.');
|
||||||
|
error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
action: action.result || null,
|
||||||
|
snapshot: runtimeActivityService.getSnapshot()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/activities/:id/next-step',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const activityId = Number(req.params.id);
|
||||||
|
logger.info('post:runtime:activities:next-step', { reqId: req.reqId, activityId });
|
||||||
|
const action = await runtimeActivityService.requestNextStep(activityId, {});
|
||||||
|
if (!action?.ok) {
|
||||||
|
const error = new Error(action?.message || 'Nächster Schritt fehlgeschlagen.');
|
||||||
|
error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
action: action.result || null,
|
||||||
|
snapshot: runtimeActivityService.getSnapshot()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/activities/clear-recent',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.info('post:runtime:activities:clear-recent', { reqId: req.reqId });
|
||||||
|
const result = runtimeActivityService.clearRecent();
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
removed: Number(result?.removed || 0),
|
||||||
|
snapshot: result?.snapshot || runtimeActivityService.getSnapshot()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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 logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||||
|
|
||||||
|
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)/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.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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const logger = require('./logger').child('CD_RIP');
|
||||||
|
const { spawnTrackedProcess } = require('./processRunner');
|
||||||
|
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
|
||||||
|
const { ensureDir } = require('../utils/files');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']);
|
||||||
|
const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse cdparanoia -Q output to extract track information.
|
||||||
|
* Supports both bracket styles shown by different builds:
|
||||||
|
* track 1: 0 (00:00.00) 24218 (05:22.43)
|
||||||
|
* track 1: 0 [00:00.00] 24218 [05:22.43]
|
||||||
|
*/
|
||||||
|
function parseToc(tocOutput) {
|
||||||
|
const lines = String(tocOutput || '').split(/\r?\n/);
|
||||||
|
const tracks = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
||||||
|
if (trackMatch) {
|
||||||
|
const position = Number(trackMatch[1]);
|
||||||
|
const payloadWithoutTimes = String(trackMatch[2] || '')
|
||||||
|
.replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' ');
|
||||||
|
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
||||||
|
if (sectorValues.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSector = Number(sectorValues[0]);
|
||||||
|
const lengthSector = Number(sectorValues[1]);
|
||||||
|
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration in seconds: sectors / 75
|
||||||
|
const durationSec = Math.round(lengthSector / 75);
|
||||||
|
tracks.push({
|
||||||
|
position,
|
||||||
|
startSector,
|
||||||
|
lengthSector,
|
||||||
|
durationSec,
|
||||||
|
durationMs: durationSec * 1000
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative cdparanoia -Q table style:
|
||||||
|
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
|
||||||
|
// ^ length sectors ^ start sector
|
||||||
|
const tableMatch = line.match(
|
||||||
|
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i
|
||||||
|
);
|
||||||
|
if (!tableMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = Number(tableMatch[1]);
|
||||||
|
const lengthSector = Number(tableMatch[2]);
|
||||||
|
const startSector = Number(tableMatch[3]);
|
||||||
|
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationSec = Math.round(lengthSector / 75);
|
||||||
|
tracks.push({
|
||||||
|
position,
|
||||||
|
startSector,
|
||||||
|
lengthSector,
|
||||||
|
durationSec,
|
||||||
|
durationMs: durationSec * 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readToc(devicePath, cmd) {
|
||||||
|
const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
|
||||||
|
logger.info('toc:read', { devicePath, cmd: cdparanoia });
|
||||||
|
try {
|
||||||
|
// Depending on distro/build, TOC can appear on stderr and/or stdout.
|
||||||
|
const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||||
|
logger.info('toc:done', { devicePath, trackCount: tracks.length });
|
||||||
|
return tracks;
|
||||||
|
} catch (error) {
|
||||||
|
// cdparanoia -Q may exit non-zero even when TOC is readable.
|
||||||
|
const stderr = String(error?.stderr || '');
|
||||||
|
const stdout = String(error?.stdout || '');
|
||||||
|
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length });
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||||
|
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate);
|
||||||
|
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
|
||||||
|
return `${relativeBasePath}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePathSegment(value, fallback = 'unknown') {
|
||||||
|
const raw = String(value == null ? '' : value)
|
||||||
|
.normalize('NFC')
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '-')
|
||||||
|
// Keep umlauts/special letters, but filter heart symbols in filenames.
|
||||||
|
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
|
||||||
|
.replace(/\p{C}+/gu, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!raw || raw === '.' || raw === '..') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return raw.slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateTokenKey(rawKey) {
|
||||||
|
const key = String(rawKey || '').trim().toLowerCase();
|
||||||
|
if (!key) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') {
|
||||||
|
return 'trackNr';
|
||||||
|
}
|
||||||
|
if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') {
|
||||||
|
return 'trackNo';
|
||||||
|
}
|
||||||
|
if (key === 'trackartist' || key === 'track_artist') {
|
||||||
|
return 'trackArtist';
|
||||||
|
}
|
||||||
|
if (key === 'albumartist') {
|
||||||
|
return 'albumArtist';
|
||||||
|
}
|
||||||
|
if (key === 'interpret') {
|
||||||
|
return 'artist';
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRenderedTemplate(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/\(\s*\)/g, '')
|
||||||
|
.replace(/\[\s*]/g, '')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutputTemplate(template, values) {
|
||||||
|
const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE;
|
||||||
|
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
|
||||||
|
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
|
||||||
|
const rawValue = values[normalizedKey];
|
||||||
|
if (rawValue === undefined || rawValue === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(rawValue);
|
||||||
|
});
|
||||||
|
return cleanupRenderedTemplate(rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTemplateValues(track, meta, format = null) {
|
||||||
|
const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1;
|
||||||
|
const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`);
|
||||||
|
const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist');
|
||||||
|
const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist');
|
||||||
|
const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album');
|
||||||
|
const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), '');
|
||||||
|
return {
|
||||||
|
artist: albumArtist,
|
||||||
|
albumArtist,
|
||||||
|
trackArtist,
|
||||||
|
album,
|
||||||
|
year,
|
||||||
|
title: trackTitle,
|
||||||
|
trackNr: String(trackNo).padStart(2, '0'),
|
||||||
|
trackNo: String(trackNo),
|
||||||
|
format: format ? String(format).trim().toLowerCase() : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) {
|
||||||
|
const values = buildTemplateValues(track, meta, format);
|
||||||
|
const rendered = renderOutputTemplate(outputTemplate, values)
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/\/+/g, '/')
|
||||||
|
.replace(/^\/+|\/+$/g, '');
|
||||||
|
|
||||||
|
const parts = rendered
|
||||||
|
.split('/')
|
||||||
|
.map((part) => sanitizePathSegment(part, 'unknown'))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||||
|
const sampleTrack = {
|
||||||
|
position: 1,
|
||||||
|
title: 'Track 1'
|
||||||
|
};
|
||||||
|
const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate);
|
||||||
|
const relativeDir = path.dirname(relativeBasePath);
|
||||||
|
if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) {
|
||||||
|
return baseDir;
|
||||||
|
}
|
||||||
|
return path.join(baseDir, relativeDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPathSegments(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.split('/')
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
|
||||||
|
const outputSegments = splitPathSegments(outputBaseDir);
|
||||||
|
const relativeSegments = splitPathSegments(relativeDir);
|
||||||
|
if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const offset = outputSegments.length - relativeSegments.length;
|
||||||
|
for (let i = 0; i < relativeSegments.length; i++) {
|
||||||
|
if (outputSegments[offset + i] !== relativeSegments[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripLeadingRelativeDir(relativeFilePath, relativeDir) {
|
||||||
|
const fileSegments = splitPathSegments(relativeFilePath);
|
||||||
|
const dirSegments = splitPathSegments(relativeDir);
|
||||||
|
if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) {
|
||||||
|
return relativeFilePath;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < dirSegments.length; i++) {
|
||||||
|
if (fileSegments[i] !== dirSegments[i]) {
|
||||||
|
return relativeFilePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path.join(...fileSegments.slice(dirSegments.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
|
||||||
|
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format);
|
||||||
|
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
|
||||||
|
const relativeDir = path.dirname(relativeBasePath);
|
||||||
|
let relativeFilePath = `${relativeBasePath}.${ext}`;
|
||||||
|
if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) {
|
||||||
|
if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) {
|
||||||
|
relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const outFile = path.join(outputBaseDir, relativeFilePath);
|
||||||
|
return {
|
||||||
|
outFile,
|
||||||
|
relativeFilePath,
|
||||||
|
outFilename: path.basename(relativeFilePath)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCancelledError() {
|
||||||
|
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNotCancelled(isCancelled) {
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
throw buildCancelledError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExitCode(error) {
|
||||||
|
const code = Number(error?.code);
|
||||||
|
if (Number.isFinite(code)) {
|
||||||
|
return Math.trunc(code);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteShellArg(value) {
|
||||||
|
const text = String(value == null ? '' : value);
|
||||||
|
if (!text) {
|
||||||
|
return "''";
|
||||||
|
}
|
||||||
|
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `'${text.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommandLine(cmd, args = []) {
|
||||||
|
const normalizedArgs = Array.isArray(args) ? args : [];
|
||||||
|
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFilePreservingRaw(sourcePath, targetPath) {
|
||||||
|
const rawSource = String(sourcePath || '').trim();
|
||||||
|
const rawTarget = String(targetPath || '').trim();
|
||||||
|
if (!rawSource || !rawTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = path.resolve(rawSource);
|
||||||
|
const target = path.resolve(rawTarget);
|
||||||
|
if (source === target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.copyFileSync(source, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProcessTracked({
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
onStdoutLine,
|
||||||
|
onStderrLine,
|
||||||
|
context,
|
||||||
|
onProcessHandle,
|
||||||
|
isCancelled
|
||||||
|
}) {
|
||||||
|
assertNotCancelled(isCancelled);
|
||||||
|
const handle = spawnTrackedProcess({
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
onStdoutLine,
|
||||||
|
onStderrLine,
|
||||||
|
context
|
||||||
|
});
|
||||||
|
if (typeof onProcessHandle === 'function') {
|
||||||
|
onProcessHandle(handle);
|
||||||
|
}
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
handle.cancel();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await handle.promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
throw buildCancelledError();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rip and encode a CD.
|
||||||
|
*
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} options.jobId - Job ID for logging
|
||||||
|
* @param {string} options.devicePath - e.g. /dev/sr0
|
||||||
|
* @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia
|
||||||
|
* @param {string} options.rawWavDir - temp dir for WAV files
|
||||||
|
* @param {string} options.outputDir - final output dir
|
||||||
|
* @param {string} options.format - wav|flac|mp3|opus|ogg
|
||||||
|
* @param {object} options.formatOptions - encoder-specific options
|
||||||
|
* @param {number[]} options.selectedTracks - track positions to rip (empty = all)
|
||||||
|
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
||||||
|
* @param {object} options.meta - album metadata {title, artist, year}
|
||||||
|
* @param {string} options.outputTemplate - template for relative output path without extension
|
||||||
|
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
||||||
|
* @param {Function} options.onLog - (level, msg) => void
|
||||||
|
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
|
||||||
|
* @param {Function} options.isCancelled - returns true when user requested cancellation
|
||||||
|
* @param {object} options.context - passed to spawnTrackedProcess
|
||||||
|
*/
|
||||||
|
async function ripAndEncode(options) {
|
||||||
|
const {
|
||||||
|
jobId,
|
||||||
|
devicePath,
|
||||||
|
cdparanoiaCmd = 'cdparanoia',
|
||||||
|
rawWavDir,
|
||||||
|
outputDir,
|
||||||
|
format = 'flac',
|
||||||
|
formatOptions = {},
|
||||||
|
selectedTracks = [],
|
||||||
|
tracks = [],
|
||||||
|
meta = {},
|
||||||
|
outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||||
|
onProgress,
|
||||||
|
onLog,
|
||||||
|
onProcessHandle,
|
||||||
|
isCancelled,
|
||||||
|
context
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!SUPPORTED_FORMATS.has(format)) {
|
||||||
|
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracksToRip = selectedTracks.length > 0
|
||||||
|
? tracks.filter((t) => selectedTracks.includes(t.position))
|
||||||
|
: tracks;
|
||||||
|
|
||||||
|
if (tracksToRip.length === 0) {
|
||||||
|
throw new Error('Keine Tracks zum Rippen ausgewählt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDir(rawWavDir);
|
||||||
|
await ensureDir(outputDir);
|
||||||
|
|
||||||
|
logger.info('rip:start', {
|
||||||
|
jobId,
|
||||||
|
devicePath,
|
||||||
|
format,
|
||||||
|
trackCount: tracksToRip.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = (level, msg) => {
|
||||||
|
logger[level] && logger[level](msg, { jobId });
|
||||||
|
onLog && onLog(level, msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||||
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
|
assertNotCancelled(isCancelled);
|
||||||
|
const track = tracksToRip[i];
|
||||||
|
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||||
|
const ripArgs = ['-d', devicePath, String(track.position), wavFile];
|
||||||
|
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'rip',
|
||||||
|
trackEvent: 'start',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 0,
|
||||||
|
percent: (i / tracksToRip.length) * 50
|
||||||
|
});
|
||||||
|
|
||||||
|
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
||||||
|
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runProcessTracked({
|
||||||
|
cmd: cdparanoiaCmd,
|
||||||
|
args: ripArgs,
|
||||||
|
cwd: rawWavDir,
|
||||||
|
onStderrLine(line) {
|
||||||
|
const parsed = parseCdParanoiaProgress(line);
|
||||||
|
if (parsed && parsed.percent !== null) {
|
||||||
|
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'rip',
|
||||||
|
trackEvent: 'progress',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: parsed.percent,
|
||||||
|
percent: overallPercent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
onProcessHandle,
|
||||||
|
isCancelled
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'rip',
|
||||||
|
trackEvent: 'complete',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 100,
|
||||||
|
percent: ((i + 1) / tracksToRip.length) * 50
|
||||||
|
});
|
||||||
|
|
||||||
|
log('info', `Track ${track.position} gerippt.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||||
|
if (format === 'wav') {
|
||||||
|
// Keep RAW WAVs in place and copy them to the final output structure.
|
||||||
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
|
assertNotCancelled(isCancelled);
|
||||||
|
const track = tracksToRip[i];
|
||||||
|
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||||
|
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'encode',
|
||||||
|
trackEvent: 'start',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 0,
|
||||||
|
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||||
|
});
|
||||||
|
ensureDir(path.dirname(outFile));
|
||||||
|
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||||||
|
copyFilePreservingRaw(wavFile, outFile);
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'encode',
|
||||||
|
trackEvent: 'complete',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 100,
|
||||||
|
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||||
|
});
|
||||||
|
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||||
|
}
|
||||||
|
return { outputDir, format, trackCount: tracksToRip.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
|
assertNotCancelled(isCancelled);
|
||||||
|
const track = tracksToRip[i];
|
||||||
|
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(wavFile)) {
|
||||||
|
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
|
||||||
|
ensureDir(path.dirname(outFile));
|
||||||
|
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'encode',
|
||||||
|
trackEvent: 'start',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 0,
|
||||||
|
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||||
|
});
|
||||||
|
|
||||||
|
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
||||||
|
|
||||||
|
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
|
||||||
|
log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runProcessTracked({
|
||||||
|
cmd: encodeArgs.cmd,
|
||||||
|
args: encodeArgs.args,
|
||||||
|
cwd: rawWavDir,
|
||||||
|
onStdoutLine() {},
|
||||||
|
onStderrLine() {},
|
||||||
|
context,
|
||||||
|
onProcessHandle,
|
||||||
|
isCancelled
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety net: some encoders (e.g. older flac without --no-delete-input-file) may remove
|
||||||
|
// the source WAV. Restore it from the encoded output so the RAW folder stays filled.
|
||||||
|
if (!fs.existsSync(wavFile) && fs.existsSync(outFile)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(outFile, wavFile);
|
||||||
|
log('info', `Track ${track.position}: WAV-Quelldatei vom Encoder gelöscht – aus Output wiederhergestellt.`);
|
||||||
|
} catch (restoreErr) {
|
||||||
|
log('warn', `Track ${track.position}: WAV-Wiederherstellung fehlgeschlagen: ${restoreErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress && onProgress({
|
||||||
|
phase: 'encode',
|
||||||
|
trackEvent: 'complete',
|
||||||
|
trackIndex: i + 1,
|
||||||
|
trackTotal: tracksToRip.length,
|
||||||
|
trackPosition: track.position,
|
||||||
|
trackPercent: 100,
|
||||||
|
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
||||||
|
});
|
||||||
|
|
||||||
|
log('info', `Track ${track.position} encodiert.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { outputDir, format, trackCount: tracksToRip.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
|
||||||
|
const artist = track?.artist || meta?.artist || '';
|
||||||
|
const album = meta?.title || '';
|
||||||
|
const year = meta?.year ? String(meta.year) : '';
|
||||||
|
const trackTitle = track.title || `Track ${track.position}`;
|
||||||
|
const trackNum = String(track.position);
|
||||||
|
|
||||||
|
if (format === 'flac') {
|
||||||
|
const level = Number(opts.flacCompression ?? 5);
|
||||||
|
const clampedLevel = Math.max(0, Math.min(8, level));
|
||||||
|
return {
|
||||||
|
cmd: 'flac',
|
||||||
|
args: [
|
||||||
|
`--compression-level-${clampedLevel}`,
|
||||||
|
'--no-delete-input-file', // flac deletes input WAV by default; keep RAW folder filled
|
||||||
|
'--tag', `TITLE=${trackTitle}`,
|
||||||
|
'--tag', `ARTIST=${artist}`,
|
||||||
|
'--tag', `ALBUM=${album}`,
|
||||||
|
'--tag', `DATE=${year}`,
|
||||||
|
'--tag', `TRACKNUMBER=${trackNum}`,
|
||||||
|
wavFile,
|
||||||
|
'-o', outFile
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'mp3') {
|
||||||
|
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
|
||||||
|
const args = ['--id3v2-only', '--noreplaygain'];
|
||||||
|
if (mode === 'vbr') {
|
||||||
|
const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)));
|
||||||
|
args.push('-V', String(quality));
|
||||||
|
} else {
|
||||||
|
const bitrate = Number(opts.mp3Bitrate ?? 192);
|
||||||
|
args.push('-b', String(bitrate));
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
|
'--tt', trackTitle,
|
||||||
|
'--ta', artist,
|
||||||
|
'--tl', album,
|
||||||
|
'--ty', year,
|
||||||
|
'--tn', trackNum,
|
||||||
|
wavFile,
|
||||||
|
outFile
|
||||||
|
);
|
||||||
|
return { cmd: 'lame', args };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'opus') {
|
||||||
|
const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160)));
|
||||||
|
const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10)));
|
||||||
|
return {
|
||||||
|
cmd: 'opusenc',
|
||||||
|
args: [
|
||||||
|
'--bitrate', String(bitrate),
|
||||||
|
'--comp', String(complexity),
|
||||||
|
'--title', trackTitle,
|
||||||
|
'--artist', artist,
|
||||||
|
'--album', album,
|
||||||
|
'--date', year,
|
||||||
|
'--tracknumber', trackNum,
|
||||||
|
wavFile,
|
||||||
|
outFile
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'ogg') {
|
||||||
|
const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)));
|
||||||
|
return {
|
||||||
|
cmd: 'oggenc',
|
||||||
|
args: [
|
||||||
|
'-q', String(quality),
|
||||||
|
'-t', trackTitle,
|
||||||
|
'-a', artist,
|
||||||
|
'-l', album,
|
||||||
|
'-d', year,
|
||||||
|
'-N', trackNum,
|
||||||
|
'-o', outFile,
|
||||||
|
wavFile
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unbekanntes Format: ${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseToc,
|
||||||
|
readToc,
|
||||||
|
ripAndEncode,
|
||||||
|
buildOutputDir,
|
||||||
|
buildOutputFilename,
|
||||||
|
DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||||
|
SUPPORTED_FORMATS
|
||||||
|
};
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
/**
|
||||||
|
* 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 { 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 });
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const child = spawn(prepared.cmd, prepared.args, {
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||||
|
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||||
|
});
|
||||||
|
|
||||||
|
output = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
||||||
|
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||||
|
success = result.code === 0;
|
||||||
|
if (!success) errorMessage = `Exit-Code ${result.code}`;
|
||||||
|
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||||
|
status: success ? 'success' : 'error',
|
||||||
|
success,
|
||||||
|
outcome: success ? 'success' : 'error',
|
||||||
|
exitCode: result.code,
|
||||||
|
message: success ? null : errorMessage,
|
||||||
|
output: output || null,
|
||||||
|
stdout: result.stdout || null,
|
||||||
|
stderr: result.stderr || null,
|
||||||
|
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();
|
||||||
@@ -0,0 +1,788 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const settingsService = require('./settingsService');
|
||||||
|
const logger = require('./logger').child('DISK');
|
||||||
|
const { parseToc } = require('./cdRipService');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 4000;
|
||||||
|
const MIN_POLL_INTERVAL_MS = 1000;
|
||||||
|
const MAX_POLL_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
function toBoolean(value, fallback = false) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPollIntervalMs(rawValue) {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
const clamped = Math.max(MIN_POLL_INTERVAL_MS, Math.min(MAX_POLL_INTERVAL_MS, Math.trunc(parsed)));
|
||||||
|
return clamped || DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenDevices(nodes, acc = []) {
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
acc.push(node);
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
flattenDevices(node.children, acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignature(info) {
|
||||||
|
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMediaProfile(rawValue) {
|
||||||
|
const value = String(rawValue || '').trim().toLowerCase();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value === 'bluray'
|
||||||
|
|| value === 'blu-ray'
|
||||||
|
|| value === 'blu_ray'
|
||||||
|
|| value === 'bd'
|
||||||
|
|| value === 'bdmv'
|
||||||
|
|| value === 'bdrom'
|
||||||
|
|| value === 'bd-rom'
|
||||||
|
|| value === 'bd-r'
|
||||||
|
|| value === 'bd-re'
|
||||||
|
) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value === 'dvd'
|
||||||
|
|| value === 'dvdvideo'
|
||||||
|
|| value === 'dvd-video'
|
||||||
|
|| value === 'dvdrom'
|
||||||
|
|| value === 'dvd-rom'
|
||||||
|
|| value === 'video_ts'
|
||||||
|
|| value === 'iso9660'
|
||||||
|
) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (value === 'cd' || value === 'audio_cd') {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpecificMediaProfile(value) {
|
||||||
|
return value === 'bluray' || value === 'dvd' || value === 'cd';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMediaProfileFromTextParts(parts) {
|
||||||
|
const markerText = (parts || [])
|
||||||
|
.map((value) => String(value || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (!markerText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re/.test(markerText)) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (/(^|[\s_-])video_ts($|[\s_-])|dvd|iso9660/.test(markerText)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||||
|
const fstype = String(rawFsType || '').trim().toLowerCase();
|
||||||
|
if (fstype === 'audio_cd') {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
const model = String(rawModel || '').trim().toLowerCase();
|
||||||
|
const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model);
|
||||||
|
const hasDvdModelMarker = /dvd/.test(model);
|
||||||
|
const hasCdOnlyModelMarker = /(^|[\s_-])cd([\s_-]|$)|cd-?rom/.test(model) && !hasBlurayModelMarker && !hasDvdModelMarker;
|
||||||
|
|
||||||
|
if (!fstype) {
|
||||||
|
if (hasBlurayModelMarker) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (hasDvdModelMarker) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fstype.includes('udf')) {
|
||||||
|
// UDF is used by both DVDs (UDF 1.x) and Blu-rays (UDF 2.x).
|
||||||
|
// Drive model alone (hasBlurayModelMarker) is not reliable: a BD-ROM drive
|
||||||
|
// with a DVD inside would incorrectly be detected as Blu-ray.
|
||||||
|
// Return null so UDF version detection via blkid can decide.
|
||||||
|
if (hasBlurayModelMarker) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (hasDvdModelMarker) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fstype.includes('iso9660') || fstype.includes('cdfs')) {
|
||||||
|
// iso9660/cdfs is never used by Blu-ray discs (they use UDF 2.x).
|
||||||
|
// Ignore hasBlurayModelMarker – it only reflects drive capability.
|
||||||
|
if (hasCdOnlyModelMarker) {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||||
|
const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => {
|
||||||
|
const normalizedKey = String(key || '').trim().toUpperCase();
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[normalizedKey] = String(rawValue || '').trim();
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1');
|
||||||
|
if (hasFlag('ID_CDROM_MEDIA_BD')) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (hasFlag('ID_CDROM_MEDIA_DVD')) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (hasFlag('ID_CDROM_MEDIA_CD')) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiskDetectionService extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.running = false;
|
||||||
|
this.timer = null;
|
||||||
|
this.lastDetected = null;
|
||||||
|
this.lastPresent = false;
|
||||||
|
this.deviceLocks = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running = true;
|
||||||
|
logger.info('start');
|
||||||
|
this.scheduleNext(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false;
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
logger.info('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext(delayMs) {
|
||||||
|
if (!this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timer = setTimeout(async () => {
|
||||||
|
let nextDelay = DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const map = await settingsService.getSettingsMap();
|
||||||
|
nextDelay = clampPollIntervalMs(map.disc_poll_interval_ms);
|
||||||
|
const autoDetectionEnabled = toBoolean(map.disc_auto_detection_enabled, true);
|
||||||
|
logger.debug('poll:tick', {
|
||||||
|
driveMode: map.drive_mode,
|
||||||
|
driveDevice: map.drive_device,
|
||||||
|
nextDelay,
|
||||||
|
autoDetectionEnabled
|
||||||
|
});
|
||||||
|
if (autoDetectionEnabled) {
|
||||||
|
const detected = await this.detectDisc(map);
|
||||||
|
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
||||||
|
} else {
|
||||||
|
logger.debug('poll:skip:auto-detection-disabled', { nextDelay });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('poll:error', { error: errorToMeta(error) });
|
||||||
|
this.emit('error', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleNext(nextDelay);
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescanAndEmit() {
|
||||||
|
try {
|
||||||
|
const map = await settingsService.getSettingsMap();
|
||||||
|
logger.info('rescan:requested', {
|
||||||
|
driveMode: map.drive_mode,
|
||||||
|
driveDevice: map.drive_device
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = await this.detectDisc(map);
|
||||||
|
const result = this.applyDetectionResult(detected, { forceInsertEvent: true });
|
||||||
|
|
||||||
|
logger.info('rescan:done', {
|
||||||
|
present: result.present,
|
||||||
|
emitted: result.emitted,
|
||||||
|
changed: result.changed,
|
||||||
|
detected: result.device || null
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('rescan:error', { error: errorToMeta(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeDevicePath(devicePath) {
|
||||||
|
return String(devicePath || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
lockDevice(devicePath, owner = null) {
|
||||||
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.deviceLocks.get(normalized) || {
|
||||||
|
count: 0,
|
||||||
|
owners: []
|
||||||
|
};
|
||||||
|
|
||||||
|
entry.count += 1;
|
||||||
|
if (owner) {
|
||||||
|
entry.owners.push(owner);
|
||||||
|
}
|
||||||
|
this.deviceLocks.set(normalized, entry);
|
||||||
|
|
||||||
|
logger.info('lock:add', {
|
||||||
|
devicePath: normalized,
|
||||||
|
count: entry.count,
|
||||||
|
owner
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
devicePath: normalized,
|
||||||
|
owner
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockDevice(devicePath, owner = null) {
|
||||||
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.deviceLocks.get(normalized);
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count = Math.max(0, entry.count - 1);
|
||||||
|
if (entry.count === 0) {
|
||||||
|
this.deviceLocks.delete(normalized);
|
||||||
|
logger.info('lock:remove', {
|
||||||
|
devicePath: normalized,
|
||||||
|
owner
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deviceLocks.set(normalized, entry);
|
||||||
|
logger.info('lock:decrement', {
|
||||||
|
devicePath: normalized,
|
||||||
|
count: entry.count,
|
||||||
|
owner
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeviceLocked(devicePath) {
|
||||||
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.deviceLocks.has(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveLocks() {
|
||||||
|
return Array.from(this.deviceLocks.entries()).map(([path, info]) => ({
|
||||||
|
path,
|
||||||
|
count: info.count,
|
||||||
|
owners: info.owners
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDetectionResult(detected, { forceInsertEvent = false } = {}) {
|
||||||
|
const isPresent = Boolean(detected);
|
||||||
|
const changed =
|
||||||
|
isPresent &&
|
||||||
|
(!this.lastDetected || buildSignature(this.lastDetected) !== buildSignature(detected));
|
||||||
|
|
||||||
|
if (isPresent) {
|
||||||
|
const shouldEmitInserted = forceInsertEvent || !this.lastPresent || changed;
|
||||||
|
this.lastDetected = detected;
|
||||||
|
this.lastPresent = true;
|
||||||
|
|
||||||
|
if (shouldEmitInserted) {
|
||||||
|
logger.info('disc:inserted', { detected, forceInsertEvent, changed });
|
||||||
|
this.emit('discInserted', detected);
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
changed,
|
||||||
|
emitted: 'discInserted',
|
||||||
|
device: detected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
changed,
|
||||||
|
emitted: 'none',
|
||||||
|
device: detected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPresent && this.lastPresent) {
|
||||||
|
const removed = this.lastDetected;
|
||||||
|
this.lastDetected = null;
|
||||||
|
this.lastPresent = false;
|
||||||
|
logger.info('disc:removed', { removed });
|
||||||
|
this.emit('discRemoved', removed);
|
||||||
|
return {
|
||||||
|
present: false,
|
||||||
|
changed: true,
|
||||||
|
emitted: 'discRemoved',
|
||||||
|
device: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
present: false,
|
||||||
|
changed: false,
|
||||||
|
emitted: 'none',
|
||||||
|
device: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectDisc(settingsMap) {
|
||||||
|
if (settingsMap.drive_mode === 'explicit') {
|
||||||
|
return this.detectExplicit(settingsMap.drive_device);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.detectAuto();
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectExplicit(devicePath) {
|
||||||
|
if (this.isDeviceLocked(devicePath)) {
|
||||||
|
logger.debug('detect:explicit:locked', {
|
||||||
|
devicePath,
|
||||||
|
activeLocks: this.getActiveLocks()
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!devicePath || !fs.existsSync(devicePath)) {
|
||||||
|
logger.debug('detect:explicit:not-found', { devicePath });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaState = await this.checkMediaPresent(devicePath);
|
||||||
|
if (!mediaState.hasMedia) {
|
||||||
|
logger.debug('detect:explicit:no-media', { devicePath });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const discLabel = await this.getDiscLabel(devicePath);
|
||||||
|
|
||||||
|
const details = await this.getBlockDeviceInfo();
|
||||||
|
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||||
|
const detectedFsType = String(match.fstype || mediaState.type || '').trim() || null;
|
||||||
|
|
||||||
|
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
||||||
|
discLabel,
|
||||||
|
label: match.label,
|
||||||
|
model: match.model,
|
||||||
|
fstype: detectedFsType,
|
||||||
|
mountpoint: match.mountpoint
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = {
|
||||||
|
mode: 'explicit',
|
||||||
|
path: devicePath,
|
||||||
|
name: match.name || devicePath.split('/').pop(),
|
||||||
|
model: match.model || 'Unknown',
|
||||||
|
label: match.label || null,
|
||||||
|
discLabel: discLabel || null,
|
||||||
|
mountpoint: match.mountpoint || null,
|
||||||
|
fstype: detectedFsType,
|
||||||
|
mediaProfile: mediaProfile || null,
|
||||||
|
index: this.guessDiscIndex(match.name || devicePath)
|
||||||
|
};
|
||||||
|
logger.debug('detect:explicit:success', { detected });
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectAuto() {
|
||||||
|
const details = await this.getBlockDeviceInfo();
|
||||||
|
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
||||||
|
|
||||||
|
for (const item of romCandidates) {
|
||||||
|
const path = item.path || (item.name ? `/dev/${item.name}` : null);
|
||||||
|
if (!path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDeviceLocked(path)) {
|
||||||
|
logger.debug('detect:auto:skip-locked', {
|
||||||
|
path,
|
||||||
|
activeLocks: this.getActiveLocks()
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaState = await this.checkMediaPresent(path);
|
||||||
|
if (!mediaState.hasMedia) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const discLabel = await this.getDiscLabel(path);
|
||||||
|
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
|
||||||
|
|
||||||
|
const mediaProfile = await this.inferMediaProfile(path, {
|
||||||
|
discLabel,
|
||||||
|
label: item.label,
|
||||||
|
model: item.model,
|
||||||
|
fstype: detectedFsType,
|
||||||
|
mountpoint: item.mountpoint
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = {
|
||||||
|
mode: 'auto',
|
||||||
|
path,
|
||||||
|
name: item.name,
|
||||||
|
model: item.model || 'Optical Drive',
|
||||||
|
label: item.label || null,
|
||||||
|
discLabel: discLabel || null,
|
||||||
|
mountpoint: item.mountpoint || null,
|
||||||
|
fstype: detectedFsType,
|
||||||
|
mediaProfile: mediaProfile || null,
|
||||||
|
index: this.guessDiscIndex(item.name)
|
||||||
|
};
|
||||||
|
logger.debug('detect:auto:success', { detected });
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('detect:auto:none');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlockDeviceInfo() {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('lsblk', [
|
||||||
|
'-J',
|
||||||
|
'-o',
|
||||||
|
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL'
|
||||||
|
]);
|
||||||
|
const parsed = JSON.parse(stdout);
|
||||||
|
const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
path: entry.path,
|
||||||
|
type: entry.type,
|
||||||
|
mountpoint: entry.mountpoint,
|
||||||
|
fstype: entry.fstype,
|
||||||
|
label: entry.label,
|
||||||
|
model: entry.model
|
||||||
|
}));
|
||||||
|
logger.debug('lsblk:ok', { deviceCount: devices.length });
|
||||||
|
return devices;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('lsblk:failed', { error: errorToMeta(error) });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkMediaPresent(devicePath) {
|
||||||
|
let blkidType = null;
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||||
|
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
||||||
|
} catch (_error) {
|
||||||
|
// blkid failed – could mean no disc, or an audio CD (no filesystem type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blkidType) {
|
||||||
|
logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType });
|
||||||
|
return { hasMedia: true, type: blkidType };
|
||||||
|
}
|
||||||
|
|
||||||
|
// blkid found nothing – audio CDs have no filesystem, so fall back to udevadm
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('udevadm', [
|
||||||
|
'info',
|
||||||
|
'--query=property',
|
||||||
|
'--name',
|
||||||
|
devicePath
|
||||||
|
]);
|
||||||
|
const props = {};
|
||||||
|
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf('=');
|
||||||
|
if (idx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim();
|
||||||
|
}
|
||||||
|
const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1');
|
||||||
|
const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1');
|
||||||
|
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
|
||||||
|
if (hasCD && !hasDVD && !hasBD) {
|
||||||
|
logger.debug('udevadm:audio-cd', { devicePath });
|
||||||
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
|
}
|
||||||
|
} catch (_udevError) {
|
||||||
|
// udevadm not available or failed – ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: cdparanoia can read the TOC of audio CDs directly.
|
||||||
|
// Useful when udev media flags are not propagated (e.g. VM passthrough).
|
||||||
|
// Some builds return non-zero even when TOC output exists, so parse both
|
||||||
|
// stdout/stderr and treat valid TOC lines as "audio CD present".
|
||||||
|
// Keep compatibility with previous behavior: exit 0 counts as media even
|
||||||
|
// when TOC output format cannot be parsed.
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
|
||||||
|
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length });
|
||||||
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
|
}
|
||||||
|
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
|
||||||
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
|
} catch (cdError) {
|
||||||
|
const stderr = String(cdError?.stderr || '');
|
||||||
|
const stdout = String(cdError?.stdout || '');
|
||||||
|
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length });
|
||||||
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
|
}
|
||||||
|
// cdparanoia failed and no TOC output could be parsed.
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('blkid:no-media-or-fail', { devicePath });
|
||||||
|
return { hasMedia: false, type: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDiscLabel(devicePath) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'LABEL', devicePath]);
|
||||||
|
const label = stdout.trim();
|
||||||
|
logger.debug('blkid:label', { devicePath, discLabel: label || null });
|
||||||
|
return label || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('blkid:no-label', { devicePath, error: errorToMeta(error) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async inferMediaProfileFromUdev(devicePath) {
|
||||||
|
const normalizedPath = String(devicePath || '').trim();
|
||||||
|
if (!normalizedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('udevadm', ['info', '--query=property', '--name', normalizedPath]);
|
||||||
|
const properties = {};
|
||||||
|
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf('=');
|
||||||
|
if (idx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(line.slice(0, idx)).trim();
|
||||||
|
const value = String(line.slice(idx + 1)).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
properties[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferred = inferMediaProfileFromUdevProperties(properties);
|
||||||
|
if (inferred) {
|
||||||
|
logger.debug('udev:media-profile', { devicePath: normalizedPath, inferred });
|
||||||
|
}
|
||||||
|
return inferred;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('udev:media-profile:failed', {
|
||||||
|
devicePath: normalizedPath,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async inferMediaProfile(devicePath, hints = {}) {
|
||||||
|
// Audio CDs have no filesystem – short-circuit immediately
|
||||||
|
if (String(hints?.fstype || '').trim().toLowerCase() === 'audio_cd') {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicit = normalizeMediaProfile(hints?.mediaProfile);
|
||||||
|
if (isSpecificMediaProfile(explicit)) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only pass disc-specific fields – NOT hints?.model (drive model).
|
||||||
|
// Drive model (e.g. "BD-ROM") reflects drive capability, not disc type.
|
||||||
|
// A BD-ROM drive with a DVD would otherwise be detected as Blu-ray here.
|
||||||
|
const hinted = inferMediaProfileFromTextParts([
|
||||||
|
hints?.discLabel,
|
||||||
|
hints?.label,
|
||||||
|
hints?.fstype,
|
||||||
|
]);
|
||||||
|
if (hinted) {
|
||||||
|
return hinted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountpoint = String(hints?.mountpoint || '').trim();
|
||||||
|
if (mountpoint) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(`${mountpoint}/BDMV`)) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore fs errors
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(`${mountpoint}/VIDEO_TS`)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore fs errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byUdev = await this.inferMediaProfileFromUdev(devicePath);
|
||||||
|
if (byUdev) {
|
||||||
|
return byUdev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hintFstype = String(hints?.fstype || '').trim().toLowerCase();
|
||||||
|
const byFsTypeHint = inferMediaProfileFromFsTypeAndModel(hints?.fstype, hints?.model);
|
||||||
|
const udfHintFallback = hintFstype.includes('udf')
|
||||||
|
? inferMediaProfileFromFsTypeAndModel(hints?.fstype, null)
|
||||||
|
: null;
|
||||||
|
// UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model
|
||||||
|
// marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the
|
||||||
|
// early return and fall through to the blkid check which uses the UDF version number.
|
||||||
|
if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) {
|
||||||
|
return byFsTypeHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('blkid', ['-p', '-o', 'export', devicePath]);
|
||||||
|
const payload = {};
|
||||||
|
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf('=');
|
||||||
|
if (idx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(line.slice(0, idx)).trim().toUpperCase();
|
||||||
|
const value = String(line.slice(idx + 1)).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
payload[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// APPLICATION_ID contains disc-specific strings (e.g. "BDAV"/"BDMV" for Blu-ray,
|
||||||
|
// "DVD_VIDEO" for DVD). Drive model is excluded – see reasoning above.
|
||||||
|
const byBlkidMarker = inferMediaProfileFromTextParts([
|
||||||
|
payload.LABEL,
|
||||||
|
payload.TYPE,
|
||||||
|
payload.VERSION,
|
||||||
|
payload.APPLICATION_ID,
|
||||||
|
]);
|
||||||
|
if (byBlkidMarker) {
|
||||||
|
return byBlkidMarker;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = String(payload.TYPE || '').trim().toLowerCase();
|
||||||
|
// For UDF, VERSION is the most reliable discriminator: 1.x → DVD, 2.x → Blu-ray.
|
||||||
|
// This check must run independently of inferMediaProfileFromFsTypeAndModel so it
|
||||||
|
// is not skipped when the drive model returns null (BD-ROM drive with DVD inside).
|
||||||
|
if (type.includes('udf')) {
|
||||||
|
const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.'));
|
||||||
|
if (Number.isFinite(version)) {
|
||||||
|
return version >= 2 ? 'bluray' : 'dvd';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model);
|
||||||
|
if (byBlkidFsType) {
|
||||||
|
return byBlkidFsType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID:
|
||||||
|
// prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc.
|
||||||
|
const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null);
|
||||||
|
if (byBlkidFsTypeWithoutModel) {
|
||||||
|
return byBlkidFsTypeWithoutModel;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('infer-media-profile:blkid-failed', {
|
||||||
|
devicePath,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (udfHintFallback) {
|
||||||
|
return udfHintFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
guessDiscIndex(name) {
|
||||||
|
if (!name) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = String(name).match(/(\d+)$/);
|
||||||
|
return match ? Number(match[1]) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new DiskDetectionService();
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const settingsService = require('./settingsService');
|
||||||
|
const wsService = require('./websocketService');
|
||||||
|
const logger = require('./logger').child('HWMON');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MS = 5000;
|
||||||
|
const MIN_INTERVAL_MS = 1000;
|
||||||
|
const MAX_INTERVAL_MS = 60000;
|
||||||
|
const DF_TIMEOUT_MS = 1800;
|
||||||
|
const SENSORS_TIMEOUT_MS = 1800;
|
||||||
|
const NVIDIA_SMI_TIMEOUT_MS = 1800;
|
||||||
|
const RELEVANT_SETTINGS_KEYS = new Set([
|
||||||
|
'hardware_monitoring_enabled',
|
||||||
|
'hardware_monitoring_interval_ms',
|
||||||
|
'raw_dir',
|
||||||
|
'raw_dir_bluray',
|
||||||
|
'raw_dir_dvd',
|
||||||
|
'raw_dir_cd',
|
||||||
|
'movie_dir',
|
||||||
|
'movie_dir_bluray',
|
||||||
|
'movie_dir_dvd',
|
||||||
|
'log_dir'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoolean(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathSetting(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampIntervalMs(rawValue) {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return DEFAULT_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
const clamped = Math.max(MIN_INTERVAL_MS, Math.min(MAX_INTERVAL_MS, Math.trunc(parsed)));
|
||||||
|
return clamped || DEFAULT_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundNumber(rawValue, digits = 1) {
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const factor = 10 ** digits;
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageNumberList(values = []) {
|
||||||
|
const list = (Array.isArray(values) ? values : []).filter((value) => Number.isFinite(Number(value)));
|
||||||
|
if (list.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sum = list.reduce((acc, value) => acc + Number(value), 0);
|
||||||
|
return sum / list.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaybeNumber(rawValue) {
|
||||||
|
if (rawValue === null || rawValue === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
const normalized = String(rawValue).trim().replace(',', '.');
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cleaned = normalized.replace(/[^0-9.+-]/g, '');
|
||||||
|
if (!cleaned) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(cleaned);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTempC(rawValue) {
|
||||||
|
const parsed = parseMaybeNumber(rawValue);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let celsius = parsed;
|
||||||
|
if (Math.abs(celsius) > 500) {
|
||||||
|
celsius = celsius / 1000;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(celsius) || celsius <= -40 || celsius >= 160) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return roundNumber(celsius, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommandMissingError(error) {
|
||||||
|
return String(error?.code || '').toUpperCase() === 'ENOENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTextFileSafe(filePath) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filePath, 'utf-8').trim();
|
||||||
|
} catch (_error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTemperatureCandidates(node, pathParts = [], out = []) {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(node)) {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
collectTemperatureCandidates(value, [...pathParts, key], out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!/^temp\d+_input$/i.test(String(key || ''))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalizedTemp = normalizeTempC(value);
|
||||||
|
if (normalizedTemp === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
label: [...pathParts, key].join(' / '),
|
||||||
|
value: normalizedTemp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTemperatureCandidates(candidates = []) {
|
||||||
|
const perCoreSamples = new Map();
|
||||||
|
const packageSamples = [];
|
||||||
|
const genericSamples = [];
|
||||||
|
|
||||||
|
for (const entry of Array.isArray(candidates) ? candidates : []) {
|
||||||
|
const value = Number(entry?.value);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const label = String(entry?.label || '');
|
||||||
|
const labelLower = label.toLowerCase();
|
||||||
|
const coreMatch = labelLower.match(/\bcore\s*([0-9]+)\b/);
|
||||||
|
if (coreMatch) {
|
||||||
|
const index = Number(coreMatch[1]);
|
||||||
|
if (Number.isFinite(index) && index >= 0) {
|
||||||
|
const list = perCoreSamples.get(index) || [];
|
||||||
|
list.push(value);
|
||||||
|
perCoreSamples.set(index, list);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/package id|tdie|tctl|cpu package|physical id/.test(labelLower)) {
|
||||||
|
packageSamples.push(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
genericSamples.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const perCore = Array.from(perCoreSamples.entries())
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([index, values]) => ({
|
||||||
|
index,
|
||||||
|
temperatureC: roundNumber(averageNumberList(values), 1)
|
||||||
|
}))
|
||||||
|
.filter((item) => item.temperatureC !== null);
|
||||||
|
|
||||||
|
const overallRaw = packageSamples.length > 0
|
||||||
|
? averageNumberList(packageSamples)
|
||||||
|
: (perCore.length > 0 ? averageNumberList(perCore.map((item) => item.temperatureC)) : averageNumberList(genericSamples));
|
||||||
|
const overallC = roundNumber(overallRaw, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
overallC,
|
||||||
|
perCore,
|
||||||
|
available: Boolean(overallC !== null || perCore.length > 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyCpuTemperatureLabel(label = '') {
|
||||||
|
const normalized = String(label || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /cpu|core|package|tdie|tctl|physical id|x86_pkg_temp|k10temp|zenpower|cpu-thermal|soc_thermal/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferCpuTemperatureCandidates(candidates = []) {
|
||||||
|
const list = Array.isArray(candidates) ? candidates : [];
|
||||||
|
const cpuLikely = list.filter((item) => isLikelyCpuTemperatureLabel(item?.label));
|
||||||
|
return cpuLikely.length > 0 ? cpuLikely : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDfStats(rawOutput) {
|
||||||
|
const lines = String(rawOutput || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dataLine = lines[lines.length - 1];
|
||||||
|
const columns = dataLine.split(/\s+/);
|
||||||
|
if (columns.length < 6) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalKb = parseMaybeNumber(columns[1]);
|
||||||
|
const usedKb = parseMaybeNumber(columns[2]);
|
||||||
|
const availableKb = parseMaybeNumber(columns[3]);
|
||||||
|
const usagePercent = parseMaybeNumber(String(columns[4]).replace('%', ''));
|
||||||
|
const mountPoint = columns.slice(5).join(' ');
|
||||||
|
|
||||||
|
if (!Number.isFinite(totalKb) || !Number.isFinite(usedKb) || !Number.isFinite(availableKb)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBytes: Math.max(0, Math.round(totalKb * 1024)),
|
||||||
|
usedBytes: Math.max(0, Math.round(usedKb * 1024)),
|
||||||
|
freeBytes: Math.max(0, Math.round(availableKb * 1024)),
|
||||||
|
usagePercent: Number.isFinite(usagePercent)
|
||||||
|
? roundNumber(usagePercent, 1)
|
||||||
|
: (totalKb > 0 ? roundNumber((usedKb / totalKb) * 100, 1) : null),
|
||||||
|
mountPoint: mountPoint || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNvidiaCsvLine(line) {
|
||||||
|
const columns = String(line || '').split(',').map((part) => part.trim());
|
||||||
|
if (columns.length < 10) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = parseMaybeNumber(columns[0]);
|
||||||
|
const memoryUsedMiB = parseMaybeNumber(columns[5]);
|
||||||
|
const memoryTotalMiB = parseMaybeNumber(columns[6]);
|
||||||
|
return {
|
||||||
|
index: Number.isFinite(index) ? Math.trunc(index) : null,
|
||||||
|
name: columns[1] || null,
|
||||||
|
utilizationPercent: roundNumber(parseMaybeNumber(columns[2]), 1),
|
||||||
|
memoryUtilizationPercent: roundNumber(parseMaybeNumber(columns[3]), 1),
|
||||||
|
temperatureC: roundNumber(parseMaybeNumber(columns[4]), 1),
|
||||||
|
memoryUsedBytes: Number.isFinite(memoryUsedMiB) ? Math.round(memoryUsedMiB * 1024 * 1024) : null,
|
||||||
|
memoryTotalBytes: Number.isFinite(memoryTotalMiB) ? Math.round(memoryTotalMiB * 1024 * 1024) : null,
|
||||||
|
powerDrawW: roundNumber(parseMaybeNumber(columns[7]), 1),
|
||||||
|
powerLimitW: roundNumber(parseMaybeNumber(columns[8]), 1),
|
||||||
|
fanPercent: roundNumber(parseMaybeNumber(columns[9]), 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class HardwareMonitorService {
|
||||||
|
constructor() {
|
||||||
|
this.enabled = false;
|
||||||
|
this.intervalMs = DEFAULT_INTERVAL_MS;
|
||||||
|
this.monitoredPaths = [];
|
||||||
|
this.running = false;
|
||||||
|
this.timer = null;
|
||||||
|
this.pollInFlight = false;
|
||||||
|
this.lastCpuTimes = null;
|
||||||
|
this.sensorsCommandAvailable = null;
|
||||||
|
this.nvidiaSmiAvailable = null;
|
||||||
|
this.lastSnapshot = {
|
||||||
|
enabled: false,
|
||||||
|
intervalMs: DEFAULT_INTERVAL_MS,
|
||||||
|
updatedAt: null,
|
||||||
|
sample: null,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.reloadFromSettings({
|
||||||
|
forceBroadcast: true,
|
||||||
|
forceImmediatePoll: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot() {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(this.lastSnapshot?.enabled),
|
||||||
|
intervalMs: Number(this.lastSnapshot?.intervalMs || DEFAULT_INTERVAL_MS),
|
||||||
|
updatedAt: this.lastSnapshot?.updatedAt || null,
|
||||||
|
sample: this.lastSnapshot?.sample || null,
|
||||||
|
error: this.lastSnapshot?.error || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSettingsChanged(changedKeys = []) {
|
||||||
|
const normalizedKeys = (Array.isArray(changedKeys) ? changedKeys : [])
|
||||||
|
.map((key) => String(key || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (normalizedKeys.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevant = normalizedKeys.some((key) => RELEVANT_SETTINGS_KEYS.has(key));
|
||||||
|
if (!relevant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.reloadFromSettings({
|
||||||
|
forceImmediatePoll: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadFromSettings(options = {}) {
|
||||||
|
const forceBroadcast = Boolean(options?.forceBroadcast);
|
||||||
|
const forceImmediatePoll = Boolean(options?.forceImmediatePoll);
|
||||||
|
let settingsMap = {};
|
||||||
|
try {
|
||||||
|
settingsMap = await settingsService.getSettingsMap();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('settings:load:failed', { error: errorToMeta(error) });
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEnabled = toBoolean(settingsMap.hardware_monitoring_enabled);
|
||||||
|
const nextIntervalMs = clampIntervalMs(settingsMap.hardware_monitoring_interval_ms);
|
||||||
|
const nextPaths = this.buildMonitoredPaths(settingsMap);
|
||||||
|
const wasEnabled = this.enabled;
|
||||||
|
const intervalChanged = nextIntervalMs !== this.intervalMs;
|
||||||
|
const pathsChanged = this.pathsSignature(this.monitoredPaths) !== this.pathsSignature(nextPaths);
|
||||||
|
|
||||||
|
this.enabled = nextEnabled;
|
||||||
|
this.intervalMs = nextIntervalMs;
|
||||||
|
this.monitoredPaths = nextPaths;
|
||||||
|
this.lastSnapshot = {
|
||||||
|
...this.lastSnapshot,
|
||||||
|
enabled: this.enabled,
|
||||||
|
intervalMs: this.intervalMs
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.stopPolling();
|
||||||
|
this.lastSnapshot = {
|
||||||
|
enabled: false,
|
||||||
|
intervalMs: this.intervalMs,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
sample: null,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
this.broadcastUpdate();
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.running) {
|
||||||
|
this.startPolling();
|
||||||
|
} else if (intervalChanged || pathsChanged || forceImmediatePoll || !wasEnabled) {
|
||||||
|
this.scheduleNext(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceBroadcast || intervalChanged || !wasEnabled) {
|
||||||
|
this.broadcastUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildMonitoredPaths(settingsMap = {}) {
|
||||||
|
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||||
|
const bluray = settingsService.resolveEffectiveToolSettings(sourceMap, 'bluray');
|
||||||
|
const dvd = settingsService.resolveEffectiveToolSettings(sourceMap, 'dvd');
|
||||||
|
const cd = settingsService.resolveEffectiveToolSettings(sourceMap, 'cd');
|
||||||
|
const blurayRawPath = normalizePathSetting(bluray?.raw_dir);
|
||||||
|
const dvdRawPath = normalizePathSetting(dvd?.raw_dir);
|
||||||
|
const cdRawPath = normalizePathSetting(cd?.raw_dir);
|
||||||
|
const blurayMoviePath = normalizePathSetting(bluray?.movie_dir);
|
||||||
|
const dvdMoviePath = normalizePathSetting(dvd?.movie_dir);
|
||||||
|
const monitoredPaths = [];
|
||||||
|
|
||||||
|
const addPath = (key, label, monitoredPath) => {
|
||||||
|
monitoredPaths.push({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
path: normalizePathSetting(monitoredPath)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (blurayRawPath && dvdRawPath && blurayRawPath !== dvdRawPath) {
|
||||||
|
addPath('raw_dir_bluray', 'RAW-Verzeichnis (Blu-ray)', blurayRawPath);
|
||||||
|
addPath('raw_dir_dvd', 'RAW-Verzeichnis (DVD)', dvdRawPath);
|
||||||
|
} else {
|
||||||
|
addPath('raw_dir', 'RAW-Verzeichnis', blurayRawPath || dvdRawPath || sourceMap.raw_dir);
|
||||||
|
}
|
||||||
|
addPath('raw_dir_cd', 'CD RAW-Ordner', cdRawPath || sourceMap.raw_dir_cd);
|
||||||
|
|
||||||
|
if (blurayMoviePath && dvdMoviePath && blurayMoviePath !== dvdMoviePath) {
|
||||||
|
addPath('movie_dir_bluray', 'Movie-Verzeichnis (Blu-ray)', blurayMoviePath);
|
||||||
|
addPath('movie_dir_dvd', 'Movie-Verzeichnis (DVD)', dvdMoviePath);
|
||||||
|
} else {
|
||||||
|
addPath('movie_dir', 'Movie-Verzeichnis', blurayMoviePath || dvdMoviePath || sourceMap.movie_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPath('log_dir', 'Log-Verzeichnis', sourceMap.log_dir);
|
||||||
|
|
||||||
|
return monitoredPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
pathsSignature(paths = []) {
|
||||||
|
return (Array.isArray(paths) ? paths : [])
|
||||||
|
.map((item) => `${String(item?.key || '')}:${String(item?.path || '')}`)
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling() {
|
||||||
|
if (this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running = true;
|
||||||
|
logger.info('start', {
|
||||||
|
intervalMs: this.intervalMs,
|
||||||
|
pathKeys: this.monitoredPaths.map((item) => item.key)
|
||||||
|
});
|
||||||
|
this.scheduleNext(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
const wasActive = this.running || this.pollInFlight || Boolean(this.timer);
|
||||||
|
this.running = false;
|
||||||
|
this.pollInFlight = false;
|
||||||
|
this.lastCpuTimes = null;
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
if (wasActive) {
|
||||||
|
logger.info('stop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext(delayMs) {
|
||||||
|
if (!this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
}
|
||||||
|
const delay = Math.max(0, Math.trunc(Number(delayMs) || this.intervalMs));
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
void this.pollOnce();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pollOnce() {
|
||||||
|
if (!this.running || !this.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.pollInFlight) {
|
||||||
|
this.scheduleNext(this.intervalMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pollInFlight = true;
|
||||||
|
try {
|
||||||
|
const sample = await this.collectSample();
|
||||||
|
this.lastSnapshot = {
|
||||||
|
enabled: true,
|
||||||
|
intervalMs: this.intervalMs,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
sample,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
this.broadcastUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('poll:failed', { error: errorToMeta(error) });
|
||||||
|
this.lastSnapshot = {
|
||||||
|
...this.lastSnapshot,
|
||||||
|
enabled: true,
|
||||||
|
intervalMs: this.intervalMs,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
error: error?.message || 'Hardware-Monitoring fehlgeschlagen.'
|
||||||
|
};
|
||||||
|
this.broadcastUpdate();
|
||||||
|
} finally {
|
||||||
|
this.pollInFlight = false;
|
||||||
|
if (this.running && this.enabled) {
|
||||||
|
this.scheduleNext(this.intervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastUpdate() {
|
||||||
|
wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectSample() {
|
||||||
|
const memory = this.collectMemoryMetrics();
|
||||||
|
const [cpu, gpu, storage] = await Promise.all([
|
||||||
|
this.collectCpuMetrics(),
|
||||||
|
this.collectGpuMetrics(),
|
||||||
|
this.collectStorageMetrics()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu,
|
||||||
|
memory,
|
||||||
|
gpu,
|
||||||
|
storage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
collectMemoryMetrics() {
|
||||||
|
const totalBytes = Number(os.totalmem() || 0);
|
||||||
|
const freeBytes = Number(os.freemem() || 0);
|
||||||
|
const usedBytes = Math.max(0, totalBytes - freeBytes);
|
||||||
|
const usagePercent = totalBytes > 0
|
||||||
|
? roundNumber((usedBytes / totalBytes) * 100, 1)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
totalBytes,
|
||||||
|
usedBytes,
|
||||||
|
freeBytes,
|
||||||
|
usagePercent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getCpuTimes() {
|
||||||
|
const cpus = os.cpus() || [];
|
||||||
|
return cpus.map((cpu) => {
|
||||||
|
const times = cpu?.times || {};
|
||||||
|
const idle = Number(times.idle || 0);
|
||||||
|
const total = Object.values(times).reduce((sum, value) => sum + Number(value || 0), 0);
|
||||||
|
return { idle, total };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateCpuUsage(currentTimes = [], previousTimes = []) {
|
||||||
|
const perCore = [];
|
||||||
|
const coreCount = Math.min(currentTimes.length, previousTimes.length);
|
||||||
|
if (coreCount <= 0) {
|
||||||
|
return {
|
||||||
|
overallUsagePercent: null,
|
||||||
|
perCore
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDelta = 0;
|
||||||
|
let idleDelta = 0;
|
||||||
|
for (let index = 0; index < coreCount; index += 1) {
|
||||||
|
const prev = previousTimes[index];
|
||||||
|
const cur = currentTimes[index];
|
||||||
|
const deltaTotal = Number(cur?.total || 0) - Number(prev?.total || 0);
|
||||||
|
const deltaIdle = Number(cur?.idle || 0) - Number(prev?.idle || 0);
|
||||||
|
const usage = deltaTotal > 0
|
||||||
|
? roundNumber(((deltaTotal - deltaIdle) / deltaTotal) * 100, 1)
|
||||||
|
: null;
|
||||||
|
perCore.push({
|
||||||
|
index,
|
||||||
|
usagePercent: usage
|
||||||
|
});
|
||||||
|
if (deltaTotal > 0) {
|
||||||
|
totalDelta += deltaTotal;
|
||||||
|
idleDelta += deltaIdle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallUsagePercent = totalDelta > 0
|
||||||
|
? roundNumber(((totalDelta - idleDelta) / totalDelta) * 100, 1)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
overallUsagePercent,
|
||||||
|
perCore
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectCpuMetrics() {
|
||||||
|
const cpus = os.cpus() || [];
|
||||||
|
const currentTimes = this.getCpuTimes();
|
||||||
|
const usage = this.calculateCpuUsage(currentTimes, this.lastCpuTimes || []);
|
||||||
|
this.lastCpuTimes = currentTimes;
|
||||||
|
|
||||||
|
const tempMetrics = await this.collectCpuTemperatures();
|
||||||
|
const tempByCoreIndex = new Map(
|
||||||
|
(tempMetrics.perCore || []).map((item) => [Number(item.index), item.temperatureC])
|
||||||
|
);
|
||||||
|
|
||||||
|
const perCore = usage.perCore.map((entry) => ({
|
||||||
|
index: entry.index,
|
||||||
|
usagePercent: entry.usagePercent,
|
||||||
|
temperatureC: tempByCoreIndex.has(entry.index) ? tempByCoreIndex.get(entry.index) : null
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const tempEntry of tempMetrics.perCore || []) {
|
||||||
|
const index = Number(tempEntry?.index);
|
||||||
|
if (!Number.isFinite(index) || perCore.some((item) => item.index === index)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
perCore.push({
|
||||||
|
index,
|
||||||
|
usagePercent: null,
|
||||||
|
temperatureC: tempEntry.temperatureC
|
||||||
|
});
|
||||||
|
}
|
||||||
|
perCore.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: cpus[0]?.model || null,
|
||||||
|
logicalCoreCount: cpus.length,
|
||||||
|
loadAverage: os.loadavg().map((value) => roundNumber(value, 2)),
|
||||||
|
overallUsagePercent: usage.overallUsagePercent,
|
||||||
|
overallTemperatureC: tempMetrics.overallC,
|
||||||
|
usageAvailable: usage.overallUsagePercent !== null,
|
||||||
|
temperatureAvailable: Boolean(tempMetrics.available),
|
||||||
|
temperatureSource: tempMetrics.source,
|
||||||
|
perCore
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectCpuTemperatures() {
|
||||||
|
const sensors = await this.collectTempsViaSensors();
|
||||||
|
if (sensors.available) {
|
||||||
|
return sensors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hwmon = this.collectTempsViaHwmon();
|
||||||
|
if (hwmon.available) {
|
||||||
|
return hwmon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thermalZones = this.collectTempsViaThermalZones();
|
||||||
|
if (thermalZones.available) {
|
||||||
|
return thermalZones;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'none',
|
||||||
|
overallC: null,
|
||||||
|
perCore: [],
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectTempsViaSensors() {
|
||||||
|
if (this.sensorsCommandAvailable === false) {
|
||||||
|
return {
|
||||||
|
source: 'sensors',
|
||||||
|
overallC: null,
|
||||||
|
perCore: [],
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('sensors', ['-j'], {
|
||||||
|
timeout: SENSORS_TIMEOUT_MS,
|
||||||
|
maxBuffer: 2 * 1024 * 1024
|
||||||
|
});
|
||||||
|
this.sensorsCommandAvailable = true;
|
||||||
|
const parsed = JSON.parse(String(stdout || '{}'));
|
||||||
|
const candidates = collectTemperatureCandidates(parsed);
|
||||||
|
const preferred = preferCpuTemperatureCandidates(candidates);
|
||||||
|
return {
|
||||||
|
source: 'sensors',
|
||||||
|
...mapTemperatureCandidates(preferred)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isCommandMissingError(error)) {
|
||||||
|
this.sensorsCommandAvailable = false;
|
||||||
|
}
|
||||||
|
logger.debug('cpu-temp:sensors:failed', { error: errorToMeta(error) });
|
||||||
|
return {
|
||||||
|
source: 'sensors',
|
||||||
|
overallC: null,
|
||||||
|
perCore: [],
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collectTempsViaHwmon() {
|
||||||
|
const hwmonRoot = '/sys/class/hwmon';
|
||||||
|
if (!fs.existsSync(hwmonRoot)) {
|
||||||
|
return {
|
||||||
|
source: 'hwmon',
|
||||||
|
overallC: null,
|
||||||
|
perCore: [],
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
let dirs = [];
|
||||||
|
try {
|
||||||
|
dirs = fs.readdirSync(hwmonRoot, { withFileTypes: true });
|
||||||
|
} catch (_error) {
|
||||||
|
dirs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!dir.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const basePath = path.join(hwmonRoot, dir.name);
|
||||||
|
const sensorName = readTextFileSafe(path.join(basePath, 'name')) || dir.name;
|
||||||
|
let files = [];
|
||||||
|
try {
|
||||||
|
files = fs.readdirSync(basePath);
|
||||||
|
} catch (_error) {
|
||||||
|
files = [];
|
||||||
|
}
|
||||||
|
const tempInputFiles = files.filter((file) => /^temp\d+_input$/i.test(file));
|
||||||
|
|
||||||
|
for (const fileName of tempInputFiles) {
|
||||||
|
const tempValue = normalizeTempC(readTextFileSafe(path.join(basePath, fileName)));
|
||||||
|
if (tempValue === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const labelFile = fileName.replace('_input', '_label');
|
||||||
|
const label = readTextFileSafe(path.join(basePath, labelFile)) || fileName;
|
||||||
|
candidates.push({
|
||||||
|
label: `${sensorName} / ${label}`,
|
||||||
|
value: tempValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'hwmon',
|
||||||
|
...mapTemperatureCandidates(preferCpuTemperatureCandidates(candidates))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
collectTempsViaThermalZones() {
|
||||||
|
const thermalRoot = '/sys/class/thermal';
|
||||||
|
if (!fs.existsSync(thermalRoot)) {
|
||||||
|
return {
|
||||||
|
source: 'thermal_zone',
|
||||||
|
overallC: null,
|
||||||
|
perCore: [],
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
try {
|
||||||
|
files = fs.readdirSync(thermalRoot, { withFileTypes: true });
|
||||||
|
} catch (_error) {
|
||||||
|
files = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
for (const dir of files) {
|
||||||
|
if (!dir.isDirectory() || !dir.name.startsWith('thermal_zone')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const basePath = path.join(thermalRoot, dir.name);
|
||||||
|
const tempC = normalizeTempC(readTextFileSafe(path.join(basePath, 'temp')));
|
||||||
|
if (tempC === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const zoneType = readTextFileSafe(path.join(basePath, 'type')) || dir.name;
|
||||||
|
candidates.push({
|
||||||
|
label: `${zoneType} / temp`,
|
||||||
|
value: tempC
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'thermal_zone',
|
||||||
|
...mapTemperatureCandidates(preferCpuTemperatureCandidates(candidates))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectGpuMetrics() {
|
||||||
|
if (this.nvidiaSmiAvailable === false) {
|
||||||
|
return {
|
||||||
|
source: 'nvidia-smi',
|
||||||
|
available: false,
|
||||||
|
devices: [],
|
||||||
|
message: 'nvidia-smi ist nicht verfuegbar.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
'nvidia-smi',
|
||||||
|
[
|
||||||
|
'--query-gpu=index,name,utilization.gpu,utilization.memory,temperature.gpu,memory.used,memory.total,power.draw,power.limit,fan.speed',
|
||||||
|
'--format=csv,noheader,nounits'
|
||||||
|
],
|
||||||
|
{
|
||||||
|
timeout: NVIDIA_SMI_TIMEOUT_MS,
|
||||||
|
maxBuffer: 1024 * 1024
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.nvidiaSmiAvailable = true;
|
||||||
|
const devices = String(stdout || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => parseNvidiaCsvLine(line))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return {
|
||||||
|
source: 'nvidia-smi',
|
||||||
|
available: false,
|
||||||
|
devices: [],
|
||||||
|
message: 'Keine GPU-Daten ueber nvidia-smi erkannt.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'nvidia-smi',
|
||||||
|
available: true,
|
||||||
|
devices,
|
||||||
|
message: null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const commandMissing = isCommandMissingError(error);
|
||||||
|
if (commandMissing) {
|
||||||
|
this.nvidiaSmiAvailable = false;
|
||||||
|
}
|
||||||
|
logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) });
|
||||||
|
return {
|
||||||
|
source: 'nvidia-smi',
|
||||||
|
available: false,
|
||||||
|
devices: [],
|
||||||
|
message: commandMissing
|
||||||
|
? 'nvidia-smi ist nicht verfuegbar.'
|
||||||
|
: (String(error?.stderr || error?.message || 'GPU-Abfrage fehlgeschlagen').trim().slice(0, 220))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectStorageMetrics() {
|
||||||
|
const list = [];
|
||||||
|
for (const entry of this.monitoredPaths) {
|
||||||
|
list.push(await this.collectStorageForPath(entry));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
findNearestExistingPath(inputPath) {
|
||||||
|
const normalized = String(inputPath || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let candidate = path.resolve(normalized);
|
||||||
|
for (let depth = 0; depth < 64; depth += 1) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(candidate);
|
||||||
|
if (!parent || parent === candidate) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
candidate = parent;
|
||||||
|
}
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectStorageForPath(entry) {
|
||||||
|
const key = String(entry?.key || '');
|
||||||
|
const label = String(entry?.label || key || 'Pfad');
|
||||||
|
const rawPath = String(entry?.path || '').trim();
|
||||||
|
if (!rawPath) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
path: null,
|
||||||
|
queryPath: null,
|
||||||
|
exists: false,
|
||||||
|
totalBytes: null,
|
||||||
|
usedBytes: null,
|
||||||
|
freeBytes: null,
|
||||||
|
usagePercent: null,
|
||||||
|
mountPoint: null,
|
||||||
|
note: null,
|
||||||
|
error: 'Pfad ist leer.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(rawPath);
|
||||||
|
const exists = fs.existsSync(resolvedPath);
|
||||||
|
const queryPath = exists ? resolvedPath : this.findNearestExistingPath(resolvedPath);
|
||||||
|
|
||||||
|
if (!queryPath) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
path: resolvedPath,
|
||||||
|
queryPath: null,
|
||||||
|
exists: false,
|
||||||
|
totalBytes: null,
|
||||||
|
usedBytes: null,
|
||||||
|
freeBytes: null,
|
||||||
|
usagePercent: null,
|
||||||
|
mountPoint: null,
|
||||||
|
note: null,
|
||||||
|
error: 'Pfad oder Parent existiert nicht.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('df', ['-Pk', queryPath], {
|
||||||
|
timeout: DF_TIMEOUT_MS,
|
||||||
|
maxBuffer: 256 * 1024
|
||||||
|
});
|
||||||
|
const parsed = parseDfStats(stdout);
|
||||||
|
if (!parsed) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
path: resolvedPath,
|
||||||
|
queryPath,
|
||||||
|
exists,
|
||||||
|
totalBytes: null,
|
||||||
|
usedBytes: null,
|
||||||
|
freeBytes: null,
|
||||||
|
usagePercent: null,
|
||||||
|
mountPoint: null,
|
||||||
|
note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
|
||||||
|
error: 'Dateisystemdaten konnten nicht geparst werden.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
path: resolvedPath,
|
||||||
|
queryPath,
|
||||||
|
exists,
|
||||||
|
...parsed,
|
||||||
|
note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
path: resolvedPath,
|
||||||
|
queryPath,
|
||||||
|
exists,
|
||||||
|
totalBytes: null,
|
||||||
|
usedBytes: null,
|
||||||
|
freeBytes: null,
|
||||||
|
usagePercent: null,
|
||||||
|
mountPoint: null,
|
||||||
|
note: null,
|
||||||
|
error: String(error?.message || 'df Abfrage fehlgeschlagen')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new HardwareMonitorService();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const { logDir: fallbackLogDir } = require('../config');
|
||||||
|
|
||||||
|
function normalizeDir(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackLogRootDir() {
|
||||||
|
return path.resolve(fallbackLogDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLogRootDir(value) {
|
||||||
|
return normalizeDir(value) || getFallbackLogRootDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeLogRootDir = getFallbackLogRootDir();
|
||||||
|
|
||||||
|
function setLogRootDir(value) {
|
||||||
|
runtimeLogRootDir = resolveLogRootDir(value);
|
||||||
|
return runtimeLogRootDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogRootDir() {
|
||||||
|
return runtimeLogRootDir || getFallbackLogRootDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackendLogDir() {
|
||||||
|
return path.join(getLogRootDir(), 'backend');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJobLogDir() {
|
||||||
|
return getLogRootDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getFallbackLogRootDir,
|
||||||
|
resolveLogRootDir,
|
||||||
|
setLogRootDir,
|
||||||
|
getLogRootDir,
|
||||||
|
getBackendLogDir,
|
||||||
|
getJobLogDir
|
||||||
|
};
|
||||||
@@ -0,0 +1,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
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const settingsService = require('./settingsService');
|
||||||
|
const logger = require('./logger').child('OMDB');
|
||||||
|
|
||||||
|
class OmdbService {
|
||||||
|
async search(query) {
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
logger.info('search:start', { query });
|
||||||
|
|
||||||
|
const settings = await settingsService.getSettingsMap();
|
||||||
|
const apiKey = settings.omdb_api_key;
|
||||||
|
if (!apiKey) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = settings.omdb_default_type || 'movie';
|
||||||
|
const url = new URL('https://www.omdbapi.com/');
|
||||||
|
url.searchParams.set('apikey', apiKey);
|
||||||
|
url.searchParams.set('s', query.trim());
|
||||||
|
url.searchParams.set('type', type);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('search:http-failed', { query, status: response.status });
|
||||||
|
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.Response === 'False' || !Array.isArray(data.Search)) {
|
||||||
|
logger.warn('search:no-results', { query, response: data.Response, error: data.Error });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const results = data.Search.map((item) => ({
|
||||||
|
title: item.Title,
|
||||||
|
year: item.Year,
|
||||||
|
imdbId: item.imdbID,
|
||||||
|
type: item.Type,
|
||||||
|
poster: item.Poster
|
||||||
|
}));
|
||||||
|
logger.info('search:done', { query, count: results.length });
|
||||||
|
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 url = new URL('https://www.omdbapi.com/');
|
||||||
|
url.searchParams.set('apikey', apiKey);
|
||||||
|
url.searchParams.set('i', normalizedId);
|
||||||
|
url.searchParams.set('plot', 'full');
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status });
|
||||||
|
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
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
@@ -0,0 +1,113 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
const wsService = require('./websocketService');
|
||||||
|
|
||||||
|
const MAX_RECENT_ACTIVITIES = 120;
|
||||||
|
const MAX_ACTIVITY_OUTPUT_CHARS = 12000;
|
||||||
|
const MAX_ACTIVITY_TEXT_CHARS = 2000;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const suffix = trim ? ' ...[gekürzt]' : '\n...[gekürzt]';
|
||||||
|
text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -0,0 +1,866 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const logger = require('./logger').child('SCRIPT_CHAINS');
|
||||||
|
const runtimeActivityService = require('./runtimeActivityService');
|
||||||
|
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 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'
|
||||||
|
});
|
||||||
|
const run = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(prepared.cmd, prepared.args, {
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: true
|
||||||
|
});
|
||||||
|
controlState.activeChild = child;
|
||||||
|
controlState.activeChildTermination = null;
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||||
|
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||||
|
child.on('error', (error) => {
|
||||||
|
controlState.activeChild = null;
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
const termination = controlState.activeChildTermination;
|
||||||
|
controlState.activeChild = null;
|
||||||
|
controlState.activeChildTermination = null;
|
||||||
|
resolve({ code, signal, stdout, stderr, 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
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
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),
|
||||||
|
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();
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
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 { 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 }) {
|
||||||
|
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));
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const exitCode = Number.isFinite(Number(run.code)) ? Number(run.code) : null;
|
||||||
|
const finishedSuccessfully = exitCode === 0 && !run.timedOut;
|
||||||
|
const cancelledByUser = Boolean(controlState.cancelRequested)
|
||||||
|
&& (Boolean(controlState.cancelSignalSent) || !finishedSuccessfully);
|
||||||
|
const success = finishedSuccessfully;
|
||||||
|
const message = cancelledByUser
|
||||||
|
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||||
|
: (run.timedOut
|
||||||
|
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
|
||||||
|
: (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
|
||||||
|
const errorMessage = success
|
||||||
|
? null
|
||||||
|
: (cancelledByUser
|
||||||
|
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||||
|
: (run.timedOut
|
||||||
|
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
|
||||||
|
: `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
|
||||||
|
runtimeActivityService.completeActivity(activityId, {
|
||||||
|
status: success ? 'success' : 'error',
|
||||||
|
success,
|
||||||
|
outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'),
|
||||||
|
cancelled: cancelledByUser,
|
||||||
|
exitCode,
|
||||||
|
stdout: run.stdout || null,
|
||||||
|
stderr: run.stderr || null,
|
||||||
|
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||||
|
stderrTruncated: Boolean(run.stderrTruncated),
|
||||||
|
errorMessage,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
scriptId: script.id,
|
||||||
|
scriptName: script.name,
|
||||||
|
success,
|
||||||
|
exitCode: run.code,
|
||||||
|
signal: run.signal,
|
||||||
|
timedOut: run.timedOut,
|
||||||
|
durationMs: run.durationMs,
|
||||||
|
stdout: run.stdout,
|
||||||
|
stderr: run.stderr,
|
||||||
|
stdoutTruncated: run.stdoutTruncated,
|
||||||
|
stderrTruncated: run.stderrTruncated
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
runtimeActivityService.completeActivity(activityId, {
|
||||||
|
status: 'error',
|
||||||
|
success: false,
|
||||||
|
outcome: controlState.cancelRequested ? 'cancelled' : 'error',
|
||||||
|
cancelled: Boolean(controlState.cancelRequested),
|
||||||
|
errorMessage: controlState.cancelRequested
|
||||||
|
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||||
|
: (error?.message || 'Skript-Test Fehler'),
|
||||||
|
message: controlState.cancelRequested
|
||||||
|
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||||
|
: (error?.message || 'Skript-Test Fehler')
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
controlState.child = null;
|
||||||
|
await prepared.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ScriptService();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { dataDir } = require('../config');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const logger = require('./logger').child('THUMBNAIL');
|
||||||
|
|
||||||
|
const THUMBNAILS_DIR = path.join(dataDir, 'thumbnails');
|
||||||
|
const CACHE_DIR = path.join(THUMBNAILS_DIR, 'cache');
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
|
||||||
|
function ensureDirs() {
|
||||||
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheFilePath(jobId) {
|
||||||
|
return path.join(CACHE_DIR, `job-${jobId}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistentFilePath(jobId) {
|
||||||
|
return path.join(THUMBNAILS_DIR, `job-${jobId}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localUrl(jobId) {
|
||||||
|
return `/api/thumbnails/job-${jobId}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalUrl(url) {
|
||||||
|
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (redirectsLeft <= 0) {
|
||||||
|
return reject(new Error('Zu viele Weiterleitungen beim Bild-Download'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = url.startsWith('https') ? https : http;
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
try { file.destroy(); } catch (_) {}
|
||||||
|
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
proto.get(url, { timeout: 15000 }, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
res.resume();
|
||||||
|
file.close(() => {
|
||||||
|
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
|
||||||
|
downloadImage(res.headers.location, destPath, redirectsLeft - 1).then(resolve).catch(reject);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
cleanup();
|
||||||
|
return reject(new Error(`HTTP ${res.statusCode} beim Bild-Download`));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => file.close(() => resolve()));
|
||||||
|
file.on('error', (err) => { cleanup(); reject(err); });
|
||||||
|
}).on('error', (err) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
}).on('timeout', function () {
|
||||||
|
this.destroy();
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Timeout beim Bild-Download'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt das Bild einer extern-URL in den Cache herunter.
|
||||||
|
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
|
||||||
|
* @returns {Promise<string|null>} lokaler Pfad oder null
|
||||||
|
*/
|
||||||
|
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||||
|
if (!posterUrl || isLocalUrl(posterUrl)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureDirs();
|
||||||
|
const dest = cacheFilePath(jobId);
|
||||||
|
await downloadImage(posterUrl, dest);
|
||||||
|
logger.info('thumbnail:cached', { jobId, posterUrl, dest });
|
||||||
|
return dest;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:cache:failed', { jobId, posterUrl, error: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt das gecachte Bild in den persistenten Ordner.
|
||||||
|
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
|
||||||
|
* Wird nach erfolgreichem Rip aufgerufen.
|
||||||
|
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
|
||||||
|
*/
|
||||||
|
function promoteJobThumbnail(jobId) {
|
||||||
|
try {
|
||||||
|
ensureDirs();
|
||||||
|
const src = cacheFilePath(jobId);
|
||||||
|
const dest = persistentFilePath(jobId);
|
||||||
|
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.renameSync(src, dest);
|
||||||
|
logger.info('thumbnail:promoted', { jobId, dest });
|
||||||
|
return localUrl(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls kein Cache vorhanden, aber persistente Datei schon existiert
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
return localUrl(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('thumbnail:promote:no-source', { jobId });
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:promote:failed', { jobId, error: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Pfad zum persistenten Thumbnail-Ordner zurück (für Static-Serving).
|
||||||
|
*/
|
||||||
|
function getThumbnailsDir() {
|
||||||
|
return THUMBNAILS_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopiert das persistente Thumbnail von sourceJobId zu targetJobId.
|
||||||
|
* Wird bei Rip-Neustart genutzt, damit der neue Job ein eigenes Bild hat
|
||||||
|
* und nicht auf die Datei des alten Jobs angewiesen ist.
|
||||||
|
* @returns {string|null} neue lokale URL oder null
|
||||||
|
*/
|
||||||
|
function copyThumbnail(sourceJobId, targetJobId) {
|
||||||
|
try {
|
||||||
|
const src = persistentFilePath(sourceJobId);
|
||||||
|
if (!fs.existsSync(src)) return null;
|
||||||
|
ensureDirs();
|
||||||
|
const dest = persistentFilePath(targetJobId);
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
logger.info('thumbnail:copied', { sourceJobId, targetJobId });
|
||||||
|
return localUrl(targetJobId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:copy:failed', { sourceJobId, targetJobId, error: err.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht Cache- und persistente Thumbnail-Datei eines Jobs.
|
||||||
|
* Wird beim Löschen eines Jobs aufgerufen.
|
||||||
|
*/
|
||||||
|
function deleteThumbnail(jobId) {
|
||||||
|
for (const filePath of [persistentFilePath(jobId), cacheFilePath(jobId)]) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:delete:failed', { jobId, filePath, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migriert bestehende Jobs: lädt alle externen poster_url-Bilder herunter
|
||||||
|
* und speichert sie lokal. Läuft beim Start im Hintergrund, sequenziell
|
||||||
|
* mit kurzem Delay um externe Server nicht zu überlasten.
|
||||||
|
*/
|
||||||
|
async function migrateExistingThumbnails() {
|
||||||
|
try {
|
||||||
|
ensureDirs();
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// Alle abgeschlossenen Jobs mit externer poster_url, die noch kein lokales Bild haben
|
||||||
|
const jobs = await db.all(
|
||||||
|
`SELECT id, poster_url FROM jobs
|
||||||
|
WHERE rip_successful = 1
|
||||||
|
AND poster_url IS NOT NULL
|
||||||
|
AND poster_url != ''
|
||||||
|
AND poster_url NOT LIKE '/api/thumbnails/%'
|
||||||
|
ORDER BY id ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!jobs.length) {
|
||||||
|
logger.info('thumbnail:migrate:nothing-to-do');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('thumbnail:migrate:start', { count: jobs.length });
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
// Persistente Datei bereits vorhanden? Dann nur DB aktualisieren.
|
||||||
|
const dest = persistentFilePath(job.id);
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
|
||||||
|
succeeded++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadImage(job.poster_url, dest);
|
||||||
|
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
|
||||||
|
logger.info('thumbnail:migrate:ok', { jobId: job.id });
|
||||||
|
succeeded++;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('thumbnail:migrate:failed', { jobId: job.id, url: job.poster_url, error: err.message });
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurze Pause zwischen Downloads (externe Server schonen)
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('thumbnail:migrate:done', { succeeded, failed, total: jobs.length });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('thumbnail:migrate:error', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cacheJobThumbnail,
|
||||||
|
promoteJobThumbnail,
|
||||||
|
copyThumbnail,
|
||||||
|
deleteThumbnail,
|
||||||
|
getThumbnailsDir,
|
||||||
|
migrateExistingThumbnails,
|
||||||
|
isLocalUrl
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const logger = require('./logger').child('USER_PRESET');
|
||||||
|
|
||||||
|
const VALID_MEDIA_TYPES = new Set(['bluray', 'dvd', 'other', 'all']);
|
||||||
|
|
||||||
|
function normalizeMediaType(value) {
|
||||||
|
const v = String(value || '').trim().toLowerCase();
|
||||||
|
return VALID_MEDIA_TYPES.has(v) ? v : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToPreset(row) {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
mediaType: row.media_type,
|
||||||
|
handbrakePreset: row.handbrake_preset || null,
|
||||||
|
extraArgs: row.extra_args || null,
|
||||||
|
description: row.description || null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPresets(mediaType = null) {
|
||||||
|
const db = await getDb();
|
||||||
|
let rows;
|
||||||
|
if (mediaType && VALID_MEDIA_TYPES.has(mediaType)) {
|
||||||
|
rows = await db.all(
|
||||||
|
`SELECT * FROM user_presets WHERE media_type = ? OR media_type = 'all' ORDER BY name ASC`,
|
||||||
|
[mediaType]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rows = await db.all(`SELECT * FROM user_presets ORDER BY media_type ASC, name ASC`);
|
||||||
|
}
|
||||||
|
return rows.map(rowToPreset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPresetById(id) {
|
||||||
|
const db = await getDb();
|
||||||
|
const row = await db.get(`SELECT * FROM user_presets WHERE id = ? LIMIT 1`, [id]);
|
||||||
|
return rowToPreset(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPreset(payload) {
|
||||||
|
const name = String(payload?.name || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
const error = new Error('Preset-Name darf nicht leer sein.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = normalizeMediaType(payload?.mediaType);
|
||||||
|
const handbrakePreset = String(payload?.handbrakePreset || '').trim() || null;
|
||||||
|
const extraArgs = String(payload?.extraArgs || '').trim() || null;
|
||||||
|
const description = String(payload?.description || '').trim() || null;
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const result = await db.run(
|
||||||
|
`INSERT INTO user_presets (name, media_type, handbrake_preset, extra_args, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[name, mediaType, handbrakePreset, extraArgs, description]
|
||||||
|
);
|
||||||
|
|
||||||
|
const preset = await getPresetById(result.lastID);
|
||||||
|
logger.info('create', { id: preset.id, name: preset.name, mediaType: preset.mediaType });
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePreset(id, payload) {
|
||||||
|
const db = await getDb();
|
||||||
|
const existing = await getPresetById(id);
|
||||||
|
if (!existing) {
|
||||||
|
const error = new Error(`Preset ${id} nicht gefunden.`);
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = payload?.name !== undefined ? String(payload.name || '').trim() : existing.name;
|
||||||
|
if (!name) {
|
||||||
|
const error = new Error('Preset-Name darf nicht leer sein.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = payload?.mediaType !== undefined
|
||||||
|
? normalizeMediaType(payload.mediaType)
|
||||||
|
: existing.mediaType;
|
||||||
|
const handbrakePreset = payload?.handbrakePreset !== undefined
|
||||||
|
? (String(payload.handbrakePreset || '').trim() || null)
|
||||||
|
: existing.handbrakePreset;
|
||||||
|
const extraArgs = payload?.extraArgs !== undefined
|
||||||
|
? (String(payload.extraArgs || '').trim() || null)
|
||||||
|
: existing.extraArgs;
|
||||||
|
const description = payload?.description !== undefined
|
||||||
|
? (String(payload.description || '').trim() || null)
|
||||||
|
: existing.description;
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`UPDATE user_presets
|
||||||
|
SET name = ?, media_type = ?, handbrake_preset = ?, extra_args = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
[name, mediaType, handbrakePreset, extraArgs, description, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await getPresetById(id);
|
||||||
|
logger.info('update', { id: updated.id, name: updated.name });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePreset(id) {
|
||||||
|
const db = await getDb();
|
||||||
|
const existing = await getPresetById(id);
|
||||||
|
if (!existing) {
|
||||||
|
const error = new Error(`Preset ${id} nicht gefunden.`);
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(`DELETE FROM user_presets WHERE id = ?`, [id]);
|
||||||
|
logger.info('delete', { id: existing.id, name: existing.name });
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listPresets,
|
||||||
|
getPresetById,
|
||||||
|
createPreset,
|
||||||
|
updatePreset,
|
||||||
|
deletePreset
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const logger = require('./logger').child('WS');
|
||||||
|
|
||||||
|
class WebSocketService {
|
||||||
|
constructor() {
|
||||||
|
this.wss = null;
|
||||||
|
this.clients = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'WS_CONNECTED',
|
||||||
|
payload: { connectedAt: new Date().toISOString() }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
this.clients.delete(socket);
|
||||||
|
logger.info('client:closed', { clients: this.clients.size });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
this.clients.delete(socket);
|
||||||
|
logger.warn('client:error', { clients: this.clients.size });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.readyState === client.OPEN) {
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new WebSocketService();
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
function splitArgs(input) {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [];
|
||||||
|
let current = '';
|
||||||
|
let quote = null;
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (const ch of input) {
|
||||||
|
if (escaping) {
|
||||||
|
current += ch;
|
||||||
|
escaping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '\\') {
|
||||||
|
escaping = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) {
|
||||||
|
quote = null;
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
if (current.length > 0) {
|
||||||
|
args.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
args.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
splitArgs
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
function errorToMeta(error) {
|
||||||
|
if (!error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
code: error.code,
|
||||||
|
signal: error.signal,
|
||||||
|
statusCode: error.statusCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
errorToMeta
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function ensureDir(dirPath) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(input) {
|
||||||
|
return String(input || 'untitled')
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(template, values) {
|
||||||
|
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
||||||
|
const val = values[key.trim()];
|
||||||
|
if (val === undefined || val === null || val === '') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
sanitizeFileName,
|
||||||
|
renderTemplate,
|
||||||
|
findLargestMediaFile,
|
||||||
|
findMediaFiles
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
Executable
BIN
Binary file not shown.
+467
@@ -0,0 +1,467 @@
|
|||||||
|
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,
|
||||||
|
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,
|
||||||
|
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 TABLE job_lineage_artifacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
source_job_id INTEGER,
|
||||||
|
media_type TEXT,
|
||||||
|
raw_path TEXT,
|
||||||
|
output_path TEXT,
|
||||||
|
reason TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_job_lineage_artifacts_job_id ON job_lineage_artifacts(job_id);
|
||||||
|
CREATE INDEX idx_job_lineage_artifacts_source_job_id ON job_lineage_artifacts(source_job_id);
|
||||||
|
|
||||||
|
CREATE TABLE scripts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 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 ('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);
|
||||||
|
|
||||||
|
-- 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 ('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 ('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');
|
||||||
|
|
||||||
|
-- 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, 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.', 'backup', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 305);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_bluray', 'backup');
|
||||||
|
|
||||||
|
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, 'mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner.', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 505);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'backup');
|
||||||
|
|
||||||
|
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})');
|
||||||
|
|
||||||
|
-- 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 (
|
||||||
|
'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}');
|
||||||
|
|
||||||
|
-- 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);
|
||||||
|
|
||||||
|
-- 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 ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
|
||||||
|
|
||||||
|
-- Benachrichtigungen
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false');
|
||||||
|
|
||||||
|
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');
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Cron API
|
||||||
|
|
||||||
|
Ripster enthält ein eingebautes Cron-System für Skripte und Skript-Ketten (`sourceType: script|chain`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/crons
|
||||||
|
|
||||||
|
Listet alle Cron-Jobs.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Nachtlauf Backup",
|
||||||
|
"cronExpression": "0 2 * * *",
|
||||||
|
"sourceType": "script",
|
||||||
|
"sourceId": 3,
|
||||||
|
"sourceName": "Backup-Skript",
|
||||||
|
"enabled": true,
|
||||||
|
"pushoverEnabled": true,
|
||||||
|
"lastRunAt": "2026-03-10T02:00:00.000Z",
|
||||||
|
"lastRunStatus": "success",
|
||||||
|
"nextRunAt": "2026-03-11T02:00:00.000Z",
|
||||||
|
"createdAt": "2026-03-01T10:00:00.000Z",
|
||||||
|
"updatedAt": "2026-03-10T02:00:05.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/crons
|
||||||
|
|
||||||
|
Erstellt Cron-Job.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Nachtlauf Backup",
|
||||||
|
"cronExpression": "0 2 * * *",
|
||||||
|
"sourceType": "script",
|
||||||
|
"sourceId": 3,
|
||||||
|
"enabled": true,
|
||||||
|
"pushoverEnabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: `201` mit `{ "job": { ... } }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/crons/:id
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "job": { "id": 1, "name": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/crons/:id
|
||||||
|
|
||||||
|
Aktualisiert Cron-Job. Felder wie bei `POST`.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "job": { ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /api/crons/:id
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "removed": { "id": 1, "name": "Nachtlauf Backup" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/crons/:id/logs
|
||||||
|
|
||||||
|
Liefert Ausführungs-Logs.
|
||||||
|
|
||||||
|
**Query-Parameter:**
|
||||||
|
|
||||||
|
| Parameter | Typ | Default | Beschreibung |
|
||||||
|
|-----------|-----|---------|-------------|
|
||||||
|
| `limit` | number | `20` | Anzahl Einträge, max. `100` |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"cronJobId": 1,
|
||||||
|
"startedAt": "2026-03-10T02:00:01.000Z",
|
||||||
|
"finishedAt": "2026-03-10T02:00:05.000Z",
|
||||||
|
"status": "success",
|
||||||
|
"output": "Backup abgeschlossen.",
|
||||||
|
"errorMessage": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status`: `running` | `success` | `error`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/crons/:id/run
|
||||||
|
|
||||||
|
Triggert Job manuell (asynchron).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "triggered": true, "cronJobId": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn Job bereits läuft: `409`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/crons/validate-expression
|
||||||
|
|
||||||
|
Validiert 5-Felder-Cron-Ausdruck und berechnet nächsten Lauf.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "cronExpression": "*/15 * * * *" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gültige Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"nextRunAt": "2026-03-10T14:15:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ungültige Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": false,
|
||||||
|
"error": "Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag).",
|
||||||
|
"nextRunAt": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cron-Format
|
||||||
|
|
||||||
|
Ripster unterstützt 5 Felder:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Minute Stunde Tag Monat Wochentag
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
- `0 2 * * *` täglich 02:00
|
||||||
|
- `*/15 * * * *` alle 15 Minuten
|
||||||
|
- `0 6 * * 1-5` Mo-Fr 06:00
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket-Events zu Cron
|
||||||
|
|
||||||
|
- `CRON_JOBS_UPDATED` bei Create/Update/Delete
|
||||||
|
- `CRON_JOB_UPDATED` bei Laufzeitstatus (`running` -> `success|error`)
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# 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/database
|
||||||
|
|
||||||
|
Debug-Ansicht der DB-Zeilen (angereichert).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"status": "FINISHED",
|
||||||
|
"rawFolderName": "Inception - RAW - job-42"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 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-lightning-bolt: **WebSocket Events**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Pipeline-, Queue-, Disk-, Settings-, Cron- und Monitoring-Events.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: WebSocket](websocket.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
|
||||||
|
Ripster hat keine eingebaute Authentifizierung und ist für lokalen, geschützten Betrieb gedacht.
|
||||||
@@ -0,0 +1,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 |
|
||||||
@@ -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.
|
||||||
@@ -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": { ... } }`.
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reconnect-Verhalten
|
||||||
|
|
||||||
|
`useWebSocket` verbindet bei Abbruch automatisch neu:
|
||||||
|
|
||||||
|
- Retry-Intervall: `1500ms`
|
||||||
|
- Wiederverbindung bis Komponente unmounted wird
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Technischer Anhang
|
||||||
|
|
||||||
|
Dieser Bereich enthält die technische Referenz hinter dem Benutzerhandbuch.
|
||||||
|
|
||||||
|
## Inhalt
|
||||||
|
|
||||||
|
- **Konfiguration**
|
||||||
|
- komplette Feldreferenz
|
||||||
|
- Umgebungsvariablen
|
||||||
|
- **Pipeline intern**
|
||||||
|
- Zustandsmodell
|
||||||
|
- Encode-Planung
|
||||||
|
- Playlist-Analyse
|
||||||
|
- Pre-/Post-Encode-Ausführungen
|
||||||
|
- **API-Referenz**
|
||||||
|
- REST-Endpunkte
|
||||||
|
- WebSocket-Events
|
||||||
|
- **Architektur**
|
||||||
|
- Backend-/Frontend-Aufbau
|
||||||
|
- Datenbank
|
||||||
|
- **Deployment**
|
||||||
|
- Betrieb in Entwicklung und Produktion
|
||||||
|
- **Externe Tools**
|
||||||
|
- MakeMKV, HandBrake, MediaInfo
|
||||||
|
|
||||||
|
## Wann du in den Anhang wechselst
|
||||||
|
|
||||||
|
- du integrierst Ripster mit anderen Systemen
|
||||||
|
- du betreibst mehrere Instanzen oder willst tiefer debuggen
|
||||||
|
- du brauchst Feld-/API-/Schema-Details für Automatisierung
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Backend-Services
|
||||||
|
|
||||||
|
Das Backend ist in Services aufgeteilt, die von Express-Routen orchestriert werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `pipelineService.js`
|
||||||
|
|
||||||
|
Zentrale Workflow-Orchestrierung.
|
||||||
|
|
||||||
|
Aufgaben:
|
||||||
|
|
||||||
|
- Pipeline-State-Machine + Persistenz (`pipeline_state`)
|
||||||
|
- Disc-Analyse/Rip/Review/Encode
|
||||||
|
- Queue-Management (Jobs + `script|chain|wait` Einträge)
|
||||||
|
- Retry/Re-Encode/Restart-Flows
|
||||||
|
- WebSocket-Broadcasts für State/Progress/Queue
|
||||||
|
|
||||||
|
Wichtige Methoden:
|
||||||
|
|
||||||
|
- `analyzeDisc()`
|
||||||
|
- `selectMetadata()`
|
||||||
|
- `startPreparedJob()`
|
||||||
|
- `confirmEncodeReview()`
|
||||||
|
- `cancel()`
|
||||||
|
- `retry()`
|
||||||
|
- `reencodeFromRaw()`
|
||||||
|
- `restartReviewFromRaw()`
|
||||||
|
- `restartEncodeWithLastSettings()`
|
||||||
|
- `resumeReadyToEncodeJob()`
|
||||||
|
- `enqueueNonJobEntry()`, `reorderQueue()`, `removeQueueEntry()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `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
|
||||||
|
- HandBrake-Preset-Liste via `HandBrakeCLI -z`
|
||||||
|
- MakeMKV-Registration-Command aus `makemkv_registration_key`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `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`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `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)
|
||||||
|
- `logger.js` (rotierende Datei-Logs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bootstrapping (`src/index.js`)
|
||||||
|
|
||||||
|
Beim Start:
|
||||||
|
|
||||||
|
1. DB init/migrate
|
||||||
|
2. Pipeline-Init
|
||||||
|
3. Cron-Init
|
||||||
|
4. Express-Routes + Error-Handler
|
||||||
|
5. WebSocket-Server auf `/ws`
|
||||||
|
6. Hardware-Monitoring-Init
|
||||||
|
7. Disk-Detection-Start
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Datenbank
|
||||||
|
|
||||||
|
Ripster verwendet SQLite (`backend/data/ripster.db`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabellen
|
||||||
|
|
||||||
|
```text
|
||||||
|
settings_schema
|
||||||
|
settings_values
|
||||||
|
jobs
|
||||||
|
pipeline_state
|
||||||
|
scripts
|
||||||
|
script_chains
|
||||||
|
script_chain_steps
|
||||||
|
user_presets
|
||||||
|
cron_jobs
|
||||||
|
cron_run_logs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `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`
|
||||||
|
- Audit: `created_at`, `updated_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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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, created_at FROM jobs ORDER BY created_at DESC;
|
||||||
|
SELECT key, value FROM settings_values ORDER BY key;
|
||||||
|
```
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Frontend-Komponenten
|
||||||
|
|
||||||
|
Frontend: React + PrimeReact + Vite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hauptseiten
|
||||||
|
|
||||||
|
### `DashboardPage.jsx`
|
||||||
|
|
||||||
|
Pipeline-Steuerung:
|
||||||
|
|
||||||
|
- Status/Progress/ETA
|
||||||
|
- Metadaten-Dialog
|
||||||
|
- Playlist-Entscheidung
|
||||||
|
- Review-Panel
|
||||||
|
- Queue-Interaktion (reorder/add/remove)
|
||||||
|
- Job-Aktionen (Start/Cancel/Retry/Re-Encode)
|
||||||
|
- Hardware-Monitoring-Anzeige
|
||||||
|
|
||||||
|
### `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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Komponenten
|
||||||
|
|
||||||
|
- `PipelineStatusCard.jsx`
|
||||||
|
- `MetadataSelectionDialog.jsx`
|
||||||
|
- `MediaInfoReviewPanel.jsx`
|
||||||
|
- `JobDetailDialog.jsx`
|
||||||
|
- `CronJobsTab.jsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build/Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# dev
|
||||||
|
npm run dev --prefix frontend
|
||||||
|
|
||||||
|
# prod build
|
||||||
|
npm run build --prefix frontend
|
||||||
|
```
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Anhang: Architektur
|
||||||
|
|
||||||
|
Ripster ist eine Client-Server-Anwendung mit REST + WebSocket und externen CLI-Tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Systemüberblick
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph Browser["Browser (React)"]
|
||||||
|
Dashboard[Dashboard]
|
||||||
|
Settings[Einstellungen]
|
||||||
|
History[Historie]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend["Node.js Backend"]
|
||||||
|
API[REST API\nExpress]
|
||||||
|
WS[WebSocket\n/ws]
|
||||||
|
Pipeline[pipelineService]
|
||||||
|
Cron[cronService]
|
||||||
|
DB[(SQLite)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Tools["Externe Tools"]
|
||||||
|
MakeMKV[makemkvcon]
|
||||||
|
HandBrake[HandBrakeCLI]
|
||||||
|
MediaInfo[mediainfo]
|
||||||
|
end
|
||||||
|
|
||||||
|
Browser <-->|HTTP| API
|
||||||
|
Browser <-->|WebSocket| WS
|
||||||
|
Pipeline --> MakeMKV
|
||||||
|
Pipeline --> HandBrake
|
||||||
|
Pipeline --> MediaInfo
|
||||||
|
API --> DB
|
||||||
|
Pipeline --> DB
|
||||||
|
Cron --> DB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- [:octicons-arrow-right-24: Übersicht](overview.md)
|
||||||
|
- [:octicons-arrow-right-24: Backend-Services](backend.md)
|
||||||
|
- [:octicons-arrow-right-24: Frontend-Komponenten](frontend.md)
|
||||||
|
- [:octicons-arrow-right-24: Datenbank](database.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Architektur-Übersicht
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kernprinzipien
|
||||||
|
|
||||||
|
### Event-getriebene Pipeline
|
||||||
|
|
||||||
|
`pipelineService` hält einen Snapshot der State-Machine und broadcastet Änderungen sofort via WebSocket.
|
||||||
|
|
||||||
|
```text
|
||||||
|
State-Änderung -> PIPELINE_STATE_CHANGED/PIPELINE_PROGRESS -> Frontend-Update
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service-Layer
|
||||||
|
|
||||||
|
```text
|
||||||
|
Route -> Service -> DB/Tool-Execution
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes enthalten kaum Business-Logik.
|
||||||
|
|
||||||
|
### Schema-getriebene Settings
|
||||||
|
|
||||||
|
Settings sind DB-schema-getrieben (`settings_schema` + `settings_values`), UI rendert dynamisch aus diesen Daten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Echtzeit-Kommunikation
|
||||||
|
|
||||||
|
WebSocket läuft auf `/ws`.
|
||||||
|
|
||||||
|
Wichtige Events:
|
||||||
|
|
||||||
|
- `PIPELINE_STATE_CHANGED`, `PIPELINE_PROGRESS`, `PIPELINE_QUEUE_CHANGED`
|
||||||
|
- Disk erkannt, Disk entfernt
|
||||||
|
- `HARDWARE_MONITOR_UPDATE`
|
||||||
|
- `SETTINGS_UPDATED`, `SETTINGS_BULK_UPDATED`
|
||||||
|
- `SETTINGS_SCRIPTS_UPDATED`, `SETTINGS_SCRIPT_CHAINS_UPDATED`, `USER_PRESETS_UPDATED`
|
||||||
|
- `CRON_JOBS_UPDATED`, `CRON_JOB_UPDATED`
|
||||||
|
- `PIPELINE_ERROR`, `DISK_DETECTION_ERROR`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prozessausführung
|
||||||
|
|
||||||
|
Externe Tools werden als Child-Processes gestartet (`processRunner`):
|
||||||
|
|
||||||
|
- Streaming von stdout/stderr
|
||||||
|
- Progress-Parsing (`progressParsers.js`)
|
||||||
|
- kontrollierter Abbruch (SIGINT/SIGKILL-Fallback)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistenz
|
||||||
|
|
||||||
|
SQLite-Datei: `backend/data/ripster.db`
|
||||||
|
|
||||||
|
Kern-Tabellen:
|
||||||
|
|
||||||
|
- `jobs`, `pipeline_state`
|
||||||
|
- `settings_schema`, `settings_values`
|
||||||
|
- `scripts`, `script_chains`, `script_chain_steps`
|
||||||
|
- `user_presets`
|
||||||
|
- `cron_jobs`, `cron_run_logs`
|
||||||
|
|
||||||
|
Beim Start werden Schema und Settings-Migrationen automatisch ausgeführt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
Zentrales Error-Handling liefert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "...",
|
||||||
|
"statusCode": 400,
|
||||||
|
"reqId": "...",
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fehlgeschlagene Jobs bleiben in der Historie (`Fehler` oder `Abgebrochen`) und können erneut gestartet werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CORS & Runtime-Konfig
|
||||||
|
|
||||||
|
- `CORS_ORIGIN` default: `*`
|
||||||
|
- `LOG_LEVEL` default: `info`
|
||||||
|
- DB-/Log-Pfade über `DB_PATH`/`LOG_DIR` konfigurierbar
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Umgebungsvariablen
|
||||||
|
|
||||||
|
Umgebungsvariablen steuern Backend/Vite außerhalb der DB-basierten UI-Settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend (`backend/.env`)
|
||||||
|
|
||||||
|
| Variable | Default (Code) | Beschreibung |
|
||||||
|
|---------|------------------|-------------|
|
||||||
|
| `PORT` | `3001` | Express-Port |
|
||||||
|
| `DB_PATH` | `backend/data/ripster.db` | SQLite-Datei (relativ zu `backend/`) |
|
||||||
|
| `LOG_DIR` | `backend/logs` | Fallback-Logverzeichnis (wenn `log_dir`-Setting nicht gesetzt/lesbar) |
|
||||||
|
| `CORS_ORIGIN` | `*` | CORS-Origin für API |
|
||||||
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
DB_PATH=/var/lib/ripster/ripster.db
|
||||||
|
LOG_DIR=/var/log/ripster
|
||||||
|
CORS_ORIGIN=http://192.168.1.50:5173
|
||||||
|
LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis: `backend/.env.example` enthält bewusst dev-freundliche Werte (z. B. lokaler `CORS_ORIGIN`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend (`frontend/.env`)
|
||||||
|
|
||||||
|
| Variable | Default | Beschreibung |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `VITE_API_BASE` | `/api` | API-Basis für Fetch-Client |
|
||||||
|
| `VITE_WS_URL` | automatisch aus `window.location` + `/ws` | Optional explizite WebSocket-URL |
|
||||||
|
| `VITE_PUBLIC_ORIGIN` | leer | Öffentliche Vite-Origin (Remote-Dev) |
|
||||||
|
| `VITE_ALLOWED_HOSTS` | `true` | Komma-separierte Hostliste für Vite `allowedHosts` |
|
||||||
|
| `VITE_HMR_PROTOCOL` | abgeleitet aus `VITE_PUBLIC_ORIGIN` | HMR-Protokoll (`ws`/`wss`) |
|
||||||
|
| `VITE_HMR_HOST` | abgeleitet aus `VITE_PUBLIC_ORIGIN` | HMR-Host |
|
||||||
|
| `VITE_HMR_CLIENT_PORT` | abgeleitet aus `VITE_PUBLIC_ORIGIN` | HMR-Client-Port |
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# lokal (mit Vite-Proxy)
|
||||||
|
VITE_API_BASE=/api
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
# remote dev
|
||||||
|
VITE_API_BASE=http://192.168.1.50:3001/api
|
||||||
|
VITE_WS_URL=ws://192.168.1.50:3001/ws
|
||||||
|
VITE_PUBLIC_ORIGIN=http://192.168.1.50:5173
|
||||||
|
VITE_ALLOWED_HOSTS=192.168.1.50,ripster.local
|
||||||
|
VITE_HMR_PROTOCOL=ws
|
||||||
|
VITE_HMR_HOST=192.168.1.50
|
||||||
|
VITE_HMR_CLIENT_PORT=5173
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorität
|
||||||
|
|
||||||
|
1. Prozess-Umgebungsvariablen
|
||||||
|
2. `.env`
|
||||||
|
3. Code-Defaults
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Anhang: Konfiguration
|
||||||
|
|
||||||
|
Dieser Abschnitt ist die technische Referenz zu allen Konfigurationsarten in Ripster.
|
||||||
|
|
||||||
|
## Inhalte
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-tune: **Einstellungsreferenz**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Vollständige Liste aller UI-Settings (Typ, Default, Hinweise).
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Einstellungsreferenz](settings-reference.md)
|
||||||
|
|
||||||
|
- :material-variable: **Umgebungsvariablen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
`backend/.env` und `frontend/.env` inkl. Prioritäten.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Umgebungsvariablen](environment.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Zurück zum Handbuch
|
||||||
|
|
||||||
|
- [Benutzerhandbuch Überblick](../getting-started/index.md)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template-Platzhalter
|
||||||
|
|
||||||
|
Datei-/Ordner-Templates unterstützen:
|
||||||
|
|
||||||
|
- `${title}`
|
||||||
|
- `${year}`
|
||||||
|
- `${imdbId}`
|
||||||
|
|
||||||
|
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` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| `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` |
|
||||||
|
| `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: 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` |
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Entwicklungsumgebung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js >= 20.19.0
|
||||||
|
- externe Tools installiert (`makemkvcon`, `HandBrakeCLI`, `mediainfo`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Startet:
|
||||||
|
|
||||||
|
- Backend (`http://localhost:3001`, mit nodemon)
|
||||||
|
- Frontend (`http://localhost:5173`, mit Vite HMR)
|
||||||
|
|
||||||
|
Stoppen: `Ctrl+C`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manuell
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vite-Proxy (Dev)
|
||||||
|
|
||||||
|
`frontend/vite.config.js` proxied standardmäßig:
|
||||||
|
|
||||||
|
- `/api` -> `http://127.0.0.1:3001`
|
||||||
|
- `/ws` -> `ws://127.0.0.1:3001`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote-Dev (optional)
|
||||||
|
|
||||||
|
Beispiel `frontend/.env.local`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE=http://192.168.1.100:3001/api
|
||||||
|
VITE_WS_URL=ws://192.168.1.100:3001/ws
|
||||||
|
VITE_PUBLIC_ORIGIN=http://192.168.1.100:5173
|
||||||
|
VITE_ALLOWED_HOSTS=192.168.1.100,ripster.local
|
||||||
|
VITE_HMR_PROTOCOL=ws
|
||||||
|
VITE_HMR_HOST=192.168.1.100
|
||||||
|
VITE_HMR_CLIENT_PORT=5173
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nützliche Kommandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Root dev (backend + frontend)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# einzeln
|
||||||
|
npm run dev:backend
|
||||||
|
npm run dev:frontend
|
||||||
|
|
||||||
|
# Frontend Build
|
||||||
|
npm run build:frontend
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Anhang: Deployment
|
||||||
|
|
||||||
|
Technische Betriebsdokumentation für Entwicklung und Produktion.
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-laptop: **Entwicklungsumgebung**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Lokale Entwicklung mit Hot-Reload.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Entwicklung](development.md)
|
||||||
|
|
||||||
|
- :material-server: **Produktion**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Installation und Betrieb auf Servern.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Produktion](production.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# 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;
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 /opt/ripster/backend/data/ripster.db \
|
||||||
|
".backup '/var/backups/ripster-$(date +%Y%m%d).db'"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Ripster hat keine eingebaute Authentifizierung.
|
||||||
|
- Für externen Zugriff mindestens Basic Auth + TLS + Netzwerksegmentierung/VPN einsetzen.
|
||||||
|
- Secrets nicht ins Repo committen (`.env`, Settings-Felder).
|
||||||
@@ -0,0 +1,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 Dashboard
|
||||||
|
|
||||||
|
### 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. `Dashboard` ö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)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Benutzerhandbuch Überblick
|
||||||
|
|
||||||
|
Dieses Kapitel ist für den **Betrieb von Ripster im Alltag** geschrieben.
|
||||||
|
|
||||||
|
## Zielgruppe
|
||||||
|
|
||||||
|
- Anwender, die Discs verarbeiten wollen
|
||||||
|
- Betreiber, die den täglichen Ablauf stabil fahren möchten
|
||||||
|
- Power-User, die Queue/Skripte/Cron im UI steuern möchten
|
||||||
|
|
||||||
|
## Kapitelstruktur
|
||||||
|
|
||||||
|
| Kapitel | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| [Voraussetzungen](prerequisites.md) | Produktions- vs. Dev-Voraussetzungen klar trennen |
|
||||||
|
| [Installation](installation.md) | Ripster aufsetzen und starten |
|
||||||
|
| [Ersteinrichtung](configuration.md) | Pfade, Tools und Metadaten korrekt setzen |
|
||||||
|
| [Erster Lauf](quickstart.md) | Ein kompletter Job von Disc bis Datei |
|
||||||
|
| [GUI-Seiten](../gui/index.md) | Alle Ansichten und Aktionen im Detail |
|
||||||
|
| [Workflows](../workflows/index.md) | Typische Abläufe und Entscheidungen aus User-Sicht |
|
||||||
|
|
||||||
|
## Wenn du neu startest
|
||||||
|
|
||||||
|
1. [Voraussetzungen](prerequisites.md)
|
||||||
|
2. [Installation](installation.md)
|
||||||
|
3. [Ersteinrichtung](configuration.md)
|
||||||
|
4. [Erster Lauf](quickstart.md)
|
||||||
|
|
||||||
|
## Wenn Ripster bereits läuft
|
||||||
|
|
||||||
|
1. [GUI-Seiten](../gui/index.md)
|
||||||
|
2. [Workflows](../workflows/index.md)
|
||||||
|
3. Bei Detailfragen: [Technischer Anhang](../appendix/index.md)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
Die empfohlene Installation läuft über `install.sh` und richtet Ripster vollständig ein.
|
||||||
|
|
||||||
|
Du musst dafür **keine Tools manuell vorinstallieren**. Das Skript installiert die benötigten Abhängigkeiten automatisch, sofern sie nicht explizit mit `--no-*` übersprungen werden.
|
||||||
|
|
||||||
|
## Unterstützte Systeme und Anforderungen
|
||||||
|
|
||||||
|
- unterstützt laut Script: `debian`, `ubuntu`, `linuxmint`, `pop`
|
||||||
|
- Ausführung als `root` (oder via `sudo`)
|
||||||
|
- Internetzugang während der Installation
|
||||||
|
|
||||||
|
## Schritt-für-Schritt
|
||||||
|
|
||||||
|
### 1. Installationsskript herunterladen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Installation ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Während der Installation wählst du den HandBrake-Modus:
|
||||||
|
|
||||||
|
- `1` Standard (Paketinstallation)
|
||||||
|
- `2` GPU/NVDEC (gebündeltes Binary)
|
||||||
|
|
||||||
|
### 3. Dienststatus prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status ripster-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Weboberfläche öffnen
|
||||||
|
|
||||||
|
- mit nginx (Standard): `http://<Server-IP>`
|
||||||
|
- ohne nginx (`--no-nginx`): API auf `http://<Server-IP>:3001/api`
|
||||||
|
|
||||||
|
## Was `install.sh` konkret einrichtet
|
||||||
|
|
||||||
|
1. prüft Betriebssystem, Root-Rechte und ermittelt Host/IP
|
||||||
|
2. aktualisiert Paketquellen und installiert Basispakete (`curl`, `wget`, `git`, `mediainfo`, `udev` ...)
|
||||||
|
3. installiert Node.js 20 (falls nicht passend vorhanden)
|
||||||
|
4. installiert optional MakeMKV (Build aus den offiziellen Quellen)
|
||||||
|
5. installiert optional HandBrakeCLI (Standard oder GPU/NVDEC)
|
||||||
|
6. installiert optional nginx
|
||||||
|
7. legt den Systembenutzer `ripster` an (ohne Login-Shell, ohne Home) und ergänzt Gruppen (`cdrom`, `optical`, `disk`, `video`, `render`, falls vorhanden)
|
||||||
|
8. klont das Repository nach `/opt/ripster` (oder aktualisiert bei `--reinstall`)
|
||||||
|
9. legt Verzeichnisse an:
|
||||||
|
- `/opt/ripster/backend/data`
|
||||||
|
- `/opt/ripster/backend/logs`
|
||||||
|
- `/opt/ripster/backend/data/output/raw`
|
||||||
|
- `/opt/ripster/backend/data/output/movies`
|
||||||
|
- `/opt/ripster/backend/data/logs`
|
||||||
|
10. installiert npm-Abhängigkeiten (Root, Backend, Frontend) und baut das Frontend
|
||||||
|
11. erzeugt `backend/.env` (bei `--reinstall` bleibt bestehende `.env` erhalten)
|
||||||
|
12. setzt Rechte/Besitz und erstellt bei sudo-Installation zusätzlich `~/.MakeMKV` für den aufrufenden Benutzer
|
||||||
|
13. erzeugt und startet `ripster-backend.service`
|
||||||
|
14. konfiguriert und startet nginx (falls nicht übersprungen)
|
||||||
|
|
||||||
|
## Wichtige Optionen
|
||||||
|
|
||||||
|
| Option | Default laut Script | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `--branch <branch>` | `dev` | Branch für die Installation |
|
||||||
|
| `--dir <pfad>` | `/opt/ripster` | Installationsverzeichnis |
|
||||||
|
| `--user <benutzer>` | `ripster` | Service-Benutzer |
|
||||||
|
| `--port <port>` | `3001` | Backend-Port |
|
||||||
|
| `--host <hostname>` | automatisch ermittelte Host-IP | Hostname/IP für Webzugriff/CORS |
|
||||||
|
| `--no-makemkv` | aus | MakeMKV-Installation überspringen |
|
||||||
|
| `--no-handbrake` | aus | HandBrake-Installation überspringen |
|
||||||
|
| `--no-nginx` | aus | nginx-Setup überspringen |
|
||||||
|
| `--reinstall` | aus | bestehende Installation aktualisieren |
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash install.sh --branch dev
|
||||||
|
sudo bash install.sh --port 8080 --host ripster.local
|
||||||
|
sudo bash install.sh --reinstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Betrieb im Alltag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs live ansehen
|
||||||
|
sudo journalctl -u ripster-backend -f
|
||||||
|
|
||||||
|
# Dienst neu starten
|
||||||
|
sudo systemctl restart ripster-backend
|
||||||
|
|
||||||
|
# Update aus bestehender Installation
|
||||||
|
sudo bash /opt/ripster/install.sh --reinstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Häufige Stolperstellen
|
||||||
|
|
||||||
|
- Laufwerk nicht zugreifbar: Laufwerksrechte/Gruppen prüfen
|
||||||
|
- CLI-Tool fehlt wegen `--no-*`: Tool nachinstallieren oder Befehl in Settings korrigieren
|
||||||
|
- UI nicht erreichbar: nginx-Status und Firewall prüfen
|
||||||
|
|
||||||
|
## Danach weiter
|
||||||
|
|
||||||
|
1. [Ersteinrichtung](configuration.md)
|
||||||
|
2. [Erster Lauf](quickstart.md)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Voraussetzungen
|
||||||
|
|
||||||
|
Die Voraussetzungen hängen davon ab, **wie** du Ripster betreibst.
|
||||||
|
|
||||||
|
## Produktionsbetrieb mit `install.sh` (Standard)
|
||||||
|
|
||||||
|
Für den normalen Betrieb sind nur wenige Punkte vorab nötig.
|
||||||
|
|
||||||
|
### Pflicht
|
||||||
|
|
||||||
|
- unterstütztes Linux-System (laut Script: Debian, Ubuntu, Linux Mint, Pop!_OS)
|
||||||
|
- `root`-Rechte
|
||||||
|
- Internetzugang während der Installation
|
||||||
|
- optisches Laufwerk für Disc-Betrieb
|
||||||
|
|
||||||
|
`install.sh` installiert die benötigten Tools selbst (u. a. Node.js, MakeMKV, HandBrakeCLI, MediaInfo), sofern sie nicht explizit per `--no-*` übersprungen werden.
|
||||||
|
|
||||||
|
### Laufwerk kurz prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /dev/sr*
|
||||||
|
lsblk | grep rom
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn nötig Rechte setzen (Beispiel):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chmod a+rw /dev/sr0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional vorab
|
||||||
|
|
||||||
|
- OMDb API-Key (kann auch nach Installation in den `Settings` gesetzt werden)
|
||||||
|
- PushOver-Zugangsdaten (optional)
|
||||||
|
|
||||||
|
## Entwicklungsmodus (nur für Dev)
|
||||||
|
|
||||||
|
Wenn du lokal entwickelst (`./start.sh`, `npm run dev`), gelten zusätzliche Voraussetzungen:
|
||||||
|
|
||||||
|
- Node.js >= 20.19.0
|
||||||
|
- `makemkvcon`, `HandBrakeCLI`, `mediainfo` im `PATH`
|
||||||
|
|
||||||
|
Details: [Entwicklungsumgebung](../deployment/development.md)
|
||||||
|
|
||||||
|
## Abschluss-Checkliste
|
||||||
|
|
||||||
|
- [ ] Produktionsbetrieb: Linux + root + Internet + Laufwerk vorhanden
|
||||||
|
- [ ] Dev-Modus (nur falls benötigt): Node.js und CLI-Tools verfügbar
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Erster Lauf
|
||||||
|
|
||||||
|
Dieser Ablauf zeigt einen vollständigen Job aus Anwendersicht: von Disc-Erkennung bis fertiger Datei.
|
||||||
|
|
||||||
|
## 1. Dashboard öffnen und Disc einlegen
|
||||||
|
|
||||||
|
Erwartung:
|
||||||
|
|
||||||
|
- Status wechselt auf `Medium erkannt`
|
||||||
|
- im Bereich `Disk-Information` sind Laufwerksdaten sichtbar
|
||||||
|
|
||||||
|
Wenn nichts passiert: `Laufwerk neu lesen`.
|
||||||
|
|
||||||
|
## 2. Analyse starten
|
||||||
|
|
||||||
|
Aktion im Dashboard:
|
||||||
|
|
||||||
|
- `Analyse starten`
|
||||||
|
|
||||||
|
Erwartung:
|
||||||
|
|
||||||
|
- Status `Analyse`
|
||||||
|
- danach Metadaten-Dialog
|
||||||
|
|
||||||
|
## 3. Metadaten auswählen
|
||||||
|
|
||||||
|
Im Dialog `Metadaten auswählen`:
|
||||||
|
|
||||||
|
1. OMDb-Suche nutzen oder manuell eintragen
|
||||||
|
2. passenden Treffer markieren
|
||||||
|
3. `Auswahl übernehmen`
|
||||||
|
|
||||||
|
## 4. Auf den nächsten Zustand reagieren
|
||||||
|
|
||||||
|
- Normalfall ohne vorhandenes RAW: `Rippen` -> `Mediainfo-Pruefung` -> `Bereit zum Encodieren`
|
||||||
|
- bei vorhandenem RAW: direkt `Mediainfo-Pruefung` -> `Bereit zum Encodieren`
|
||||||
|
- bei unklarer Blu-ray-Playlist: `Warte auf Auswahl` (Playlist auswählen und übernehmen)
|
||||||
|
|
||||||
|
## 5. Review in `Bereit zum Encodieren`
|
||||||
|
|
||||||
|
Im aufgeklappten Job (`Pipeline-Status`):
|
||||||
|
|
||||||
|
- Encode-Titel wählen
|
||||||
|
- Audio-/Subtitle-Spuren prüfen
|
||||||
|
- optional User-Preset auswählen
|
||||||
|
- optional Pre-/Post-Skripte bzw. Ketten hinzufügen
|
||||||
|
|
||||||
|
Dann `Encoding starten`.
|
||||||
|
|
||||||
|
## 6. Encoding überwachen
|
||||||
|
|
||||||
|
Während `Encodieren`:
|
||||||
|
|
||||||
|
- Fortschritt + ETA im Dashboard
|
||||||
|
- Live-Log im `Pipeline-Status`
|
||||||
|
- Queue- und Skript/Cron-Status parallel beobachtbar
|
||||||
|
|
||||||
|
## 7. Ergebnis prüfen
|
||||||
|
|
||||||
|
Bei `Fertig`:
|
||||||
|
|
||||||
|
1. Seite `Historie` öffnen
|
||||||
|
2. Job in Details öffnen
|
||||||
|
3. Output-Pfad, Status und Log prüfen
|
||||||
|
|
||||||
|
## Typische Folgeaktionen
|
||||||
|
|
||||||
|
- Falsches OMDb-Match: in `Historie` -> `OMDb neu zuordnen`
|
||||||
|
- Neue Encodierung aus RAW: `RAW neu encodieren`
|
||||||
|
- Prüfung komplett neu aufbauen: `Review neu starten`
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Dashboard
|
||||||
|
|
||||||
|
Das Dashboard ist die **Betriebszentrale** für laufende Jobs.
|
||||||
|
|
||||||
|
## Aufbau der Seite
|
||||||
|
|
||||||
|
Die Bereiche erscheinen in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. `Hardware Monitoring`
|
||||||
|
2. `Job Queue`
|
||||||
|
3. `Skript- / Cron-Status`
|
||||||
|
4. `Job Übersicht`
|
||||||
|
5. `Disk-Information`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Hardware Monitoring
|
||||||
|
|
||||||
|
Zeigt live:
|
||||||
|
|
||||||
|
- CPU (gesamt + optional pro Kern)
|
||||||
|
- RAM
|
||||||
|
- GPU-Auslastung/Temperatur/VRAM
|
||||||
|
- freien Speicher in den konfigurierten Pfaden
|
||||||
|
|
||||||
|
Wichtig für den Betrieb:
|
||||||
|
|
||||||
|
- Hohe Speicherauslastung oder fast volle Zielpfade früh erkennen
|
||||||
|
- über `Settings` aktivierbar/deaktivierbar (`Hardware Monitoring aktiviert`)
|
||||||
|
|
||||||
|
## 2) Job Queue
|
||||||
|
|
||||||
|
Zwei Spalten:
|
||||||
|
|
||||||
|
- `Laufende Jobs`
|
||||||
|
- `Warteschlange`
|
||||||
|
|
||||||
|
Mögliche Aktionen:
|
||||||
|
|
||||||
|
- Queue per Drag-and-Drop umsortieren
|
||||||
|
- Queue-Job entfernen (`X`)
|
||||||
|
- zusätzliche Queue-Elemente einfügen (`+`):
|
||||||
|
- Skript
|
||||||
|
- Skriptkette
|
||||||
|
- Wartezeit
|
||||||
|
|
||||||
|
Hinweis:
|
||||||
|
|
||||||
|
- `Parallel` zeigt den Wert aus `Settings` -> `Parallele Jobs`.
|
||||||
|
|
||||||
|
## 3) Skript- / Cron-Status
|
||||||
|
|
||||||
|
Zeigt:
|
||||||
|
|
||||||
|
- aktive Ausführungen (Skripte, Ketten, Cron)
|
||||||
|
- zuletzt abgeschlossene Ausführungen
|
||||||
|
|
||||||
|
Mögliche Aktionen:
|
||||||
|
|
||||||
|
- laufende Ketten: `Nächster Schritt`
|
||||||
|
- laufende Einträge: `Abbrechen`
|
||||||
|
- Historie der Aktivitäten: `Liste leeren`
|
||||||
|
|
||||||
|
## 4) Job Übersicht
|
||||||
|
|
||||||
|
Kompakte Jobliste mit Status, Fortschritt, ETA. Klick auf einen Job klappt die Detailsteuerung auf.
|
||||||
|
|
||||||
|
Im aufgeklappten Zustand erscheint die Karte `Pipeline-Status` mit allen zustandsabhängigen Aktionen.
|
||||||
|
|
||||||
|
### Zustandsabhängige Hauptaktionen
|
||||||
|
|
||||||
|
| Zustand | Typische Aktion |
|
||||||
|
|---|---|
|
||||||
|
| `Medium erkannt` / `Bereit` | `Analyse starten` |
|
||||||
|
| `Metadatenauswahl` | `Metadaten öffnen` |
|
||||||
|
| `Warte auf Auswahl` | Playlist wählen und `Playlist übernehmen` |
|
||||||
|
| `Startbereit` | `Job starten` |
|
||||||
|
| `Bereit zum Encodieren` | Tracks/Skripte prüfen, dann `Encoding starten` |
|
||||||
|
| laufend (`Analyse`/`Rippen`/`Encodieren`) | `Abbrechen` |
|
||||||
|
| `Fehler` / `Abgebrochen` | `Retry Rippen`, `Disk-Analyse neu starten` |
|
||||||
|
|
||||||
|
Zusätzlich je nach Job:
|
||||||
|
|
||||||
|
- `Review neu starten`
|
||||||
|
- `Encode neu starten`
|
||||||
|
- `Aus Queue löschen`
|
||||||
|
|
||||||
|
### Titel-/Spurprüfung (`Bereit zum Encodieren`)
|
||||||
|
|
||||||
|
Im selben Block siehst du:
|
||||||
|
|
||||||
|
- Auswahl des Encode-Titels
|
||||||
|
- Audio-/Subtitle-Trackauswahl
|
||||||
|
- User-Preset-Auswahl
|
||||||
|
- Pre-/Post-Encode-Skripte und Ketten
|
||||||
|
- Preview des finalen HandBrakeCLI-Befehls
|
||||||
|
|
||||||
|
## 5) Disk-Information
|
||||||
|
|
||||||
|
Zeigt aktuelles Laufwerk und Disc-Metadaten (`Pfad`, `Modell`, `Disc-Label`, `Mount`).
|
||||||
|
|
||||||
|
Aktionen:
|
||||||
|
|
||||||
|
- `Laufwerk neu lesen`
|
||||||
|
- `Disk neu analysieren`
|
||||||
|
- `Metadaten-Modal öffnen`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Dialoge im Dashboard
|
||||||
|
|
||||||
|
### Metadaten auswählen
|
||||||
|
|
||||||
|
- OMDb-Suche + Ergebnisliste
|
||||||
|
- manuelle Eingabe als Fallback
|
||||||
|
- `Auswahl übernehmen` startet den nächsten Pipeline-Schritt
|
||||||
|
|
||||||
|
### Abbruch-Bereinigung
|
||||||
|
|
||||||
|
Nach Abbruch kann Ripster optional fragen, ob erzeugte RAW- oder Movie-Dateien gelöscht werden sollen.
|
||||||
|
|
||||||
|
### Queue-Eintrag einfügen
|
||||||
|
|
||||||
|
Erstellt gezielt einen Skript-, Ketten- oder Warte-Eintrag an einer bestimmten Queue-Position.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Database (Expert)
|
||||||
|
|
||||||
|
`/database` ist eine erweiterte Ansicht für Power-User und Recovery-Fälle.
|
||||||
|
|
||||||
|
## Zugriff
|
||||||
|
|
||||||
|
- Route direkt aufrufen: `/database`
|
||||||
|
- nicht Teil der Standard-Navigation
|
||||||
|
|
||||||
|
## Bereiche
|
||||||
|
|
||||||
|
### 1) `Historie & Datenbank`
|
||||||
|
|
||||||
|
Tabellarische Jobansicht mit:
|
||||||
|
|
||||||
|
- ID, Poster, Medium, Titel
|
||||||
|
- Status
|
||||||
|
- Start/Ende
|
||||||
|
|
||||||
|
Aktionen im Detaildialog entsprechen weitgehend der Seite `Historie` (inkl. Re-Encode, Review-Neustart, OMDb-Zuordnung, Dateilöschung).
|
||||||
|
|
||||||
|
### 2) `RAW ohne Historie`
|
||||||
|
|
||||||
|
Listet RAW-Ordner, die keinen zugehörigen Job-Eintrag haben.
|
||||||
|
|
||||||
|
Aktionen:
|
||||||
|
|
||||||
|
- `RAW prüfen` (Scan der konfigurierten RAW-Pfade)
|
||||||
|
- `Job anlegen` (Orphan-RAW in Historie importieren)
|
||||||
|
|
||||||
|
## Typischer Einsatz
|
||||||
|
|
||||||
|
- nach manuellen Dateioperationen
|
||||||
|
- nach Migrationen oder Recovery
|
||||||
|
- wenn RAW-Dateien vorhanden sind, aber kein Historieneintrag existiert
|
||||||
|
|
||||||
|
## Vorsicht
|
||||||
|
|
||||||
|
Diese Seite erlaubt Eingriffe mit direkter Auswirkung auf Datenbestand und Historie. Vor Lösch- oder Importaktionen Pfade und Zieljob sorgfältig prüfen.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Historie
|
||||||
|
|
||||||
|
Die Seite `Historie` ist für Suche, Prüfung und Nachbearbeitung bestehender Jobs.
|
||||||
|
|
||||||
|
## Hauptansicht
|
||||||
|
|
||||||
|
Filter und Werkzeuge:
|
||||||
|
|
||||||
|
- Suche (Titel/IMDb)
|
||||||
|
- Status-Filter
|
||||||
|
- Medium-Filter (`Blu-ray`, `DVD`, `Sonstiges`)
|
||||||
|
- Sortierung
|
||||||
|
- Listen-/Grid-Layout
|
||||||
|
|
||||||
|
Jeder Eintrag zeigt:
|
||||||
|
|
||||||
|
- Poster, Titel, Jahr, IMDb
|
||||||
|
- Medium-Indikator
|
||||||
|
- Status
|
||||||
|
- Start/Ende
|
||||||
|
- Verfügbarkeit von RAW/Movie
|
||||||
|
- Ratings (wenn OMDb-Daten vorhanden)
|
||||||
|
|
||||||
|
Klick auf einen Eintrag öffnet die Detailansicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Job-Detaildialog
|
||||||
|
|
||||||
|
Bereiche:
|
||||||
|
|
||||||
|
- Film-Infos + OMDb-Details
|
||||||
|
- Job-Infos (Status, Pfade, Erfolgsflags, Fehler)
|
||||||
|
- hinterlegte Encode-Auswahl
|
||||||
|
- ausgeführter HandBrake-Befehl
|
||||||
|
- strukturierte JSON-Blöcke (OMDb/MakeMKV/MediaInfo/EncodePlan/HandBrake)
|
||||||
|
- Log-Ladefunktionen (`Tail`, `Vollständig`)
|
||||||
|
|
||||||
|
## Typische Aktionen im Detaildialog
|
||||||
|
|
||||||
|
- `OMDb neu zuordnen`
|
||||||
|
- `Encode neu starten`
|
||||||
|
- `Review neu starten`
|
||||||
|
- `RAW neu encodieren`
|
||||||
|
- `RAW löschen`, `Movie löschen`, `Beides löschen`
|
||||||
|
- `Historieneintrag löschen`
|
||||||
|
- bei Queue-Lock: `Aus Queue löschen`
|
||||||
|
|
||||||
|
## Wann welche Aktion?
|
||||||
|
|
||||||
|
| Ziel | Aktion |
|
||||||
|
|---|---|
|
||||||
|
| Metadaten korrigieren | `OMDb neu zuordnen` |
|
||||||
|
| mit gleicher bestätigter Auswahl neu encodieren | `Encode neu starten` |
|
||||||
|
| Titel-/Spurprüfung komplett neu berechnen | `Review neu starten` |
|
||||||
|
| aus vorhandenem RAW erneut encodieren | `RAW neu encodieren` |
|
||||||
|
| Speicher freigeben | Dateilöschaktionen |
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
- `Tail laden (800)` für schnelle Fehleranalyse
|
||||||
|
- `Vollständiges Log laden` für vollständige Nachverfolgung
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# GUI-Seiten
|
||||||
|
|
||||||
|
Ripster hat drei Hauptseiten in der Navigation plus eine Expert-Seite.
|
||||||
|
|
||||||
|
## Seitenüberblick
|
||||||
|
|
||||||
|
| Seite | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| [Dashboard](dashboard.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos |
|
||||||
|
| [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs |
|
||||||
|
| [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten |
|
||||||
|
| [Database (Expert)](database.md) | tabellarische Rohsicht inkl. Orphan-RAW-Import |
|
||||||
|
|
||||||
|
## Empfohlene Nutzung im Alltag
|
||||||
|
|
||||||
|
1. **Start eines neuen Jobs:** `Dashboard`
|
||||||
|
2. **Regeln/Automatisierung anpassen:** `Settings`
|
||||||
|
3. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie`
|
||||||
|
4. **Sonderfälle/Recovery:** `Database`
|
||||||
|
|
||||||
|
## Hinweise zur Navigation
|
||||||
|
|
||||||
|
- `Dashboard`, `Settings`, `Historie` sind direkt in der Kopfnavigation.
|
||||||
|
- `Database` ist als Expert-Route verfügbar: `/database`.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Settings
|
||||||
|
|
||||||
|
Die Seite `Settings` steuert Konfiguration und Automatisierung.
|
||||||
|
|
||||||
|
## Tabs im Überblick
|
||||||
|
|
||||||
|
| Tab | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `Konfiguration` | alle Kernsettings (Pfade, Tools, Monitoring, Metadaten, Queue, Benachrichtigungen) |
|
||||||
|
| `Scripte` | einzelne Bash-Skripte verwalten und testen |
|
||||||
|
| `Skriptketten` | Sequenzen aus Skript- und Warte-Schritten bauen |
|
||||||
|
| `Encode-Presets` | benutzerdefinierte Presets für das Review im Dashboard |
|
||||||
|
| `Cronjobs` | zeitgesteuerte Skript-/Kettenausführung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tab `Konfiguration`
|
||||||
|
|
||||||
|
Wichtiges Bedienmuster:
|
||||||
|
|
||||||
|
1. Werte ändern
|
||||||
|
2. `Änderungen speichern`
|
||||||
|
3. bei Bedarf `Änderungen verwerfen` oder `Neu laden`
|
||||||
|
|
||||||
|
Zusätzlich:
|
||||||
|
|
||||||
|
- `PushOver Test` sendet eine Testnachricht
|
||||||
|
- Änderungen werden erst nach Speichern wirksam
|
||||||
|
- Tool-Preset-Felder bieten HandBrake-Presetauswahl direkt im Formular
|
||||||
|
|
||||||
|
## Tab `Scripte`
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
|
||||||
|
- Skript anlegen, bearbeiten, löschen
|
||||||
|
- Skript testen (`Test`)
|
||||||
|
- Reihenfolge per Drag-and-Drop
|
||||||
|
|
||||||
|
Praxis:
|
||||||
|
|
||||||
|
- Reihenfolge ist wichtig, weil ausgewählte Skripte später sequentiell abgearbeitet werden.
|
||||||
|
- Testresultate zeigen Exit-Code, Dauer und stdout/stderr.
|
||||||
|
|
||||||
|
## Tab `Skriptketten`
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
|
||||||
|
- Kette anlegen/bearbeiten/löschen
|
||||||
|
- Kette testen
|
||||||
|
- Reihenfolge der Ketten per Drag-and-Drop
|
||||||
|
|
||||||
|
Im Ketten-Editor:
|
||||||
|
|
||||||
|
- Bausteine links (`Warten`, vorhandene Skripte)
|
||||||
|
- Schritte rechts per Klick oder Drag-and-Drop hinzufügen
|
||||||
|
- Schrittreihenfolge im Canvas ändern
|
||||||
|
|
||||||
|
## Tab `Encode-Presets`
|
||||||
|
|
||||||
|
Ein Preset bündelt:
|
||||||
|
|
||||||
|
- optional HandBrake-Preset (`-Z`)
|
||||||
|
- optionale Extra-Args
|
||||||
|
- Medientyp (`Universell`, `Blu-ray`, `DVD`, `Sonstiges`)
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
|
||||||
|
- Diese Presets erscheinen später im Dashboard im Review (`Bereit zum Encodieren`).
|
||||||
|
|
||||||
|
## Tab `Cronjobs`
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
|
||||||
|
- Cronjob anlegen und bearbeiten
|
||||||
|
- Quelle wählen: Skript oder Skriptkette
|
||||||
|
- Cron-Ausdruck validieren
|
||||||
|
- `Jetzt ausführen`
|
||||||
|
- Logs je Cronjob anzeigen
|
||||||
|
- `Aktiviert` und `Pushover` toggeln
|
||||||
|
|
||||||
|
Hilfen:
|
||||||
|
|
||||||
|
- Beispiele für Cron-Ausdrücke direkt im Dialog
|
||||||
|
- Link zu `crontab.guru` im Editor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfehlung für stabile Nutzung
|
||||||
|
|
||||||
|
1. Erst `Konfiguration` sauber setzen
|
||||||
|
2. dann Skripte/Ketten testen
|
||||||
|
3. danach Cronjobs aktivieren
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Ripster Handbuch
|
||||||
|
|
||||||
|
Dieses Dokumentationsset ist als **Benutzerhandbuch** aufgebaut: erst Bedienung und Alltag, dann Technik im Anhang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart in 3 Schritten
|
||||||
|
|
||||||
|
1. Voraussetzungen prüfen und installieren: [Installation](getting-started/installation.md)
|
||||||
|
2. Grundkonfiguration in der UI setzen: [Ersteinrichtung](getting-started/configuration.md)
|
||||||
|
3. Ersten vollständigen Job durchlaufen: [Erster Lauf](getting-started/quickstart.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was du hier findest
|
||||||
|
|
||||||
|
- **Benutzerhandbuch**
|
||||||
|
- Installation
|
||||||
|
- GUI-Seiten im Detail (`Dashboard`, `Settings`, `Historie`, `Database`)
|
||||||
|
- typische Arbeitsabläufe aus Anwendersicht
|
||||||
|
- **Technischer Anhang**
|
||||||
|
- vollständige Einstellungsreferenz
|
||||||
|
- Pipeline-/API-/Architekturdetails
|
||||||
|
- Deployment und Tool-Hintergründe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Lesereihenfolge
|
||||||
|
|
||||||
|
1. [Benutzerhandbuch Überblick](getting-started/index.md)
|
||||||
|
2. [GUI-Seiten](gui/index.md)
|
||||||
|
3. [Workflows aus Nutzersicht](workflows/index.md)
|
||||||
|
4. Bei Bedarf: [Technischer Anhang](appendix/index.md)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Encode-Planung & Track-Auswahl
|
||||||
|
|
||||||
|
Vor dem eigentlichen Encoding erstellt Ripster einen Encode-Plan und zeigt ihn im Review an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quelle bestimmen (Disc/RAW)
|
||||||
|
-> HandBrake-Scan (--scan --json)
|
||||||
|
-> Plan erstellen (Titel, Audio, Untertitel)
|
||||||
|
-> Status: Bereit zum Encodieren
|
||||||
|
-> Benutzer bestaetigt Auswahl
|
||||||
|
-> finaler HandBrake-Aufruf
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review-Inhalt (Status: `Bereit zum Encodieren`)
|
||||||
|
|
||||||
|
- auswählbarer Encode-Titel
|
||||||
|
- Audio-Track-Auswahl
|
||||||
|
- Untertitel-Track-Auswahl inkl. Flags
|
||||||
|
- `burnIn`
|
||||||
|
- `forced`
|
||||||
|
- `defaultTrack`
|
||||||
|
- optionale User-Presets (HandBrake Preset + Extra Args)
|
||||||
|
- optionale Pre-/Post-Skripte und Ketten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bestaetigung (`confirm-encode`)
|
||||||
|
|
||||||
|
Typischer Payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"selectedEncodeTitleId": 1,
|
||||||
|
"selectedTrackSelection": {
|
||||||
|
"1": {
|
||||||
|
"audioTrackIds": [1, 2],
|
||||||
|
"subtitleTrackIds": [3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectedPreEncodeScriptIds": [1],
|
||||||
|
"selectedPostEncodeScriptIds": [2],
|
||||||
|
"selectedPreEncodeChainIds": [3],
|
||||||
|
"selectedPostEncodeChainIds": [4],
|
||||||
|
"selectedUserPresetId": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die bestätigte Auswahl wird im Job gespeichert und für Neustarts wiederverwendet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HandBrake-Aufruf
|
||||||
|
|
||||||
|
Grundstruktur:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI \
|
||||||
|
-i <input> \
|
||||||
|
-o <output> \
|
||||||
|
-t <titleId> \
|
||||||
|
-Z "<preset>" \
|
||||||
|
<extra-args> \
|
||||||
|
-a <audioTrackIds|none> \
|
||||||
|
-s <subtitleTrackIds|none>
|
||||||
|
```
|
||||||
|
|
||||||
|
Untertitel-Flags werden bei Bedarf ergänzt:
|
||||||
|
|
||||||
|
- `--subtitle-burned=<id>`
|
||||||
|
- `--subtitle-default=<id>`
|
||||||
|
- `--subtitle-forced=<id>` oder `--subtitle-forced`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-/Post-Encode-Ausführungen
|
||||||
|
|
||||||
|
- Pre-Encode läuft vor HandBrake
|
||||||
|
- Post-Encode läuft nach HandBrake
|
||||||
|
|
||||||
|
Fehlerverhalten:
|
||||||
|
|
||||||
|
- Pre-Encode-Fehler: Job endet mit Status `Fehler` (Encode startet nicht)
|
||||||
|
- Post-Encode-Fehler: Job kann `Fertig` bleiben, enthält aber Fehlerhinweis/Script-Summary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateinamen/Ordner
|
||||||
|
|
||||||
|
Der finale Outputpfad wird aus den Templates in den Settings aufgebaut.
|
||||||
|
|
||||||
|
Platzhalter:
|
||||||
|
|
||||||
|
- `${title}`
|
||||||
|
- `${year}`
|
||||||
|
- `${imdbId}`
|
||||||
|
|
||||||
|
Ungültige Dateizeichen werden bereinigt.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Anhang: Pipeline intern
|
||||||
|
|
||||||
|
Dieser Abschnitt beschreibt die technische Pipeline-Logik hinter den UI-Workflows.
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-state-machine: **Workflow & Zustände**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Zustandsmodell, Übergänge, Queue-Verhalten.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Workflow](workflow.md)
|
||||||
|
|
||||||
|
- :material-film: **Encode-Planung**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Aufbereitung von Titeln/Tracks und Bestätigungslogik.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Encoding](encoding.md)
|
||||||
|
|
||||||
|
- :material-playlist-check: **Playlist-Analyse**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bewertung mehrdeutiger Blu-ray-Playlists.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Playlist-Analyse](playlist-analysis.md)
|
||||||
|
|
||||||
|
- :material-script-text: **Pre-/Post-Encode-Ausführungen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Skript- und Kettenlauf vor/nach dem Encoding.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Encode-Skripte](post-encode-scripts.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Zurück zum Handbuch
|
||||||
|
|
||||||
|
- [Workflows aus Nutzersicht](../workflows/index.md)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Playlist-Analyse
|
||||||
|
|
||||||
|
Ripster analysiert bei Blu-ray-ähnlichen Quellen Playlists und fordert bei Mehrdeutigkeit eine manuelle Auswahl an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Erkennen, welche Playlist sehr wahrscheinlich der Hauptfilm ist, statt versehentlich eine Fake-/Dummy-Playlist zu verwenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Eingabedaten
|
||||||
|
|
||||||
|
Die Analyse basiert auf MakeMKV-Infos (u. a. Playlist-/Segment-Struktur, Laufzeiten, Titelzuordnung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auswertung (vereinfacht)
|
||||||
|
|
||||||
|
Für Kandidaten werden u. a. berücksichtigt:
|
||||||
|
|
||||||
|
- Laufzeit
|
||||||
|
- Segment-Reihenfolge
|
||||||
|
- Rückwärtssprünge/große Sprünge
|
||||||
|
- Kohärenz linearer Segmentfolgen
|
||||||
|
- Duplikatgruppen mit ähnlicher Laufzeit
|
||||||
|
|
||||||
|
Daraus entstehen intern Kandidatenlisten, Bewertungen und eine Empfehlung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wann muss der Benutzer entscheiden?
|
||||||
|
|
||||||
|
Wenn nach Filterung mehr als ein relevanter Kandidat übrig bleibt, wechselt der Job in der GUI auf:
|
||||||
|
|
||||||
|
- `Warte auf Auswahl`
|
||||||
|
|
||||||
|
Dann muss eine Playlist bestätigt werden, bevor der Workflow weiterläuft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfigurationseinfluss
|
||||||
|
|
||||||
|
| Feld in `Settings` | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `Minimale Titellaenge (Minuten)` | Mindestlaufzeit für Kandidaten |
|
||||||
|
|
||||||
|
Default ist aktuell `60` Minuten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI-Verhalten
|
||||||
|
|
||||||
|
Bei manueller Entscheidung zeigt das Dashboard Kandidaten inkl. Score/Bewertung und markiert eine Empfehlung.
|
||||||
|
|
||||||
|
Nach Bestätigung:
|
||||||
|
|
||||||
|
- mit vorhandenem RAW -> zurück zu `Mediainfo-Pruefung`
|
||||||
|
- ohne RAW -> Startpfad über `Startbereit` / `Rippen`
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Encode-Skripte (Pre & Post)
|
||||||
|
|
||||||
|
Ripster kann Skripte und Skript-Ketten vor und nach dem Encode ausführen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
```text
|
||||||
|
Bereit zum Encodieren
|
||||||
|
-> Pre-Encode Skripte/Ketten
|
||||||
|
-> HandBrake Encoding
|
||||||
|
-> Post-Encode Skripte/Ketten
|
||||||
|
-> Fertig oder Fehler
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auswahl im Review
|
||||||
|
|
||||||
|
Im Review-Panel kannst du getrennt wählen:
|
||||||
|
|
||||||
|
- `selectedPreEncodeScriptIds`
|
||||||
|
- `selectedPostEncodeScriptIds`
|
||||||
|
- `selectedPreEncodeChainIds`
|
||||||
|
- `selectedPostEncodeChainIds`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerverhalten
|
||||||
|
|
||||||
|
- Pre-Encode-Fehler stoppen die Kette und führen zu `Fehler`.
|
||||||
|
- Post-Encode-Fehler stoppen die restlichen Post-Schritte; Job kann dennoch `Fertig` sein (mit Fehlerzusatz im Status/Log).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verfügbare Umgebungsvariablen
|
||||||
|
|
||||||
|
Beim Script-Run werden 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
|
||||||
|
|
||||||
|
Ketten unterstützen zwei Step-Typen:
|
||||||
|
|
||||||
|
- `script` (führt ein hinterlegtes Skript aus)
|
||||||
|
- `wait` (wartet `waitSeconds`)
|
||||||
|
|
||||||
|
Bei Fehler in einem Script-Step wird die Kette abgebrochen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testläufe
|
||||||
|
|
||||||
|
- Skript testen: `POST /api/settings/scripts/:id/test`
|
||||||
|
- Kette testen: `POST /api/settings/script-chains/:id/test`
|
||||||
|
|
||||||
|
Ergebnisse enthalten Erfolg/Exit-Code, Laufzeit und stdout/stderr.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Workflow & Zustände
|
||||||
|
|
||||||
|
Ripster steuert den Ablauf als Zustandsmaschine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zustandsdiagramm (vereinfacht)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Bereit] --> B[Medium erkannt]
|
||||||
|
B --> C[Analyse]
|
||||||
|
C --> D[Metadatenauswahl]
|
||||||
|
D --> E[Startbereit]
|
||||||
|
E --> F[Rippen]
|
||||||
|
E --> G[Mediainfo-Pruefung]
|
||||||
|
G --> H[Warte auf Auswahl]
|
||||||
|
H --> G
|
||||||
|
G --> I[Bereit zum Encodieren]
|
||||||
|
I --> J[Encodieren]
|
||||||
|
J --> K[Fertig]
|
||||||
|
J --> L[Fehler]
|
||||||
|
F --> L
|
||||||
|
F --> M[Abgebrochen]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statusliste (GUI-Anzeige)
|
||||||
|
|
||||||
|
| Status in der GUI | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `Bereit` | Wartet auf Disc |
|
||||||
|
| `Medium erkannt` | Disc wurde erkannt |
|
||||||
|
| `Analyse` | MakeMKV-Analyse läuft |
|
||||||
|
| `Metadatenauswahl` | Metadaten müssen bestätigt werden |
|
||||||
|
| `Warte auf Auswahl` | Playlist-Auswahl ist erforderlich |
|
||||||
|
| `Startbereit` | kurzer Übergang vor Start |
|
||||||
|
| `Rippen` | MakeMKV-Rip läuft |
|
||||||
|
| `Mediainfo-Pruefung` | Titel/Spuren werden ausgewertet |
|
||||||
|
| `Bereit zum Encodieren` | Review ist bereit |
|
||||||
|
| `Encodieren` | HandBrake läuft |
|
||||||
|
| `Fertig` | erfolgreich abgeschlossen |
|
||||||
|
| `Abgebrochen` | manuell oder technisch abgebrochen |
|
||||||
|
| `Fehler` | fehlgeschlagen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typische Pfade
|
||||||
|
|
||||||
|
### Standardfall (kein vorhandenes RAW)
|
||||||
|
|
||||||
|
1. Medium erkannt
|
||||||
|
2. Analyse + Metadaten
|
||||||
|
3. Rippen
|
||||||
|
4. Mediainfo-Pruefung
|
||||||
|
5. Bereit zum Encodieren
|
||||||
|
6. Encodieren
|
||||||
|
7. Fertig
|
||||||
|
|
||||||
|
### Vorhandenes RAW
|
||||||
|
|
||||||
|
`Startbereit` springt direkt zu `Mediainfo-Pruefung` (kein neuer Rip).
|
||||||
|
|
||||||
|
### Mehrdeutige Blu-ray-Playlist
|
||||||
|
|
||||||
|
`Mediainfo-Pruefung` -> `Warte auf Auswahl` bis Playlist bestätigt wurde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Queue-Verhalten
|
||||||
|
|
||||||
|
Wenn der Wert `Parallele Jobs` erreicht ist:
|
||||||
|
|
||||||
|
- neue Starts werden als Queue-Einträge abgelegt
|
||||||
|
- die Queue kann zusätzlich Nicht-Job-Einträge enthalten (`Skript`, `Kette`, `Warten`)
|
||||||
|
- Reihenfolge ist per UI/API änderbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abbruch, Wiederaufnahme, Neustart
|
||||||
|
|
||||||
|
- `Abbrechen`: laufenden Job stoppen oder Queue-Eintrag entfernen
|
||||||
|
- `Retry Rippen`: Fehler-/Abbruch-Job erneut starten
|
||||||
|
- `RAW neu encodieren`: aus vorhandenem RAW neu encodieren
|
||||||
|
- `Review neu starten`: Review aus RAW neu aufbauen
|
||||||
|
- `Encode neu starten`: Encoding mit letzter bestätigter Auswahl neu starten
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/* Ripster custom styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--md-primary-fg-color: #6a1b9a;
|
||||||
|
--md-accent-fg-color: #ab47bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards grid layout */
|
||||||
|
.grid.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.cards > * {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--md-default-fg-color--lightest);
|
||||||
|
padding: 1.25rem;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.cards > *:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge colors */
|
||||||
|
.status-idle { color: #78909c; }
|
||||||
|
.status-analyzing { color: #fb8c00; }
|
||||||
|
.status-ripping { color: #1976d2; }
|
||||||
|
.status-encoding { color: #7b1fa2; }
|
||||||
|
.status-finished { color: #388e3c; }
|
||||||
|
.status-error { color: #d32f2f; }
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.md-typeset pre > code {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mermaid diagrams – standard */
|
||||||
|
.md-typeset .mermaid {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large pipeline flowchart: horizontal scroll + min-height */
|
||||||
|
.pipeline-diagram {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-diagram .mermaid {
|
||||||
|
min-width: 900px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-diagram .mermaid svg {
|
||||||
|
min-width: 900px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pipeline step track */
|
||||||
|
.pipeline-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 1rem 0 1.5rem 0;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 110px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step:not(:last-child)::after {
|
||||||
|
content: '→';
|
||||||
|
position: absolute;
|
||||||
|
right: -14px;
|
||||||
|
top: 22px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-badge {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 90px;
|
||||||
|
color: var(--md-default-fg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-step-sub {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--md-default-fg-color--light);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step colors */
|
||||||
|
.step-idle { background: #eceff1; color: #546e7a; border-color: #90a4ae; }
|
||||||
|
.step-user { background: #e8eaf6; color: #3949ab; border-color: #7986cb; }
|
||||||
|
.step-running { background: #e3f2fd; color: #1565c0; border-color: #42a5f5; }
|
||||||
|
.step-wait { background: #fff8e1; color: #e65100; border-color: #ffa726; }
|
||||||
|
.step-encode { background: #f3e5f5; color: #6a1b9a; border-color: #ab47bc; }
|
||||||
|
.step-done { background: #e8f5e9; color: #2e7d32; border-color: #66bb6a; }
|
||||||
|
.step-error { background: #ffebee; color: #c62828; border-color: #ef5350; }
|
||||||
|
|
||||||
|
/* Table improvements */
|
||||||
|
.md-typeset table:not([class]) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-typeset table:not([class]) th {
|
||||||
|
background-color: var(--md-primary-fg-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admonition tweaks */
|
||||||
|
.md-typeset .admonition.tip {
|
||||||
|
border-color: #00897b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-typeset .admonition.tip > .admonition-title {
|
||||||
|
background-color: rgba(0, 137, 123, 0.1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# HandBrake
|
||||||
|
|
||||||
|
Ripster verwendet `HandBrakeCLI` für Scan und Encode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwendete Aufrufe
|
||||||
|
|
||||||
|
### Scan (Review-Aufbau)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI --scan --json -i <input> -t 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encode (vereinfacht)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI \
|
||||||
|
-i <input> \
|
||||||
|
-o <output> \
|
||||||
|
-t <titleId> \
|
||||||
|
-Z "<preset>" \
|
||||||
|
<extra-args> \
|
||||||
|
-a <audioTrackIds|none> \
|
||||||
|
-s <subtitleTrackIds|none>
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional ergänzt Ripster:
|
||||||
|
|
||||||
|
- `--subtitle-burned=<id>`
|
||||||
|
- `--subtitle-default=<id>`
|
||||||
|
- `--subtitle-forced=<id>` oder `--subtitle-forced`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presets auslesen
|
||||||
|
|
||||||
|
Ripster liest Presets mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI -z
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevante Felder in `Settings`
|
||||||
|
|
||||||
|
| Feldname in der GUI | Bedeutung |
|
||||||
|
|-----|-----------|
|
||||||
|
| `HandBrake Kommando` | CLI-Binary |
|
||||||
|
| `HandBrake Preset` (Blu-ray/DVD) | profilspezifisches Preset |
|
||||||
|
| `HandBrake Extra Args` (Blu-ray/DVD) | profilspezifische Zusatzargumente |
|
||||||
|
| `Ausgabeformat` (Blu-ray/DVD) | Dateiendung der finalen Datei |
|
||||||
|
| `Encode-Neustart: unvollständige Ausgabe löschen` | unvollständige Ausgabe bei Neustart löschen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fortschritts-Parsing
|
||||||
|
|
||||||
|
Ripster parst HandBrake-Stderr (Prozent/ETA/Detail) und sendet WebSocket-Progress (`PIPELINE_PROGRESS`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Preset nicht gefunden: Preset-Namen mit `HandBrakeCLI -z` prüfen
|
||||||
|
- sehr langsames Encoding: Preset/Extra-Args prüfen (z. B. `--encoder-preset`)
|
||||||
|
|
||||||
|
Das Produktions-Installer-Script `install.sh` bietet eine Option zur Installation eines gebündelten HandBrakeCLI-Binaries mit NVDEC-Unterstützung (NVIDIA GPU-Dekodierung). Diese Option erscheint interaktiv während der Installation.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Anhang: Externe Tools
|
||||||
|
|
||||||
|
Ripster orchestriert externe CLI-Tools. Dieser Abschnitt erklärt deren Rolle im Gesamtsystem.
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-disc: **MakeMKV**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Disc-Analyse und Ripping.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: MakeMKV](makemkv.md)
|
||||||
|
|
||||||
|
- :material-film: **HandBrake**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Video-Encoding inklusive Preset-Logik.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: HandBrake](handbrake.md)
|
||||||
|
|
||||||
|
- :material-information: **MediaInfo**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Track-/Containeranalyse für Review und Auswahl.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: MediaInfo](mediainfo.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# MakeMKV
|
||||||
|
|
||||||
|
Ripster nutzt `makemkvcon` für Disc-Analyse und Rip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwendete Aufrufe
|
||||||
|
|
||||||
|
### Analyse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon -r info <source>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<source>` ist typischerweise:
|
||||||
|
|
||||||
|
- `disc:<index>` (Auto-Modus)
|
||||||
|
- `dev:/dev/sr0` (explicit)
|
||||||
|
- `file:<path>` (Datei/Ordner-Analyse)
|
||||||
|
|
||||||
|
### Rip (MKV-Modus)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon mkv <source> <title-or-all> <rawDir> [--minlength=...] [...extraArgs]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rip (Backup-Modus)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon backup <source> <rawDir> --decrypt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registrierungsschlüssel (optional)
|
||||||
|
|
||||||
|
Wenn in `Settings` ein `MakeMKV Key` gesetzt ist, führt Ripster vor Analyse/Rip aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon reg <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevante Felder in `Settings`
|
||||||
|
|
||||||
|
| Feldname in der GUI | Bedeutung |
|
||||||
|
|-----|-----------|
|
||||||
|
| `MakeMKV Kommando` | CLI-Binary |
|
||||||
|
| `MakeMKV Source Index` | Source-Index im Auto-Modus |
|
||||||
|
| `Minimale Titellaenge (Minuten)` | Mindestlaufzeitfilter |
|
||||||
|
| `MakeMKV Rip Modus` (Blu-ray/DVD) | `mkv` oder `backup` |
|
||||||
|
| `MakeMKV Analyze Extra Args` (Blu-ray/DVD) | Zusatzargumente für Analyse |
|
||||||
|
| `MakeMKV Rip Extra Args` (Blu-ray/DVD) | Zusatzargumente für Rip |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Blu-ray-Backups werden oft für robuste Playlist-Analyse genutzt.
|
||||||
|
- MakeMKV-Ausgaben werden geparst und als `makemkvInfo` im Job gespeichert.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# MediaInfo
|
||||||
|
|
||||||
|
Ripster nutzt `mediainfo` zur JSON-Analyse von Medien-Dateien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aufruf
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mediainfo --Output=JSON <input>
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Input ist typischerweise eine RAW-Datei oder ein vom Workflow gewählter Inputpfad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwendung in Ripster
|
||||||
|
|
||||||
|
- Track-/Codec-Metadaten für Review-Plan
|
||||||
|
- Fallback-Informationen in bestimmten Analysepfaden
|
||||||
|
- Persistenz als `mediainfoInfo` im Job
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevante Settings
|
||||||
|
|
||||||
|
| Key | Bedeutung |
|
||||||
|
|-----|-----------|
|
||||||
|
| `mediainfo_command` | CLI-Binary |
|
||||||
|
| `mediainfo_extra_args_bluray` / `_dvd` | profilspezifische Zusatzargumente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- JSON-Test: `mediainfo --Output=JSON <datei>`
|
||||||
|
- unbekannte Sprache erscheint oft als `und` (undetermined)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Workflows aus Nutzersicht
|
||||||
|
|
||||||
|
Diese Seite beschreibt typische Abläufe mit den passenden UI-Aktionen.
|
||||||
|
|
||||||
|
## Workflow 1: Standardlauf (Disc -> fertige Datei)
|
||||||
|
|
||||||
|
1. `Dashboard`: Disc einlegen, `Analyse starten`
|
||||||
|
2. Metadaten im Dialog übernehmen
|
||||||
|
3. bei `Bereit zum Encodieren` Titel/Tracks prüfen
|
||||||
|
4. `Encoding starten`
|
||||||
|
5. Ergebnis in `Historie` kontrollieren
|
||||||
|
|
||||||
|
## Workflow 2: Playlist-Entscheidung bei Blu-ray
|
||||||
|
|
||||||
|
1. Job landet in `Warte auf Auswahl`
|
||||||
|
2. im `Pipeline-Status` Playlist-Kandidaten vergleichen
|
||||||
|
3. gewünschte Playlist auswählen
|
||||||
|
4. `Playlist übernehmen`
|
||||||
|
5. danach normal weiter bis `Bereit zum Encodieren`
|
||||||
|
|
||||||
|
## Workflow 3: Mehrere Jobs mit Queue
|
||||||
|
|
||||||
|
1. Parallel-Limit in `Settings` über `Parallele Jobs` setzen
|
||||||
|
2. neue Jobs starten; überschüssige Starts gehen in `Job Queue`
|
||||||
|
3. Reihenfolge per Drag-and-Drop anpassen
|
||||||
|
4. bei Bedarf Skript/Kette/Warten als Queue-Eintrag ergänzen
|
||||||
|
|
||||||
|
## Workflow 4: Nachbearbeitung eines bestehenden Jobs
|
||||||
|
|
||||||
|
In `Historie` -> Detaildialog:
|
||||||
|
|
||||||
|
- Metadaten korrigieren: `OMDb neu zuordnen`
|
||||||
|
- gleiche Einstellungen erneut nutzen: `Encode neu starten`
|
||||||
|
- Analyse neu aufbauen: `Review neu starten`
|
||||||
|
- aus RAW erneut encodieren: `RAW neu encodieren`
|
||||||
|
|
||||||
|
## Workflow 5: Automatisierung mit Skripten und Cron
|
||||||
|
|
||||||
|
1. `Settings` -> `Scripte`: Skripte anlegen und testen
|
||||||
|
2. `Settings` -> `Skriptketten`: Ketten bauen und testen
|
||||||
|
3. im Dashboard-Review Pre-/Post-Ausführungen pro Job auswählen
|
||||||
|
4. `Settings` -> `Cronjobs`: zeitgesteuerte Ausführung konfigurieren
|
||||||
|
5. Status im Dashboard (`Skript- / Cron-Status`) überwachen
|
||||||
|
|
||||||
|
## Workflow 6: Abbruch und Recovery
|
||||||
|
|
||||||
|
### Fall A: Job wurde abgebrochen
|
||||||
|
|
||||||
|
- im Dashboard optional erzeugte RAW/Movie-Datei bereinigen
|
||||||
|
- anschließend je nach Ziel: `Retry Rippen` oder `Disk-Analyse neu starten`
|
||||||
|
|
||||||
|
### Fall B: Job steht in `Bereit zum Encodieren`, ist aber nicht aktive Session
|
||||||
|
|
||||||
|
- in `Historie` oder `Database`: `Im Dashboard öffnen`
|
||||||
|
- im Dashboard Review erneut prüfen und starten
|
||||||
|
|
||||||
|
### Fall C: RAW ohne Historieneintrag
|
||||||
|
|
||||||
|
- `/database` öffnen
|
||||||
|
- Bereich `RAW ohne Historie`
|
||||||
|
- `Job anlegen`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Optional: komplett explizite API-Basis (sonst /api via Vite-Proxy)
|
||||||
|
# VITE_API_BASE=http://10.10.10.24:3001/api
|
||||||
|
|
||||||
|
# Optional: expliziter WS-Endpunkt (sonst ws(s)://<host>/ws)
|
||||||
|
# VITE_WS_URL=ws://10.10.10.24:3001/ws
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ripster</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1713
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "ripster-frontend",
|
||||||
|
"version": "0.9.1-6",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primereact": "^10.9.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^5.4.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { api } from './api/client';
|
||||||
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
|
import DashboardPage from './pages/DashboardPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import HistoryPage from './pages/HistoryPage';
|
||||||
|
import DatabasePage from './pages/DatabasePage';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const appVersion = __APP_VERSION__;
|
||||||
|
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||||
|
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
||||||
|
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const refreshPipeline = async () => {
|
||||||
|
const response = await api.getPipelineState();
|
||||||
|
setPipeline(response.pipeline);
|
||||||
|
setHardwareMonitoring(response?.hardwareMonitoring || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPipeline().catch(() => null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
onMessage: (message) => {
|
||||||
|
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
||||||
|
setPipeline(message.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'PIPELINE_PROGRESS') {
|
||||||
|
const payload = message.payload;
|
||||||
|
const progressJobId = payload?.activeJobId;
|
||||||
|
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
|
||||||
|
? payload.contextPatch
|
||||||
|
: null;
|
||||||
|
setPipeline((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
// Update per-job progress map so concurrent jobs don't overwrite each other.
|
||||||
|
if (progressJobId != null) {
|
||||||
|
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
||||||
|
const mergedJobContext = contextPatch
|
||||||
|
? {
|
||||||
|
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||||
|
? previousJobProgress.context
|
||||||
|
: {}),
|
||||||
|
...contextPatch
|
||||||
|
}
|
||||||
|
: (previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||||
|
? previousJobProgress.context
|
||||||
|
: undefined);
|
||||||
|
next.jobProgress = {
|
||||||
|
...(prev?.jobProgress || {}),
|
||||||
|
[progressJobId]: {
|
||||||
|
...previousJobProgress,
|
||||||
|
state: payload.state,
|
||||||
|
progress: payload.progress,
|
||||||
|
eta: payload.eta,
|
||||||
|
statusText: payload.statusText,
|
||||||
|
...(mergedJobContext !== undefined ? { context: mergedJobContext } : {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Update global snapshot fields only for the primary active job.
|
||||||
|
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
||||||
|
next.state = payload.state ?? prev?.state;
|
||||||
|
next.progress = payload.progress ?? prev?.progress;
|
||||||
|
next.eta = payload.eta ?? prev?.eta;
|
||||||
|
next.statusText = payload.statusText ?? prev?.statusText;
|
||||||
|
if (contextPatch) {
|
||||||
|
next.context = {
|
||||||
|
...(prev?.context && typeof prev.context === 'object' ? prev.context : {}),
|
||||||
|
...contextPatch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'PIPELINE_QUEUE_CHANGED') {
|
||||||
|
setPipeline((prev) => ({
|
||||||
|
...(prev || {}),
|
||||||
|
queue: message.payload || null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISC_DETECTED') {
|
||||||
|
setLastDiscEvent(message.payload?.device || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISC_REMOVED') {
|
||||||
|
setLastDiscEvent(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
|
||||||
|
setHardwareMonitoring(message.payload || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ label: 'Dashboard', path: '/' },
|
||||||
|
{ label: 'Settings', path: '/settings' },
|
||||||
|
{ label: 'Historie', path: '/history' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="brand-block">
|
||||||
|
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||||
|
<div className="brand-copy">
|
||||||
|
<h1>Ripster</h1>
|
||||||
|
<div className="brand-meta">
|
||||||
|
<p>Disc Ripping Control Center</p>
|
||||||
|
<span className="app-version" aria-label={`Version ${appVersion}`}>
|
||||||
|
v{appVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="nav-buttons">
|
||||||
|
{nav.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.path}
|
||||||
|
label={item.label}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className={location.pathname === item.path ? 'nav-btn nav-btn-active' : 'nav-btn'}
|
||||||
|
outlined={location.pathname !== item.path}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<DashboardPage
|
||||||
|
pipeline={pipeline}
|
||||||
|
hardwareMonitoring={hardwareMonitoring}
|
||||||
|
lastDiscEvent={lastDiscEvent}
|
||||||
|
refreshPipeline={refreshPipeline}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
|
<Route path="/database" element={<DatabasePage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
const GET_RESPONSE_CACHE = new Map();
|
||||||
|
|
||||||
|
function invalidateCachedGet(prefixes = []) {
|
||||||
|
const list = Array.isArray(prefixes) ? prefixes.filter(Boolean) : [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
GET_RESPONSE_CACHE.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of GET_RESPONSE_CACHE.keys()) {
|
||||||
|
if (list.some((prefix) => key.startsWith(prefix))) {
|
||||||
|
GET_RESPONSE_CACHE.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCachedGet(path, ttlMs) {
|
||||||
|
const cacheKey = String(path || '');
|
||||||
|
const nextEntry = GET_RESPONSE_CACHE.get(cacheKey) || {
|
||||||
|
value: undefined,
|
||||||
|
expiresAt: 0,
|
||||||
|
promise: null
|
||||||
|
};
|
||||||
|
const nextPromise = request(path)
|
||||||
|
.then((payload) => {
|
||||||
|
GET_RESPONSE_CACHE.set(cacheKey, {
|
||||||
|
value: payload,
|
||||||
|
expiresAt: Date.now() + Math.max(1000, Number(ttlMs || 0)),
|
||||||
|
promise: null
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const current = GET_RESPONSE_CACHE.get(cacheKey);
|
||||||
|
if (current && current.promise === nextPromise) {
|
||||||
|
GET_RESPONSE_CACHE.set(cacheKey, {
|
||||||
|
value: current.value,
|
||||||
|
expiresAt: current.expiresAt || 0,
|
||||||
|
promise: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
GET_RESPONSE_CACHE.set(cacheKey, {
|
||||||
|
value: nextEntry.value,
|
||||||
|
expiresAt: nextEntry.expiresAt || 0,
|
||||||
|
promise: nextPromise
|
||||||
|
});
|
||||||
|
return nextPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCachedGet(path, options = {}) {
|
||||||
|
const ttlMs = Math.max(1000, Number(options?.ttlMs || 0));
|
||||||
|
const forceRefresh = Boolean(options?.forceRefresh);
|
||||||
|
const cacheKey = String(path || '');
|
||||||
|
const current = GET_RESPONSE_CACHE.get(cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!forceRefresh && current && current.value !== undefined) {
|
||||||
|
if (current.expiresAt > now) {
|
||||||
|
return current.value;
|
||||||
|
}
|
||||||
|
if (!current.promise) {
|
||||||
|
void refreshCachedGet(path, ttlMs);
|
||||||
|
}
|
||||||
|
return current.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && current?.promise) {
|
||||||
|
return current.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshCachedGet(path, ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterMutationInvalidate(prefixes = []) {
|
||||||
|
invalidateCachedGet(prefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {})
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorPayload = null;
|
||||||
|
let message = `HTTP ${response.status}`;
|
||||||
|
try {
|
||||||
|
errorPayload = await response.json();
|
||||||
|
message = errorPayload?.error?.message || message;
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = response.status;
|
||||||
|
error.details = errorPayload?.error?.details || null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getSettings(options = {}) {
|
||||||
|
return requestCachedGet('/settings', {
|
||||||
|
ttlMs: 5 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getEffectivePaths(options = {}) {
|
||||||
|
return requestCachedGet('/settings/effective-paths', {
|
||||||
|
ttlMs: 30 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getHandBrakePresets(options = {}) {
|
||||||
|
return requestCachedGet('/settings/handbrake-presets', {
|
||||||
|
ttlMs: 10 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getScripts(options = {}) {
|
||||||
|
return requestCachedGet('/settings/scripts', {
|
||||||
|
ttlMs: 2 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async createScript(payload = {}) {
|
||||||
|
const result = await request('/settings/scripts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async reorderScripts(orderedScriptIds = []) {
|
||||||
|
const result = await request('/settings/scripts/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : []
|
||||||
|
})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async updateScript(scriptId, payload = {}) {
|
||||||
|
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async deleteScript(scriptId) {
|
||||||
|
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
testScript(scriptId) {
|
||||||
|
return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getScriptChains(options = {}) {
|
||||||
|
return requestCachedGet('/settings/script-chains', {
|
||||||
|
ttlMs: 2 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async createScriptChain(payload = {}) {
|
||||||
|
const result = await request('/settings/script-chains', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async reorderScriptChains(orderedChainIds = []) {
|
||||||
|
const result = await request('/settings/script-chains/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
orderedChainIds: Array.isArray(orderedChainIds) ? orderedChainIds : []
|
||||||
|
})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async updateScriptChain(chainId, payload = {}) {
|
||||||
|
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async deleteScriptChain(chainId) {
|
||||||
|
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
testScriptChain(chainId) {
|
||||||
|
return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async updateSetting(key, value) {
|
||||||
|
const result = await request(`/settings/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ value })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings', '/settings/handbrake-presets']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async updateSettingsBulk(settings) {
|
||||||
|
const result = await request('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ settings })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings', '/settings/handbrake-presets']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
testPushover(payload = {}) {
|
||||||
|
return request('/settings/pushover/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getPipelineState() {
|
||||||
|
return request('/pipeline/state');
|
||||||
|
},
|
||||||
|
getRuntimeActivities() {
|
||||||
|
return request('/runtime/activities');
|
||||||
|
},
|
||||||
|
cancelRuntimeActivity(activityId, payload = {}) {
|
||||||
|
return request(`/runtime/activities/${encodeURIComponent(activityId)}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
requestRuntimeNextStep(activityId, payload = {}) {
|
||||||
|
return request(`/runtime/activities/${encodeURIComponent(activityId)}/next-step`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearRuntimeRecentActivities() {
|
||||||
|
return request('/runtime/activities/clear-recent', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async analyzeDisc() {
|
||||||
|
const result = await request('/pipeline/analyze', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async rescanDisc() {
|
||||||
|
const result = await request('/pipeline/rescan-disc', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
searchOmdb(q) {
|
||||||
|
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
||||||
|
},
|
||||||
|
searchMusicBrainz(q) {
|
||||||
|
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
|
||||||
|
},
|
||||||
|
getMusicBrainzRelease(mbId) {
|
||||||
|
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
|
||||||
|
},
|
||||||
|
async selectCdMetadata(payload) {
|
||||||
|
const result = await request('/pipeline/cd/select-metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async startCdRip(jobId, ripConfig) {
|
||||||
|
const result = await request(`/pipeline/cd/start/${jobId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(ripConfig || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async selectMetadata(payload) {
|
||||||
|
const result = await request('/pipeline/select-metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async startJob(jobId) {
|
||||||
|
const result = await request(`/pipeline/start/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async confirmEncodeReview(jobId, payload = {}) {
|
||||||
|
const result = await request(`/pipeline/confirm-encode/${jobId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async cancelPipeline(jobId = null) {
|
||||||
|
const result = await request('/pipeline/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ jobId })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async retryJob(jobId) {
|
||||||
|
const result = await request(`/pipeline/retry/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async resumeReadyJob(jobId) {
|
||||||
|
const result = await request(`/pipeline/resume-ready/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async reencodeJob(jobId) {
|
||||||
|
const result = await request(`/pipeline/reencode/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async restartReviewFromRaw(jobId) {
|
||||||
|
const result = await request(`/pipeline/restart-review/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async restartEncodeWithLastSettings(jobId) {
|
||||||
|
const result = await request(`/pipeline/restart-encode/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
getPipelineQueue() {
|
||||||
|
return request('/pipeline/queue');
|
||||||
|
},
|
||||||
|
async reorderPipelineQueue(orderedEntryIds = []) {
|
||||||
|
const result = await request('/pipeline/queue/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ orderedEntryIds: Array.isArray(orderedEntryIds) ? orderedEntryIds : [] })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async addQueueEntry(payload = {}) {
|
||||||
|
const result = await request('/pipeline/queue/entry', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async removeQueueEntry(entryId) {
|
||||||
|
const result = await request(`/pipeline/queue/entry/${encodeURIComponent(entryId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
getJobs(params = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
if (Array.isArray(params.statuses) && params.statuses.length > 0) {
|
||||||
|
query.set('statuses', params.statuses.join(','));
|
||||||
|
}
|
||||||
|
if (params.search) query.set('search', params.search);
|
||||||
|
if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) {
|
||||||
|
query.set('limit', String(Math.trunc(Number(params.limit))));
|
||||||
|
}
|
||||||
|
if (params.lite) {
|
||||||
|
query.set('lite', '1');
|
||||||
|
}
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
|
return request(`/history${suffix}`);
|
||||||
|
},
|
||||||
|
getDatabaseRows(params = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
if (params.search) query.set('search', params.search);
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
|
return request(`/history/database${suffix}`);
|
||||||
|
},
|
||||||
|
getOrphanRawFolders() {
|
||||||
|
return request('/history/orphan-raw');
|
||||||
|
},
|
||||||
|
async importOrphanRawFolder(rawPath) {
|
||||||
|
const result = await request('/history/orphan-raw/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ rawPath })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async assignJobOmdb(jobId, payload = {}) {
|
||||||
|
const result = await request(`/history/${jobId}/omdb/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async assignJobCdMetadata(jobId, payload = {}) {
|
||||||
|
const result = await request(`/history/${jobId}/cd/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async deleteJobFiles(jobId, target = 'both') {
|
||||||
|
const result = await request(`/history/${jobId}/delete-files`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ target })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
getJobDeletePreview(jobId, options = {}) {
|
||||||
|
const includeRelated = options?.includeRelated !== false;
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set('includeRelated', includeRelated ? '1' : '0');
|
||||||
|
return request(`/history/${jobId}/delete-preview?${query.toString()}`);
|
||||||
|
},
|
||||||
|
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
||||||
|
const includeRelated = Boolean(options?.includeRelated);
|
||||||
|
const result = await request(`/history/${jobId}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ target, includeRelated })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
getJob(jobId, options = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
const includeLiveLog = Boolean(options.includeLiveLog);
|
||||||
|
const includeLogs = Boolean(options.includeLogs);
|
||||||
|
const includeAllLogs = Boolean(options.includeAllLogs);
|
||||||
|
if (options.includeLiveLog) {
|
||||||
|
query.set('includeLiveLog', '1');
|
||||||
|
}
|
||||||
|
if (options.includeLogs) {
|
||||||
|
query.set('includeLogs', '1');
|
||||||
|
}
|
||||||
|
if (options.includeAllLogs) {
|
||||||
|
query.set('includeAllLogs', '1');
|
||||||
|
}
|
||||||
|
if (Number.isFinite(Number(options.logTailLines)) && Number(options.logTailLines) > 0) {
|
||||||
|
query.set('logTailLines', String(Math.trunc(Number(options.logTailLines))));
|
||||||
|
}
|
||||||
|
if (options.lite) {
|
||||||
|
query.set('lite', '1');
|
||||||
|
}
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
|
const path = `/history/${jobId}${suffix}`;
|
||||||
|
const canUseCache = !includeLiveLog && !includeLogs && !includeAllLogs;
|
||||||
|
if (!canUseCache) {
|
||||||
|
return request(path);
|
||||||
|
}
|
||||||
|
return requestCachedGet(path, {
|
||||||
|
ttlMs: 8000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── User Presets ───────────────────────────────────────────────────────────
|
||||||
|
getUserPresets(mediaType = null, options = {}) {
|
||||||
|
const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : '';
|
||||||
|
return requestCachedGet(`/settings/user-presets${suffix}`, {
|
||||||
|
ttlMs: 2 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async createUserPreset(payload = {}) {
|
||||||
|
const result = await request('/settings/user-presets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/user-presets']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async updateUserPreset(id, payload = {}) {
|
||||||
|
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/user-presets']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async deleteUserPreset(id) {
|
||||||
|
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/user-presets']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Cron Jobs ──────────────────────────────────────────────────────────────
|
||||||
|
getCronJobs() {
|
||||||
|
return request('/crons');
|
||||||
|
},
|
||||||
|
getCronJob(id) {
|
||||||
|
return request(`/crons/${encodeURIComponent(id)}`);
|
||||||
|
},
|
||||||
|
createCronJob(payload = {}) {
|
||||||
|
return request('/crons', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateCronJob(id, payload = {}) {
|
||||||
|
return request(`/crons/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteCronJob(id) {
|
||||||
|
return request(`/crons/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getCronJobLogs(id, limit = 20) {
|
||||||
|
return request(`/crons/${encodeURIComponent(id)}/logs?limit=${limit}`);
|
||||||
|
},
|
||||||
|
runCronJobNow(id) {
|
||||||
|
return request(`/crons/${encodeURIComponent(id)}/run`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validateCronExpression(cronExpression) {
|
||||||
|
return request('/crons/validate-expression', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ cronExpression })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { API_BASE };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user