diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..9776943 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,159 @@ +name: Deploy Docs to GitHub Pages + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + branches: + - main + - dev + paths: + - 'docs/**' + - 'mkdocs.yml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + name: Build Documentation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Für git-dates Plugin + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Cache pip packages + uses: actions/cache@v4 + with: + key: mkdocs-material-${{ runner.os }}-${{ hashFiles('requirements-docs.txt') }} + path: ~/.cache/pip + restore-keys: | + mkdocs-material-${{ runner.os }}- + + - name: Install MkDocs and dependencies + run: pip install -r requirements-docs.txt + + - name: Build MkDocs site (main -> /, dev -> /dev) + env: + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + SITE_ROOT="${GITHUB_WORKSPACE:-$PWD}/site" + WORKTREE_ROOT="${RUNNER_TEMP:-$PWD}/mkdocs-worktrees" + + has_ref() { + git rev-parse --verify --quiet "$1" >/dev/null + } + + # Fetch refs separately (robust when one branch is not yet mirrored to GitHub). + git fetch origin main || true + git fetch origin dev || true + + MAIN_REF="origin/main" + DEV_REF="origin/dev" + if [ "${REF_NAME}" = "main" ]; then + MAIN_REF="${SHA}" + fi + if [ "${REF_NAME}" = "dev" ]; then + DEV_REF="${SHA}" + fi + + if ! has_ref "${MAIN_REF}"; then + echo "::error::Konnte Main-Ref '${MAIN_REF}' nicht auflösen. Main-Doku kann nicht gebaut werden." + exit 1 + fi + if ! has_ref "${DEV_REF}"; then + echo "::warning::Dev-Ref '${DEV_REF}' nicht gefunden. Es wird ein Platzhalter unter /dev erzeugt." + DEV_REF="" + fi + + # Build each branch in its own worktree and merge into one Pages artifact: + # - main docs at site/ + # - dev docs at site/dev/ + rm -rf "${WORKTREE_ROOT}" "${SITE_ROOT}" + mkdir -p "${WORKTREE_ROOT}" + mkdir -p "${SITE_ROOT}" + git worktree add --detach "${WORKTREE_ROOT}/main" "${MAIN_REF}" + + if ! mkdocs build --strict --verbose \ + --config-file "${WORKTREE_ROOT}/main/mkdocs.yml" \ + --site-dir "${SITE_ROOT}"; then + echo "::error::Main-Dokumentation konnte nicht gebaut werden." + exit 1 + fi + + if [ -n "${DEV_REF}" ]; then + git worktree add --detach "${WORKTREE_ROOT}/dev" "${DEV_REF}" + if ! mkdocs build --strict --verbose \ + --config-file "${WORKTREE_ROOT}/dev/mkdocs.yml" \ + --site-dir "${SITE_ROOT}/dev"; then + echo "::warning::Dev-Dokumentation konnte nicht gebaut werden. Es wird ein Platzhalter unter /dev erzeugt." + rm -rf "${SITE_ROOT}/dev" + mkdir -p "${SITE_ROOT}/dev" + cat > "${SITE_ROOT}/dev/index.html" <<'EOF' + + + Dev Doku +

Dev-Dokumentation ist aktuell auf GitHub noch nicht verfügbar.

+ + EOF + fi + else + mkdir -p "${SITE_ROOT}/dev" + cat > "${SITE_ROOT}/dev/index.html" <<'EOF' + + + Dev Doku +

Dev-Dokumentation ist aktuell auf GitHub noch nicht verfügbar.

+ + EOF + fi + + - name: Verify Pages artifact + run: | + set -euo pipefail + SITE_ROOT="${GITHUB_WORKSPACE:-$PWD}/site" + if [ ! -d "${SITE_ROOT}" ]; then + echo "::error::site/ wurde nicht erzeugt." + ls -la + exit 1 + fi + if [ ! -f "${SITE_ROOT}/index.html" ]; then + echo "::error::site/index.html fehlt." + find "${SITE_ROOT}" -maxdepth 3 -type f | head -n 50 || true + exit 1 + fi + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ${{ github.workspace }}/site + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67ad41e --- /dev/null +++ b/.gitignore @@ -0,0 +1,85 @@ +# ---------------------------- +# Dependencies +# ---------------------------- +node_modules/ +backend/node_modules/ +frontend/node_modules/ + +# ---------------------------- +# Build artifacts / caches +# ---------------------------- +frontend/dist/ +frontend/.vite/ +backend/dist/ +dist/ +build/ +.cache/ +coverage/ +*.tsbuildinfo + +# ---------------------------- +# Runtime state / PIDs / temp +# ---------------------------- +start.pid +*.pid +*.pid.lock +tmp/ +temp/ +*.tmp +*.swp +*.swo +*~ + +# ---------------------------- +# Databases and generated data +# ---------------------------- +backend/data/ +!backend/data/.gitkeep +*.db +*.db-wal +*.db-shm + +# ---------------------------- +# Logs/Debug +# ---------------------------- +backend/logs/ +logs/ +*.log +job*-hbscan.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +debug/ +DATABASE_BLA +# ---------------------------- +# Env / secrets (keep examples) +# ---------------------------- +.env +.env.* +backend/.env +backend/.env.* +frontend/.env +frontend/.env.* +!.env.example +!.env.sample +!backend/.env.example +!backend/.env.sample +!frontend/.env.example +!frontend/.env.sample + +# ---------------------------- +# IDE / OS files +# ---------------------------- +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# ---------------------------- +# Scripts +# ---------------------------- +/scripts/ +/release.sh +/Audible_Tool +/AddOns \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..5bd6811 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19.0 diff --git a/.venv-docs/bin/python b/.venv-docs/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/.venv-docs/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv-docs/bin/python3 b/.venv-docs/bin/python3 new file mode 120000 index 0000000..ae65fda --- /dev/null +++ b/.venv-docs/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/.venv-docs/bin/python3.12 b/.venv-docs/bin/python3.12 new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/.venv-docs/bin/python3.12 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv-docs/lib64 b/.venv-docs/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/.venv-docs/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/.venv-docs/pyvenv.cfg b/.venv-docs/pyvenv.cfg new file mode 100644 index 0000000..bb26a71 --- /dev/null +++ b/.venv-docs/pyvenv.cfg @@ -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 diff --git a/.worktrees/main b/.worktrees/main new file mode 160000 index 0000000..190f6fe --- /dev/null +++ b/.worktrees/main @@ -0,0 +1 @@ +Subproject commit 190f6fe1a5287a5c2b299f42e648290f274f1fd8 diff --git a/README.md b/README.md index c56094a..3f67f8a 100644 --- a/README.md +++ b/README.md @@ -1 +1,209 @@ -# ripster +# Ripster + +![Ripster Logo](frontend/public/logo.png) + +Ripster ist eine lokale Web-Anwendung für Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Das System kombiniert MakeMKV, HandBrake, FFmpeg, `cdparanoia`, `mkvmerge`, SQLite und eine React-Oberfläche zu einer durchgehenden Pipeline mit Queue, Historie, Downloads, Skript-Automation und Echtzeitstatus per WebSocket. + +## ⚠️ Rechtlicher Hinweis + +Ripster ist ausschließlich für die Sicherung von Titeln gedacht, die dir selbst gehören oder für die du die erforderlichen Nutzungsrechte besitzt. Bitte prüfe vor der Nutzung die in deinem Land geltenden Urheberrechts-, Privatkopie- und Kopierschutzregelungen. Die Software ist nicht für Piraterie oder die Verarbeitung unberechtigt beschaffter Inhalte gedacht. + +## 🎬 Funktionsumfang + +### 🎞️ Medien-Workflows +- Blu-ray-Ripping und -Encoding +- DVD-Ripping und -Encoding +- Audio-CD-Ripping und -Encoding +- Audiobook-Verarbeitung aus `.aax` +- Datei-Converter für Audio-, Video- und ISO-Dateien + +### 📀 Video (Blu-ray / DVD) +- automatische Disc-Erkennung, Rescan einzelner Laufwerke und Live-Status im Ripper +- Metadaten-Suche über TMDB für Filme und Serien +- HandBrake-Review mit Playlist-/Titel-Auswahl, Audio-/Untertitel-Track-Auswahl und Encode-Vorschau +- MakeMKV-Rip mit profilspezifischen Modi und Zusatzargumenten +- HandBrake-Encoding mit User-Presets, offiziellen HandBrake-Presets und Extra-Args +- Serien-Workflows für DVD- und Blu-ray-Discs inkl. Episoden-Zuordnung und Batch-Encoding +- Multipart-Movie-Workflows über mehrere Discs inkl. Merge-Job via `mkvmerge` +- Re-Encode aus RAW, Review-Neustart, Encode-Neustart, Retry und Resume laufender/unterbrochener Jobs + +### 🎵 Audio-CD +- TOC-Analyse und Rip mit `cdparanoia` +- MusicBrainz-Suche und Übernahme von Album-/Track-Metadaten +- Track-Auswahl und Ausgabe als FLAC, WAV, MP3, Opus oder Ogg Vorbis +- RAW-Wiederverwendung und CD-Review-/Encode-Neustart aus vorhandenen Daten + +### 🎧 Audiobooks + +Diese Funktion ist für eine Archivierung von gekauften Titeln. Das System ermittelt keine Activation-Bytes und verweist dafür auf eine externe Seite + +- Upload und Verarbeitung von Audible-/AAX-Dateien +- Metadaten- und Kapitelanreicherung über Audnex plus lokale Probe-Daten +- Ausgabe als M4B, MP3 oder FLAC +- Einzeldatei oder kapitelweises Splitten + +### 🔄 Converter (Beta) +- Dateibaum und Datei-Explorer für das Converter-RAW-Verzeichnis +- Upload, Ordner anlegen, Umbenennen, Verschieben und Löschen +- Jobs aus Dateiauswahl erzeugen, Dateien bestehenden Jobs zuweisen oder daraus entfernen +- Audio-/Video-Erkennung und automatische RAW-Scans per Polling +- TMDB-Metadatenzuordnung für passende Video-Jobs + +Der Converter ist noch nicht vollständig entwickelt und auch noch nicht vollständig geprüft. Er kann verwendet werden, es funktionieren aber ggf. nicht alle Funktionen vollständig! + +### 🛠️ Automation, Betrieb und Verwaltung +- Queue mit normalen Jobs sowie zusätzlichen `script`-, `chain`- und `wait`-Einträgen +- Pre-/Post-Encode-Skripte und Skript-Ketten +- Cron-Jobs für Skripte und Ketten inkl. Validierung, Logs und manueller Auslösung +- Download-Queue für ZIP-Archive aus Historienjobs +- Historie mit Detailansicht, Re-Encode, Review-/Encode-Neustart, Retry und Löschfunktionen +- `/database`-Ansicht für Orphan-RAW-Ordner (Import oder Löschen vorhandener RAW-Daten) +- Hardware-Monitoring mit Live-Werten und Verlaufshistorie +- Pushover-Benachrichtigungen für zentrale Pipeline-Ereignisse +- MakeMKV-Betakey-Prüfung/-Übernahme und Cover-Art-Recovery + +## 🖥️ Oberfläche + +- `Ripper`: Disc-Erkennung, Pipeline-Status, Queue, Review-Dialoge und aktive Jobs +- `Converter`: Dateibaum, Uploads und Converter-Jobs +- `Audiobooks`: AAX-Uploads und Audiobook-Jobs +- `Settings`: Pfade, Tools, Templates, Queue, Monitoring, Notifications, Scripts, Chains, Presets +- `Historie`: abgeschlossene und fehlerhafte Jobs mit Folgeaktionen +- `Downloads`: vorbereitete ZIP-Downloads +- `Database`: Orphan-RAW-Verwaltung (im Expertenmodus) +- Zusatzansichten: `Hardware` und `TMDB Migration` + +## 🚀 Installation + +Der korrekte Bootstrap läuft über `setup.sh`. `setup.sh` lädt das passende `install.sh` aus dem gewünschten Branch und startet es mit denselben Parametern. + +Unterstützte Systeme laut Installer: +- Debian +- Ubuntu + +Standardinstallation: + +```bash +wget -qO setup.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh +bash setup.sh +``` + +Es gibt 2 Branches: main und dev + +main enthält das aktuelle stbale release +dev enthält den aktuellen Stand der Weiterentwicklung + +Hinweise: +- `setup.sh` nutzt bei Bedarf `sudo`; root-Rechte und Internetzugang sind erforderlich. +- Ohne `--branch` bietet `setup.sh` lokal eine Branch-Auswahl an. +- Der eigentliche Installer ist `install.sh`; `setup.sh` ist der empfohlene Einstiegspunkt. + +Wichtige Standardparameter für den Installationslauf: + +Verfügbare Optionen: +- `--branch ` +- `--dir ` +- `--user ` +- `--port ` +- `--host ` +- `--no-makemkv` +- `--no-handbrake` +- `--no-nginx` +- `--no-system-deps` +- `--reinstall` +- `--help` + +Was der Installer typischerweise einrichtet: +- Node.js 20 +- `ffmpeg`, `ffprobe`, `mediainfo`, `mkvtoolnix` +- CD-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`) +- optional MakeMKV +- optional HandBrakeCLI +- optional nginx +- Repository-Checkout bzw. Update +- npm-Abhängigkeiten, Frontend-Build und `ripster-backend`-systemd-Service + +## ♻️ Update + +Standard-Update einer bestehenden Installation: + +```bash +sudo bash Pfad_zur_Installation/install.sh --reinstall +``` + +Wenn die Installation mit abweichenden Kernparametern eingerichtet wurde, diese beim Update wieder mitgeben: + +```bash +sudo bash Pfad_zur_Installation/install.sh --reinstall --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10 +``` + +Alternativ kann auch erneut über `setup.sh` gebootstrapped werden: + +```bash +sudo bash Pfad_zur_Installation/setup.sh --reinstall +``` + +`--reinstall` aktualisiert die Installation und behält die persistenten Daten der bestehenden Instanz bei. + +## 🧪 Entwicklung + +Schnellstart für lokale Entwicklung: + +```bash +./start.sh +``` + +`start.sh` prüft Node.js, installiert fehlende Abhängigkeiten und startet Backend und Frontend im Dev-Modus. + +Wichtige npm-Skripte: +- `npm run dev` +- `npm run dev:backend` +- `npm run dev:frontend` +- `npm run build:frontend` +- `npm run start` + +## ⚙️ Konfiguration + +Die meisten Einstellungen werden in der Weboberfläche unter `Settings` gepflegt und in SQLite gespeichert. Dazu gehören insbesondere: +- Pfade und Output-Templates für Blu-ray, DVD, Serie, CD, Audiobook, Converter, Downloads und Logs +- Tool-Kommandos für MakeMKV, HandBrake, MediaInfo, FFmpeg, FFprobe, `cdparanoia` und `mkvmerge` +- Laufwerksmodus, Disc-Erkennung und Queue-/Parallelisierungsregeln +- Hardware-Monitoring, Logging, Expert Mode und Cover-Art-Recovery +- TMDB-Zugangsdaten, Pushover, Skripte, Skript-Ketten, User-Presets und Preset-Defaults + +Zusätzliche Bootstrap-/Override-Variablen: + +Backend: +- `PORT` +- `DB_PATH` +- `LOG_DIR` +- `LOG_LEVEL` +- `CORS_ORIGIN` +- `DEFAULT_TEMP_DIR` +- `DEFAULT_RAW_DIR` +- `DEFAULT_MOVIE_DIR` +- `DEFAULT_SERIES_DIR` +- `DEFAULT_CD_DIR` +- `DEFAULT_AUDIOBOOK_RAW_DIR` +- `DEFAULT_AUDIOBOOK_DIR` +- `DEFAULT_DOWNLOAD_DIR` +- `DEFAULT_CONVERTER_RAW_DIR` +- `DEFAULT_CONVERTER_MOVIE_DIR` +- `DEFAULT_CONVERTER_AUDIO_DIR` + +Frontend: +- `VITE_API_BASE` +- `VITE_WS_URL` + +## 🗂️ Daten, Logs und API + +- Standard-DB: `backend/data/ripster.db` +- Standard-Logs: `backend/logs` +- Standard-Outputs liegen relativ zum Datenverzeichnis, sofern in den Settings nichts anderes gesetzt ist. +- Das Schema wird beim Start geprüft und fehlende DB-Elemente werden migriert. + +## 📚 Dokumentation + +Ausführlichere Dokumentation liegt in `docs/` und veröffentlicht unter: + +https://mboehmlaender.github.io/ripster/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4e89a56 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,13 @@ +PORT=3001 +DB_PATH=./data/ripster.db +CORS_ORIGIN=http://localhost:5173 +LOG_DIR=./logs +LOG_LEVEL=debug + +# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt) +# Leer lassen = relativ zum data/-Verzeichnis der DB (data/output/raw etc.) +DEFAULT_RAW_DIR= +DEFAULT_MOVIE_DIR= +DEFAULT_SERIES_DIR= +DEFAULT_CD_DIR= +DEFAULT_DOWNLOAD_DIR= diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..8a71b14 --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,11 @@ +{ + "watch": [ + "src", + ".env" + ], + "ext": "js,json,env", + "ignore": [ + "data/**", + "logs/**" + ] +} diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..e88b5f3 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3526 @@ +{ + "name": "ripster-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ripster-backend", + "version": "1.0.0", + "dependencies": { + "archiver": "^7.0.1", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "multer": "^2.1.1", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7", + "ws": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "optional": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "optional": true + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "optional": true + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "optional": true + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "optional": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "optional": true + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "optional": true + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "optional": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "optional": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "optional": true + }, + "node_modules/sqlite": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz", + "integrity": "sha512-oBkezXa2hnkfuJwUo44Hl9hS3er+YFtueifoajrgidvqsJRQFpc5fKoAkAor1O5ZnLoa28GBScfHXs8j0K358Q==" + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..9a596cb --- /dev/null +++ b/backend/package.json @@ -0,0 +1,23 @@ +{ + "name": "ripster-backend", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "archiver": "^7.0.1", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "multer": "^2.1.1", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7", + "ws": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } +} diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..91c59a1 --- /dev/null +++ b/backend/src/config.js @@ -0,0 +1,35 @@ +const path = require('path'); + +const rootDir = path.resolve(__dirname, '..'); +const rawDbPath = process.env.DB_PATH || path.join(rootDir, 'data', 'ripster.db'); +const rawLogDir = process.env.LOG_DIR || path.join(rootDir, 'logs'); +const resolvedDbPath = path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath); +const dataDir = path.dirname(resolvedDbPath); + +function resolveOutputPath(envValue, ...subParts) { + const raw = String(envValue || '').trim(); + if (raw) { + return path.isAbsolute(raw) ? raw : path.resolve(rootDir, raw); + } + return path.join(dataDir, ...subParts); +} + +module.exports = { + port: process.env.PORT ? Number(process.env.PORT) : 3001, + dbPath: resolvedDbPath, + dataDir, + tempDir: resolveOutputPath(process.env.DEFAULT_TEMP_DIR, 'temp'), + corsOrigin: process.env.CORS_ORIGIN || '*', + logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir), + logLevel: process.env.LOG_LEVEL || 'info', + defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'), + defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'), + defaultSeriesDir: resolveOutputPath(process.env.DEFAULT_SERIES_DIR, 'output', 'series'), + defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'), + defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'), + defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'), + defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads'), + defaultConverterRawDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_RAW_DIR, 'output', 'converter-raw'), + defaultConverterMovieDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_MOVIE_DIR, 'output', 'converted-movies'), + defaultConverterAudioDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_AUDIO_DIR, 'output', 'converted-audio') +}; diff --git a/backend/src/db/database.js b/backend/src/db/database.js new file mode 100644 index 0000000..cfab3d2 --- /dev/null +++ b/backend/src/db/database.js @@ -0,0 +1,1560 @@ +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 migrateTmdbMigrationFlags(dbInstance); + await migrateSettingsSchemaMetadata(dbInstance); + await ensurePipelineStateRow(dbInstance); + await backfillJobOutputFolders(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', + 'pushover_notify_cron_success', + 'pushover_notify_cron_error', + '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', + 'output_extension_audiobook', + 'use_plugin_architecture', + 'use_plugin_architecture_bluray', + 'use_plugin_architecture_bluray_analyze', + 'use_plugin_architecture_bluray_rip', + 'use_plugin_architecture_bluray_review', + 'use_plugin_architecture_bluray_encode', + 'use_plugin_architecture_dvd', + 'use_plugin_architecture_dvd_analyze', + 'use_plugin_architecture_dvd_rip', + 'use_plugin_architecture_dvd_review', + 'use_plugin_architecture_dvd_encode', + 'use_plugin_architecture_cd', + 'use_plugin_architecture_cd_analyze', + 'use_plugin_architecture_cd_rip', + 'use_plugin_architecture_audiobook', + 'use_plugin_architecture_audiobook_analyze', + 'use_plugin_architecture_audiobook_encode', + 'dvd_series_plugin_enabled', + 'dvd_series_prescan_enabled', + 'dvd_series_reuse_disc_profiles', + 'musicbrainz_enabled' + ]; + 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'` + ); +} + +async function migrateTmdbMigrationFlags(db) { + // Keep this migration idempotent. It helps initialize the new migrate_tmdb flag + // for existing rows after OMDb -> TMDb transition. + const nullableReset = await db.run( + ` + UPDATE jobs + SET migrate_tmdb = 0 + WHERE migrate_tmdb IS NULL + ` + ); + + // Audio-only domains are outside TMDb migration scope. + const nonVideoMarked = await db.run( + ` + UPDATE jobs + SET migrate_tmdb = 1 + WHERE COALESCE(migrate_tmdb, 0) = 0 + AND LOWER(TRIM(COALESCE(media_type, ''))) IN ('cd', 'audiobook') + ` + ); + + // Rows with obvious TMDb assignment can be marked as migrated. + const obviousTmdbMarked = await db.run( + ` + UPDATE jobs + SET migrate_tmdb = 1 + WHERE COALESCE(migrate_tmdb, 0) = 0 + AND ( + makemkv_info_json LIKE '%"metadataProvider":"tmdb"%' + OR makemkv_info_json LIKE '%"tmdbId":%' + OR encode_plan_json LIKE '%"metadataProvider":"tmdb"%' + OR encode_plan_json LIKE '%"tmdbId":%' + ) + ` + ); + + // Explicit OMDb footprints must stay pending for manual migration. + const explicitOmdbPending = await db.run( + ` + UPDATE jobs + SET migrate_tmdb = 0 + WHERE ( + COALESCE(selected_from_omdb, 0) = 1 + OR (omdb_json IS NOT NULL AND TRIM(omdb_json) <> '') + OR makemkv_info_json LIKE '%"metadataProvider":"omdb"%' + OR encode_plan_json LIKE '%"metadataProvider":"omdb"%' + ) + ` + ); + + logger.info('migrate:tmdb-flag:done', { + nullableReset: Number(nullableReset?.changes || 0), + nonVideoMarked: Number(nonVideoMarked?.changes || 0), + obviousTmdbMarked: Number(obviousTmdbMarked?.changes || 0), + explicitOmdbPending: Number(explicitOmdbPending?.changes || 0) + }); +} + +// 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: 'makemkv_registration_key', + required: 0, + description: 'Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden.', + validation_json: '{}' + }, + { + 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: '{}' + }, + { + key: 'dvd_series_language', + required: 1, + description: 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.', + validation_json: '{"minLength":2}' + }, + { + key: 'dvd_series_fallback_languages', + required: 1, + description: 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.', + validation_json: '{"minLength":2}' + }, + { + key: 'output_template_bluray_series_episode', + required: 1, + description: 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', + validation_json: '{"minLength":1}' + }, + { + key: 'output_template_bluray_series_multi_episode', + required: 1, + description: 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', + validation_json: '{"minLength":1}' + }, + { + key: 'output_template_dvd_series_episode', + required: 1, + description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', + validation_json: '{"minLength":1}' + }, + { + key: 'output_template_dvd_series_multi_episode', + required: 1, + description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', + validation_json: '{"minLength":1}' + } +]; + +// 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_bluray_series_episode', category: 'Pfade' }, + { key: 'output_template_bluray_series_multi_episode', category: 'Pfade' }, + { key: 'output_template_dvd', category: 'Pfade' }, + { key: 'output_template_audiobook', category: 'Pfade' }, + { key: 'output_chapter_template_audiobook', category: 'Pfade' }, + { key: 'audiobook_raw_template', category: 'Pfade' }, + { key: 'converter_raw_dir', category: 'Pfade' }, + { key: 'converter_movie_dir', category: 'Pfade' }, + { key: 'converter_audio_dir', category: 'Pfade' }, + { key: 'converter_output_template_video', category: 'Pfade' }, + { key: 'converter_output_template_audio', category: 'Pfade' }, + { key: 'server_console_log_output_enabled', category: 'Logging' }, + { key: 'server_console_log_http_enabled', category: 'Logging' }, + { key: 'server_console_log_debug_enabled', category: 'Logging' }, + { key: 'server_console_log_info_enabled', category: 'Logging' }, + { key: 'server_console_log_warn_enabled', category: 'Logging' }, + { key: 'server_console_log_error_enabled', category: 'Logging' } +]; + +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 + }); + } + + const externalStorageDescription = 'Wird links unter "Freie Speicher" angezeigt.'; + const externalStorageDescResult = await db.run( + `UPDATE settings_schema + SET description = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'external_storage_paths' AND description != ?`, + [externalStorageDescription, externalStorageDescription] + ); + if (externalStorageDescResult?.changes > 0) { + logger.info('migrate:settings-schema-external-storage-description-updated', { + key: 'external_storage_paths' + }); + } + + const metadataReadyNotifyLabel = 'Bei manueller Auswahl senden'; + const metadataReadyNotifyDescription = 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).'; + const metadataReadyNotifyResult = await db.run( + `UPDATE settings_schema + SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'pushover_notify_metadata_ready' AND (label != ? OR description != ?)`, + [ + metadataReadyNotifyLabel, + metadataReadyNotifyDescription, + metadataReadyNotifyLabel, + metadataReadyNotifyDescription + ] + ); + if (metadataReadyNotifyResult?.changes > 0) { + logger.info('migrate:settings-schema-metadata-ready-notify-updated', { + key: 'pushover_notify_metadata_ready', + label: metadataReadyNotifyLabel + }); + } + + const metadataLanguageLabel = 'Film-Sprache'; + const metadataLanguageDescription = 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.'; + const metadataLanguageResult = await db.run( + `UPDATE settings_schema + SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'dvd_series_language' AND (label != ? OR description != ?)`, + [metadataLanguageLabel, metadataLanguageDescription, metadataLanguageLabel, metadataLanguageDescription] + ); + if (metadataLanguageResult?.changes > 0) { + logger.info('migrate:settings-schema-language-updated', { + key: 'dvd_series_language', + label: metadataLanguageLabel + }); + } + + const metadataFallbackLanguageLabel = 'Fallback Sprache'; + const metadataFallbackLanguageDescription = 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.'; + const metadataFallbackLanguageResult = await db.run( + `UPDATE settings_schema + SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'dvd_series_fallback_languages' AND (label != ? OR description != ?)`, + [ + metadataFallbackLanguageLabel, + metadataFallbackLanguageDescription, + metadataFallbackLanguageLabel, + metadataFallbackLanguageDescription + ] + ); + if (metadataFallbackLanguageResult?.changes > 0) { + logger.info('migrate:settings-schema-fallback-language-updated', { + key: 'dvd_series_fallback_languages', + label: metadataFallbackLanguageLabel + }); + } + + const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; + const legacyDvdSeriesMultiEpisodeTemplateDefaults = [ + '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}' + ]; + const legacyTemplatePlaceholders = legacyDvdSeriesMultiEpisodeTemplateDefaults.map(() => '?').join(', '); + await db.run( + ` + UPDATE settings_schema + SET default_value = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'output_template_dvd_series_multi_episode' + AND ( + default_value IS NULL + OR TRIM(default_value) = '' + OR default_value IN (${legacyTemplatePlaceholders}) + ) + `, + [dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults] + ); + await db.run( + ` + INSERT INTO settings_values (key, value, updated_at) + VALUES ('output_template_dvd_series_multi_episode', ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO NOTHING + `, + [dvdSeriesMultiEpisodeTemplateDefault] + ); + await db.run( + ` + UPDATE settings_values + SET value = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'output_template_dvd_series_multi_episode' + AND ( + value IS NULL + OR TRIM(value) = '' + OR value IN (${legacyTemplatePlaceholders}) + ) + `, + [dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults] + ); + + // 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'` + ); + + // depends_on-Spalte zu settings_schema hinzufügen, bevor neue abhängige + // Settings per INSERT OR IGNORE angelegt werden. + { + const cols = await db.all(`PRAGMA table_info(settings_schema)`); + if (!cols.some((c) => c.name === 'depends_on')) { + await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`); + logger.info('migrate:settings-schema-add-depends_on'); + } + } + + // Add movie_dir_cd if not already present + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`); + + // Add movie_dir_cd_owner if not already present + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('mkvmerge_command', 'Tools', 'mkvmerge Kommando', 'string', 1, 'Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt.', 'mkvmerge', '[]', '{"minLength":1}', 2325)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mkvmerge_command', 'mkvmerge')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0')`); + + await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`); + await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`); + await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_upper_pct'`); + await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_upper_pct'`); + + await db.run(`DELETE FROM settings_values WHERE key = 'handbrake_pre_metadata_scan_enabled'`); + await db.run(`DELETE FROM settings_schema WHERE key = 'handbrake_pre_metadata_scan_enabled'`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true')`); + + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_chapter_template_audiobook', 'Pfade', 'Kapitel Template (Audiobook)', 'string', 1, 'Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}', '[]', '{"minLength":1}', 7355)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_chapter_template_audiobook', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('series_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('series_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('dvd_series_language', 'Metadaten', 'Film-Sprache', 'string', 1, 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('dvd_series_fallback_languages', 'Metadaten', 'Fallback Sprache', 'string', 1, 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`); + + await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`); + await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`); + await db.run(`DELETE FROM settings_values WHERE key = 'dvd_series_order_preference'`); + await db.run(`DELETE FROM settings_schema WHERE key = 'dvd_series_order_preference'`); + + // Converter-Pfade und Eigentümer + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('converter_raw_dir', 'Pfade', 'Converter RAW-Ordner', 'path', 0, 'Quellordner für Converter-Eingabedateien. Leer = Standardpfad.', NULL, '[]', '{}', 106)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('converter_movie_dir', 'Pfade', 'Converter Video Output-Ordner', 'path', 0, 'Zielordner für konvertierte Video-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 116)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('converter_audio_dir', 'Pfade', 'Converter Audio Output-Ordner', 'path', 0, 'Zielordner für konvertierte Audio-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 117)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('converter_output_template_video', 'Pfade', 'Converter Video Output-Template', 'string', 0, 'Template für Video-Ausgabedateinamen, z.B. {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 738)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('converter_output_template_audio', 'Pfade', 'Converter Audio Output-Template', 'string', 0, 'Template für Audio-Ausgabedateinamen, z.B. {artist} - {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 739)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('converter_raw_dir_owner', 'Pfade', 'Eigentümer Converter RAW-Ordner', 'string', 0, 'Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1065)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('converter_movie_dir_owner', 'Pfade', 'Eigentümer Converter Video Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1165)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('converter_audio_dir_owner', 'Pfade', 'Eigentümer Converter Audio Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1166)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('download_dir', 'Pfade', 'Download ZIP-Ordner', 'path', 0, 'Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads).', NULL, '[]', '{}', 118)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('download_dir_owner', 'Pfade', 'Eigentümer Download ZIP-Ordner', 'string', 0, 'Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1185)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('drive_devices', 'Laufwerk', 'Explizite Laufwerke', 'path', 0, 'Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{\"path\":\"/dev/sr0\",\"makemkvIndex\":0},{\"path\":\"/dev/sr1\",\"makemkvIndex\":1}]. Nur relevant wenn Modus = Explizites Device.', '[]', '[]', '{}', 15)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_devices', '[]')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('auto_eject_after_rip', 'Laufwerk', 'Laufwerk nach Rip auswerfen', 'boolean', 1, 'Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject).', 'false', '[]', '{}', 45)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false')`); + + await db.run(` + CREATE TABLE IF NOT EXISTS user_prefs ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + await db.run(` + CREATE TABLE IF NOT EXISTS dvd_series_disc_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + provider_series_name TEXT, + season_number INTEGER, + season_type TEXT NOT NULL DEFAULT 'dvd', + disc_label TEXT, + disc_serial TEXT, + disc_number INTEGER, + language TEXT, + disc_signature TEXT NOT NULL, + fingerprint_json TEXT, + episode_map_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature)`); + await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number)`); + await db.run(`UPDATE dvd_series_disc_profiles SET provider = 'tmdb' WHERE provider = 'tvdb'`); + + await db.run(` + CREATE TABLE IF NOT EXISTS dvd_series_title_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_job_id INTEGER NOT NULL, + disc_profile_id INTEGER, + title_index INTEGER NOT NULL, + title_kind TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 1, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + season_number INTEGER, + episode_start INTEGER, + episode_end INTEGER, + episode_ids_json TEXT, + mapping_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE, + FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL + ) + `); + await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index)`); + await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number)`); + await db.run(`UPDATE dvd_series_title_mappings SET provider = 'tmdb' WHERE provider = 'tvdb'`); +} + +async function backfillJobOutputFolders(db) { + // Remove duplicate (job_id, output_path) rows before unique index is enforced. + await db.run(` + DELETE FROM job_output_folders + WHERE id NOT IN ( + SELECT MIN(id) FROM job_output_folders GROUP BY job_id, output_path + ) + `); + + // Populate job_output_folders from jobs.output_path for pre-feature jobs. + // Only inserts rows where no entry exists for the job yet. + const rows = await db.all(` + SELECT j.id AS job_id, j.output_path + FROM jobs j + WHERE j.output_path IS NOT NULL + AND j.output_path != '' + AND NOT EXISTS ( + SELECT 1 FROM job_output_folders f WHERE f.job_id = j.id + ) + `); + if (!Array.isArray(rows) || rows.length === 0) return; + let inserted = 0; + for (const row of rows) { + try { + await db.run( + 'INSERT OR IGNORE INTO job_output_folders (job_id, output_path) VALUES (?, ?)', + [row.job_id, row.output_path] + ); + inserted += 1; + } catch (err) { + logger.warn('backfill:job-output-folders:insert-failed', { jobId: row.job_id, error: err?.message }); + } + } + if (inserted > 0) { + logger.info('backfill:job-output-folders:done', { inserted }); + } +} + +async function getDb() { + return initDatabase(); +} + +module.exports = { + initDatabase, + getDb +}; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..a7078df --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,168 @@ +require('dotenv').config(); + +const http = require('http'); +const express = require('express'); +const cors = require('cors'); +const { port, corsOrigin } = require('./config'); +const { initDatabase } = require('./db/database'); +const errorHandler = require('./middleware/errorHandler'); +const requestLogger = require('./middleware/requestLogger'); +const settingsRoutes = require('./routes/settingsRoutes'); +const pipelineRoutes = require('./routes/pipelineRoutes'); +const historyRoutes = require('./routes/historyRoutes'); +const downloadRoutes = require('./routes/downloadRoutes'); +const cronRoutes = require('./routes/cronRoutes'); +const runtimeRoutes = require('./routes/runtimeRoutes'); +const converterRoutes = require('./routes/converterRoutes'); +const wsService = require('./services/websocketService'); +const pipelineService = require('./services/pipelineService'); +const settingsService = require('./services/settingsService'); +const converterScanService = require('./services/converterScanService'); +const cronService = require('./services/cronService'); +const downloadService = require('./services/downloadService'); +const diskDetectionService = require('./services/diskDetectionService'); +const hardwareMonitorService = require('./services/hardwareMonitorService'); +const coverArtRecoveryService = require('./services/coverArtRecoveryService'); +const tempCleanupService = require('./services/tempCleanupService'); +const { fetchCurrentBetaKey } = require('./services/makemkvKeyService'); +const logger = require('./services/logger').child('BOOT'); +const { errorToMeta } = require('./utils/errorMeta'); +const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService'); + +function getDelayUntilNextBetaKeyCheck(now = new Date()) { + const next = new Date(now); + next.setHours(0, 1, 0, 0); + if (next.getTime() <= now.getTime()) { + next.setDate(next.getDate() + 1); + } + return Math.max(1000, next.getTime() - now.getTime()); +} + +async function start() { + logger.info('backend:start:init'); + await initDatabase(); + try { + const runtimeSettings = await settingsService.applyRuntimeSettings(); + logger.info('backend:runtime-settings:applied', runtimeSettings); + } catch (error) { + logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) }); + } + let betaKeyRefreshTimer = null; + const scheduleBetaKeyRefresh = () => { + clearTimeout(betaKeyRefreshTimer); + const delayMs = getDelayUntilNextBetaKeyCheck(); + betaKeyRefreshTimer = setTimeout(runBetaKeyRefresh, delayMs); + betaKeyRefreshTimer.unref?.(); + logger.info('beta-key:daily-check:scheduled', { + delayMs, + nextRunAt: new Date(Date.now() + delayMs).toISOString() + }); + }; + const runBetaKeyRefresh = async () => { + try { + const result = await fetchCurrentBetaKey({ forceRefresh: true }); + logger.info('beta-key:daily-check:done', { + sourceUrl: result?.sourceUrl || null, + validUntil: result?.validUntil || null, + fetchedAt: result?.fetchedAt || null + }); + } catch (error) { + logger.warn('beta-key:daily-check:failed', { error: errorToMeta(error) }); + } finally { + scheduleBetaKeyRefresh(); + } + }; + scheduleBetaKeyRefresh(); + await tempCleanupService.init(); + await pipelineService.init(); + await converterScanService.startPolling(); + await cronService.init(); + await downloadService.init(); + await coverArtRecoveryService.init(); + + const app = express(); + app.use(cors({ origin: corsOrigin })); + app.use(express.json({ limit: '2mb' })); + app.use(requestLogger); + + app.get('/api/health', (req, res) => { + res.json({ ok: true, now: new Date().toISOString() }); + }); + + app.use('/api/settings', settingsRoutes); + app.use('/api/pipeline', pipelineRoutes); + app.use('/api/history', historyRoutes); + app.use('/api/downloads', downloadRoutes); + app.use('/api/crons', cronRoutes); + app.use('/api/runtime', runtimeRoutes); + app.use('/api/converter', converterRoutes); + app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true })); + + app.use(errorHandler); + + const server = http.createServer(app); + wsService.init(server); + await hardwareMonitorService.init(); + + diskDetectionService.on('discInserted', (device) => { + logger.info('disk:inserted:event', { device }); + pipelineService.onDiscInserted(device).catch((error) => { + logger.error('pipeline:onDiscInserted:failed', { error: errorToMeta(error), device }); + wsService.broadcast('PIPELINE_ERROR', { message: error.message }); + }); + }); + + diskDetectionService.on('discRemoved', (device) => { + logger.info('disk:removed:event', { device }); + pipelineService.onDiscRemoved(device).catch((error) => { + logger.error('pipeline:onDiscRemoved:failed', { error: errorToMeta(error), device }); + wsService.broadcast('PIPELINE_ERROR', { message: error.message }); + }); + }); + + diskDetectionService.on('error', (error) => { + logger.error('diskDetection:error:event', { error: errorToMeta(error) }); + wsService.broadcast('DISK_DETECTION_ERROR', { message: error.message }); + }); + + diskDetectionService.start(); + + server.listen(port, () => { + logger.info('backend:listening', { port }); + // Bestehende Job-Bilder im Hintergrund migrieren (blockiert nicht den Start) + migrateExistingThumbnails().catch(() => {}); + }); + + const shutdown = () => { + logger.warn('backend:shutdown:received'); + converterScanService.stopPolling(); + diskDetectionService.stop(); + coverArtRecoveryService.stop(); + hardwareMonitorService.stop(); + cronService.stop(); + tempCleanupService.stop(); + clearTimeout(betaKeyRefreshTimer); + server.close(() => { + logger.warn('backend:shutdown:completed'); + process.exit(0); + }); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + process.on('uncaughtException', (error) => { + logger.error('process:uncaughtException', { error: errorToMeta(error) }); + }); + + process.on('unhandledRejection', (reason) => { + logger.error('process:unhandledRejection', { + reason: reason instanceof Error ? errorToMeta(reason) : String(reason) + }); + }); +} + +start().catch((error) => { + logger.error('backend:start:failed', { error: errorToMeta(error) }); + process.exit(1); +}); diff --git a/backend/src/middleware/asyncHandler.js b/backend/src/middleware/asyncHandler.js new file mode 100644 index 0000000..1520915 --- /dev/null +++ b/backend/src/middleware/asyncHandler.js @@ -0,0 +1,5 @@ +module.exports = function asyncHandler(fn) { + return function wrapped(req, res, next) { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..d27b4b5 --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -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 + } + }); +}; diff --git a/backend/src/middleware/requestLogger.js b/backend/src/middleware/requestLogger.js new file mode 100644 index 0000000..4a22e10 --- /dev/null +++ b/backend/src/middleware/requestLogger.js @@ -0,0 +1,76 @@ +const { randomUUID } = require('crypto'); +const logger = require('../services/logger').child('HTTP'); + +function truncate(value, maxLen = 1500) { + if (value === undefined) { + return undefined; + } + + let str; + if (typeof value === 'string') { + str = value; + } else { + try { + str = JSON.stringify(value); + } catch (error) { + str = '[unserializable-body]'; + } + } + if (str.length <= maxLen) { + return str; + } + + return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`; +} + +module.exports = function requestLogger(req, res, next) { + const reqId = randomUUID(); + const startedAt = Date.now(); + let completionLogged = false; + + req.reqId = reqId; + + logger.info('request:start', { + reqId, + method: req.method, + url: req.originalUrl, + ip: req.ip, + query: req.query, + body: truncate(req.body) + }); + + const logCompletion = (kind) => { + if (completionLogged) { + return; + } + completionLogged = true; + logger.info('request:end', { + reqId, + method: req.method, + url: req.originalUrl, + statusCode: res.statusCode, + durationMs: Date.now() - startedAt, + completion: kind + }); + }; + + res.on('finish', () => { + logCompletion('finish'); + }); + + res.on('close', () => { + if (!res.writableEnded) { + logger.warn('request:aborted', { + reqId, + method: req.method, + url: req.originalUrl, + statusCode: res.statusCode, + durationMs: Date.now() - startedAt + }); + return; + } + logCompletion('close'); + }); + + next(); +}; diff --git a/backend/src/plugins/AudiobookPlugin.js b/backend/src/plugins/AudiobookPlugin.js new file mode 100644 index 0000000..25a7b56 --- /dev/null +++ b/backend/src/plugins/AudiobookPlugin.js @@ -0,0 +1,460 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { SourcePlugin } = require('./PluginBase'); +const audiobookService = require('../services/audiobookService'); +const { spawnTrackedProcess } = require('../services/processRunner'); +const { ensureDir } = require('../utils/files'); + +/** + * Source-Plugin für Audiobooks (AAX-Dateien). + * + * Audiobooks unterscheiden sich von Disc-Medien grundlegend: + * - Kein Laufwerk — der Nutzer lädt eine .aax-Datei hoch + * - Kein Rip-Schritt — die AAX-Datei ist der Input + * - Encode mit ffmpeg (Gesamt oder kapitelweise) + * + * Deshalb: + * rip() → No-Op (Datei liegt bereits im rawDir) + * review() → null (kein HandBrake-Preview) + * encode() → ffmpeg Single-File oder kapitelweise + * + * detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für + * Datei-Uploads) oder eine Dateiendung im discInfo. + * + * Pflicht-Felder in ctx.extra für analyze(): + * filePath - Absoluter Pfad zur .aax-Datei + * + * Pflicht-Felder in ctx.extra für encode(): + * inputPath - Absoluter Pfad zur .aax-Input-Datei + * outputPath - Absoluter Pfad für die Ausgabe (Datei oder Verzeichnis) + * outputFormat - 'm4b' | 'mp3' | 'flac' + * formatOptions - Encoder-Optionen { flacCompression, mp3Mode, mp3Bitrate, ... } + * metadata - Metadata-Objekt aus analyze() oder encode_plan_json + * activationBytes - AAX-Aktivierungsbytes (String, kann null sein bei nicht-DRM) + * isSplitOutput - boolean: true = kapitelweise, false = Einzel-Datei + * chapterPlan - { outputFiles, chapters } — nur benötigt wenn isSplitOutput = true + * isCancelled - () => boolean + * onProcessHandle - (handle) => void [optional] + */ +class AudiobookPlugin extends SourcePlugin { + get id() { + return 'audiobook'; + } + + get name() { + return 'Audiobook (AAX)'; + } + + get priority() { + return 5; + } + + /** + * Erkennt Audiobooks. + * Wird entweder via mediaProfile ('audiobook') oder Dateiendung erkannt. + */ + detect(discInfo) { + const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase(); + if (profile === 'audiobook' || profile === 'audio_book') { + return true; + } + const ext = path.extname(String(discInfo?.filePath || discInfo?.fileName || '')).trim().toLowerCase(); + return audiobookService.SUPPORTED_INPUT_EXTENSIONS.has(ext); + } + + /** + * Analysiert eine .aax-Datei mit ffprobe. + * Gibt die extrahierten Metadaten zurück (Titel, Autor, Kapitel, Cover, etc.). + * + * @param {string} filePath - Pfad zur .aax-Datei (in ctx.extra.filePath ODER als devicePath) + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} Metadata-Objekt aus audiobookService.buildMetadataFromProbe() + */ + async analyze(filePath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'AudiobookPlugin.js' + }); + + const inputPath = ctx.extra?.filePath || filePath; + + if (!inputPath || !fs.existsSync(inputPath)) { + const error = new Error(`AudiobookPlugin.analyze(): Datei nicht gefunden: ${inputPath}`); + error.statusCode = 400; + throw error; + } + + const settings = await ctx.settings.getSettingsMap(); + const ffprobeCommand = String(settings.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + const originalName = path.basename(inputPath); + + ctx.logger.info('audiobook:analyze:start', { jobId: job?.id, inputPath, ffprobeCommand }); + + const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, inputPath); + const captured = await _runCaptured(probeConfig.cmd, probeConfig.args, { jobId: job?.id }); + + const probe = audiobookService.parseProbeOutput(captured.stdout); + if (!probe) { + const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden (kein gültiges JSON).'); + error.statusCode = 500; + throw error; + } + + const metadata = audiobookService.buildMetadataFromProbe(probe, originalName); + + ctx.logger.info('audiobook:analyze:done', { + jobId: job?.id, + title: metadata.title, + author: metadata.author, + chapterCount: metadata.chapters?.length || 0, + durationMs: metadata.durationMs + }); + + return metadata; + } + + /** + * No-Op: Audiobooks haben keinen Rip-Schritt. + * Die .aax-Datei wurde bereits beim Upload ins rawDir verschoben. + */ + async rip(_job, _ctx) { + _ctx.markExecution('rip', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'AudiobookPlugin.js' + }); + + // Für Audiobooks gibt es keinen separaten Rip-Schritt + } + + /** + * Encodiert die .aax-Datei mit ffmpeg — als Einzel-Datei oder kapitelweise. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise<{outputPath: string, format: string, chapterCount: number}>} + */ + async encode(job, ctx) { + ctx.markExecution('encode', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'AudiobookPlugin.js' + }); + + const settings = await ctx.settings.getSettingsMap(); + if (!settings.audiobook_encoding_enabled) { + ctx.logger.info('audiobook:encode:disabled', { jobId: job?.id }); + return; + } + + const { + inputPath, + outputPath, + outputFormat = 'mp3', + formatOptions = {}, + metadata = {}, + activationBytes = null, + isSplitOutput = false, + chapterPlan = null, + isCancelled, + onProcessHandle + } = ctx.extra || {}; + + if (!inputPath) { + throw new Error('AudiobookPlugin.encode(): ctx.extra.inputPath fehlt'); + } + if (!outputPath) { + throw new Error('AudiobookPlugin.encode(): ctx.extra.outputPath fehlt'); + } + if (!fs.existsSync(inputPath)) { + const error = new Error(`AudiobookPlugin.encode(): Input-Datei nicht gefunden: ${inputPath}`); + error.statusCode = 400; + throw error; + } + + const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg'; + const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat); + const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions); + + ctx.logger.info('audiobook:encode:start', { + jobId: job?.id, + format: normalizedFormat, + isSplitOutput, + inputPath + }); + + if (isSplitOutput) { + await this._encodeChapters({ + job, ctx, ffmpegCommand, inputPath, chapterPlan, + normalizedFormat, normalizedOptions, metadata, activationBytes, + isCancelled, onProcessHandle + }); + } else { + await this._encodeSingleFile({ + job, ctx, ffmpegCommand, inputPath, outputPath, + normalizedFormat, normalizedOptions, metadata, activationBytes, + isCancelled, onProcessHandle + }); + } + + ctx.logger.info('audiobook:encode:done', { + jobId: job?.id, + format: normalizedFormat, + outputPath + }); + + ctx.extra.encodeResult = { + outputPath, + format: normalizedFormat, + chapterCount: isSplitOutput + ? (chapterPlan?.outputFiles?.length || 0) + : 1 + }; + + return ctx.extra.encodeResult; + } + + /** + * Audiobooks brauchen keine Encode-Review (kein HandBrake). + */ + async review(_job, _ctx) { + _ctx.markExecution('review', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'AudiobookPlugin.js' + }); + + return null; + } + + /** + * Plugin-spezifische Settings für Audiobooks. + */ + getSettingsSchema() { + return [ + { + key: 'audiobook_encoding_enabled', + category: 'Features', + label: 'Audiobook Encoding', + type: 'boolean', + required: 1, + description: 'Audiobook-Encoding über das Plugin aktivieren.', + default_value: 'true', + options_json: '[]', + validation_json: '{}', + order_index: 100 + }, + { + key: 'ffmpeg_command', + category: 'Tools', + label: 'FFmpeg Kommando', + type: 'string', + required: 1, + description: 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', + default_value: 'ffmpeg', + options_json: '[]', + validation_json: '{"minLength":1}', + order_index: 232 + }, + { + key: 'ffprobe_command', + category: 'Tools', + label: 'FFprobe Kommando', + type: 'string', + required: 1, + description: 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', + default_value: 'ffprobe', + options_json: '[]', + validation_json: '{"minLength":1}', + order_index: 233 + }, + { + key: 'output_template_audiobook', + category: 'Pfade', + label: 'Output Template (Audiobook)', + type: 'string', + required: 1, + description: 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner über "/".', + default_value: audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, + options_json: '[]', + validation_json: '{"minLength":1}', + order_index: 735 + } + ]; + } + + // ── Private Encode-Methoden ────────────────────────────────────────────────── + + async _encodeSingleFile({ job, ctx, ffmpegCommand, inputPath, outputPath, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) { + ensureDir(path.dirname(outputPath)); + + const ffmpegConfig = audiobookService.buildEncodeCommand( + ffmpegCommand, + inputPath, + outputPath, + normalizedFormat, + normalizedOptions, + { activationBytes, metadata } + ); + + ctx.logger.info('audiobook:encode:single-file', { + jobId: job?.id, + cmd: ffmpegConfig.cmd, + outputPath + }); + + const progressParser = audiobookService.buildProgressParser(metadata?.durationMs || 0); + await _spawnAndWait({ + cmd: ffmpegConfig.cmd, + args: ffmpegConfig.args, + jobId: job?.id, + progressParser, + onProgress: (pct) => ctx.emitProgress(Math.round(pct), 'Audiobook wird encodiert …'), + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'AudiobookPlugin' } + }); + } + + async _encodeChapters({ job, ctx, ffmpegCommand, inputPath, chapterPlan, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) { + const outputFiles = Array.isArray(chapterPlan?.outputFiles) ? chapterPlan.outputFiles : []; + if (outputFiles.length === 0) { + throw new Error('AudiobookPlugin: Keine Kapitel im chapterPlan für kapitelweise Ausgabe.'); + } + + for (let index = 0; index < outputFiles.length; index++) { + _assertNotCancelled(isCancelled); + + const entry = outputFiles[index]; + const chapter = entry?.chapter || {}; + const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim(); + const startPct = (index / outputFiles.length) * 100; + const endPct = ((index + 1) / outputFiles.length) * 100; + + ensureDir(path.dirname(entry.outputPath)); + + const ffmpegConfig = audiobookService.buildChapterEncodeCommand( + ffmpegCommand, + inputPath, + entry.outputPath, + normalizedFormat, + normalizedOptions, + metadata, + chapter, + outputFiles.length, + { activationBytes } + ); + + ctx.logger.info('audiobook:encode:chapter', { + jobId: job?.id, + chapterIndex: index + 1, + chapterTotal: outputFiles.length, + chapterTitle, + outputPath: entry.outputPath + }); + + const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0); + const scaledParser = baseParser + ? (line) => { + const progress = baseParser(line); + if (!progress || progress.percent == null) { + return null; + } + return { + percent: startPct + (endPct - startPct) * (progress.percent / 100), + eta: null + }; + } + : null; + + ctx.emitProgress( + Math.round(startPct), + `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}` + ); + + await _spawnAndWait({ + cmd: ffmpegConfig.cmd, + args: ffmpegConfig.args, + jobId: job?.id, + progressParser: scaledParser, + onProgress: (pct) => ctx.emitProgress( + Math.round(pct), + `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}` + ), + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'AudiobookPlugin', chapterIndex: index + 1 } + }); + } + } +} + +// ── Hilfsfunktionen (privat) ───────────────────────────────────────────────── + +function _assertNotCancelled(isCancelled) { + if (typeof isCancelled === 'function' && isCancelled()) { + const error = new Error('Job wurde vom Benutzer abgebrochen.'); + error.statusCode = 409; + throw error; + } +} + +async function _runCaptured(cmd, args, _context = {}) { + const { execFile } = require('child_process'); + const { promisify } = require('util'); + const execFileAsync = promisify(execFile); + try { + return await execFileAsync(cmd, args, { timeout: 30000, maxBuffer: 8 * 1024 * 1024 }); + } catch (error) { + // execFile wirft bei non-zero exit; stdout/stderr können trotzdem valide sein + return { + stdout: String(error?.stdout || ''), + stderr: String(error?.stderr || ''), + exitCode: error?.code ?? 1 + }; + } +} + +async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) { + _assertNotCancelled(isCancelled); + + const handle = spawnTrackedProcess({ + cmd, + args, + onStdoutLine: () => {}, + onStderrLine: (line) => { + if (progressParser && typeof onProgress === 'function') { + const progress = progressParser(line); + if (progress?.percent != null) { + onProgress(progress.percent); + } + } + }, + context: context || { jobId } + }); + + if (typeof onProcessHandle === 'function') { + onProcessHandle(handle); + } + + if (typeof isCancelled === 'function' && isCancelled()) { + handle.cancel(); + } + + try { + return await handle.promise; + } catch (error) { + if (typeof isCancelled === 'function' && isCancelled()) { + const cancelError = new Error('Job wurde vom Benutzer abgebrochen.'); + cancelError.statusCode = 409; + throw cancelError; + } + throw error; + } +} + +module.exports = { AudiobookPlugin }; diff --git a/backend/src/plugins/BluRayPlugin.js b/backend/src/plugins/BluRayPlugin.js new file mode 100644 index 0000000..eed46b6 --- /dev/null +++ b/backend/src/plugins/BluRayPlugin.js @@ -0,0 +1,70 @@ +'use strict'; + +const { VideoDiscPlugin } = require('./VideoDiscPlugin'); + +/** + * Source-Plugin für Blu-ray Discs. + * + * Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin. + * Überschreibt nur die Identifikations-Properties und detect(). + * + * Erkennungsmerkmale: + * - mediaProfile === 'bluray' + * - Dateisystem: UDF (Versionen 2.5 / 2.6) + * - Laufwerksmodell enthält 'blu-ray', 'bdrom', 'bd-r', 'bd-re' + */ +class BluRayPlugin extends VideoDiscPlugin { + get id() { + return 'bluray'; + } + + get name() { + return 'Blu-ray'; + } + + get mediaProfile() { + return 'bluray'; + } + + /** + * Höhere Priorität als DVD (10 > 5) damit ein UDF-Laufwerk mit + * blu-ray-Modell-Marker korrekt erkannt wird, auch wenn beide Plugins + * theoretisch auf UDF-Discs matchen könnten. + */ + get priority() { + return 10; + } + + /** + * Erkennt Blu-ray Discs. + * Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld. + * + * @param {object} discInfo + * @param {string} [discInfo.mediaProfile] - 'bluray' + * @param {string} [discInfo.fstype] - 'udf' + * @param {string} [discInfo.driveModel] + * @returns {boolean} + */ + detect(discInfo) { + const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase(); + if (profile === 'bluray' || profile === 'blu-ray' || profile === 'blu_ray') { + return true; + } + // Wenn mediaProfile explizit auf einen anderen Medientyp gesetzt ist + // (z.B. 'dvd' für eine DVD im Blu-ray-Laufwerk), dem DiskDetectionService + // vertrauen und nicht via Modell-Marker überschreiben. + if (profile) { + return false; + } + // Fallback: nur wenn kein mediaProfile bekannt ist. + // UDF-Dateisystem + Laufwerks-Modell enthält Blu-ray-Marker. + const fstype = String(discInfo?.fstype || '').trim().toLowerCase(); + const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase(); + if (fstype === 'udf' && /(blu[\s-]?ray|bd[\s_-]?rom|\bbd-?r\b|\bbd-?re\b|\bbdr\b)/i.test(model)) { + return true; + } + return false; + } +} + +module.exports = { BluRayPlugin }; diff --git a/backend/src/plugins/CdPlugin.js b/backend/src/plugins/CdPlugin.js new file mode 100644 index 0000000..7d206a9 --- /dev/null +++ b/backend/src/plugins/CdPlugin.js @@ -0,0 +1,346 @@ +'use strict'; + +const { SourcePlugin } = require('./PluginBase'); +const cdRipService = require('../services/cdRipService'); + +/** + * Source-Plugin für Audio-CDs. + * + * Delegiert die gesamte Arbeit an den bereits fertig ausgelagerten + * cdRipService — dieser Plugin ist ein dünner Adapter-Wrapper. + * + * CD-Besonderheit: Rip und Encode sind bei CDs Track-für-Track + * verzahnt (rip track → encode track → nächster Track). + * Deshalb ist encode() ein No-Op; die gesamte Arbeit liegt in rip(). + * + * Erwartete ctx.extra-Felder für rip(): + * ripConfig - Das ripConfig-Objekt aus dem API-Request (format, formatOptions, + * selectedTracks, tracks, metadata, ...) + * rawWavDir - Absoluter Pfad zum WAV-Temp-/RAW-Ordner + * outputDir - Absoluter Pfad zum finalen Ausgabeordner + * outputTemplate - CD-Output-Template String + * isCancelled - () => boolean — Abbruchsignal vom Orchestrator + * onProcessHandle - Callback mit dem Prozess-Handle (für Abbruch) + */ +class CdPlugin extends SourcePlugin { + get id() { + return 'cd'; + } + + get name() { + return 'Audio CD'; + } + + get priority() { + return 10; + } + + /** + * Erkennt Audio-CDs anhand des mediaProfile-Feldes, das vom + * DiskDetectionService / diskDetectionService.inferMediaProfile() gesetzt wird. + * + * Akzeptierte Werte: 'cd', 'audio_cd' + */ + detect(discInfo) { + const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase(); + const fstype = String(discInfo?.fstype || '').trim().toLowerCase(); + return profile === 'cd' || profile === 'audio_cd' || fstype === 'audio_cd'; + } + + /** + * Liest das TOC der CD mit cdparanoia -Q und gibt die Trackliste zurück. + * + * @param {string} devicePath - z.B. '/dev/sr0' + * @param {object} job - Job-Record (id wird für Logging genutzt) + * @param {PluginContext} ctx + * @returns {Promise<{tracks: Array, cdparanoiaCmd: string, detectedTitle: string}>} + */ + async analyze(devicePath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'CdPlugin.js' + }); + + const settings = await ctx.settings.getSettingsMap(); + const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; + const detectedTitle = String( + ctx.extra?.discInfo?.discLabel || ctx.extra?.discInfo?.label || ctx.extra?.discInfo?.model || 'Audio CD' + ).trim(); + + ctx.logger.info('cd:analyze:start', { devicePath, jobId: job?.id, detectedTitle }); + + const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd); + + if (!tracks.length) { + const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).'); + error.statusCode = 400; + throw error; + } + + ctx.logger.info('cd:analyze:done', { devicePath, jobId: job?.id, trackCount: tracks.length }); + + return { + tracks, + cdparanoiaCmd, + detectedTitle + }; + } + + /** + * Rippt und encodiert alle ausgewählten Tracks der CD. + * + * Bei CDs sind Rip und Encode Track-für-Track verzahnt — alles + * läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf. + * + * Pflicht-Felder in ctx.extra: + * ripConfig - { format, formatOptions, selectedTracks, tracks, metadata, skipRip, skipEncode } + * rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner) + * outputDir - Finaler Ausgabeordner (optional bei skipEncode=true) + * outputTemplate - Template-String für Dateinamen + * isCancelled - () => boolean + * onProcessHandle - (handle) => void [optional] + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise<{outputDir, format, trackCount, encodeResults}>} + */ + async rip(job, ctx) { + ctx.markExecution('rip', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'CdPlugin.js' + }); + + const { + ripConfig = {}, + rawWavDir, + outputDir, + outputTemplate, + isCancelled, + onProcessHandle, + onProgress: onRawProgress, + onLog: onExternalLog + } = ctx.extra || {}; + + if (!rawWavDir) { + throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt'); + } + const skipRip = Boolean(ripConfig?.skipRip); + const skipEncode = Boolean(ripConfig?.skipEncode); + + if (!skipEncode && !outputDir) { + throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt'); + } + + const cdInfo = _safeParseJson(job?.makemkv_info_json) || {}; + const settings = await ctx.settings.getSettingsMap(); + const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; + const devicePath = String(job?.disc_device || '').trim(); + if (!devicePath && !skipRip) { + throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).'); + } + + const format = String(ripConfig.format || 'flac').trim().toLowerCase(); + const formatOptions = ripConfig.formatOptions || {}; + const effectiveTemplate = outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + + // Trackliste: TOC-Tracks aus DB, optional mit Metadaten aus ripConfig überschrieben + const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; + const incomingTracks = Array.isArray(ripConfig.tracks) ? ripConfig.tracks : []; + const incomingByPos = new Map( + incomingTracks + .filter((t) => Number.isFinite(Number(t?.position)) && Number(t.position) > 0) + .map((t) => [Math.trunc(Number(t.position)), t]) + ); + + const mergedTracks = tocTracks + .map((track) => { + const pos = Math.trunc(Number(track?.position)); + if (!Number.isFinite(pos) || pos <= 0) { + return null; + } + const incoming = incomingByPos.get(pos) || null; + return { + ...track, + position: pos, + title: incoming?.title || track.title || `Track ${pos}`, + artist: incoming?.artist || track.artist || null, + selected: incoming ? Boolean(incoming.selected) : (track.selected !== false) + }; + }) + .filter(Boolean); + + const selectedMeta = cdInfo.selectedMetadata || {}; + const incomingMeta = (ripConfig.metadata && typeof ripConfig.metadata === 'object') + ? ripConfig.metadata + : {}; + const meta = { + title: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD', + artist: _normText(incomingMeta.artist) || _normText(selectedMeta.artist) || null, + year: _normYear(incomingMeta.year) ?? _normYear(selectedMeta.year) ?? _normYear(job?.year) ?? null, + album: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD' + }; + + const rawSelectedPositions = Array.isArray(ripConfig.selectedTracks) + ? ripConfig.selectedTracks + .map((v) => Math.trunc(Number(v))) + .filter((v) => Number.isFinite(v) && v > 0) + : []; + const selectedTrackPositions = rawSelectedPositions.length > 0 + ? rawSelectedPositions + : mergedTracks.filter((t) => t.selected !== false).map((t) => t.position); + + ctx.logger.info('cd:rip:start', { + jobId: job?.id, + devicePath: devicePath || null, + skipRip, + skipEncode, + format, + trackCount: selectedTrackPositions.length + }); + + const result = await cdRipService.ripAndEncode({ + jobId: job?.id, + devicePath: devicePath || null, + cdparanoiaCmd, + rawWavDir, + outputDir, + format, + formatOptions, + selectedTracks: selectedTrackPositions, + tracks: mergedTracks, + meta, + outputTemplate: effectiveTemplate, + skipRip, + skipEncode, + onProgress: (progressEvent) => { + if (typeof onRawProgress === 'function') { + onRawProgress(progressEvent); + } + const pct = Number(progressEvent?.percent ?? 0); + const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe'; + const trackIdx = progressEvent?.trackIndex || 1; + const trackTotal = progressEvent?.trackTotal || 1; + ctx.emitProgress( + Math.round(pct), + `${phase} Track ${trackIdx}/${trackTotal} …` + ); + }, + onLog: (level, msg) => { + if (ctx.logger[level]) { + ctx.logger[level](msg, { jobId: job?.id }); + } + if (typeof onExternalLog === 'function') { + onExternalLog(level, msg); + } + }, + onProcessHandle, + isCancelled, + context: { jobId: job?.id, source: 'CdPlugin' } + }); + + ctx.logger.info('cd:rip:done', { + jobId: job?.id, + format, + trackCount: result.trackCount, + outputDir: result.outputDir + }); + + // Ergebnis in ctx.extra ablegen, damit der Orchestrator darauf zugreifen kann + ctx.extra.ripResult = result; + + return result; + } + + /** + * No-Op: Bei CDs ist der Encode-Schritt bereits in rip() integriert. + */ + async encode(_job, _ctx) { + _ctx.markExecution('encode', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'CdPlugin.js' + }); + + // CD Rip und Encode sind in rip() verzahnt — nichts zu tun + } + + /** + * CDs brauchen keine Encode-Review (kein HandBrake). + */ + async review(_job, _ctx) { + _ctx.markExecution('review', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'CdPlugin.js' + }); + + return null; + } + + /** + * Plugin-spezifische Settings für Audio CDs. + */ + getSettingsSchema() { + return [ + { + key: 'cdparanoia_command', + category: 'Tools', + label: 'cdparanoia Kommando', + type: 'string', + required: 1, + description: 'Pfad oder Befehl für cdparanoia. Wird für Audio-CD-Ripping genutzt.', + default_value: 'cdparanoia', + options_json: '[]', + validation_json: '{"minLength":1}', + order_index: 240 + }, + { + key: 'cd_output_template', + category: 'Pfade', + label: 'CD Output Template', + type: 'string', + required: 1, + description: `Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {trackNr}, {title}. Unterordner über "/".`, + default_value: cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE, + options_json: '[]', + validation_json: '{"minLength":1}', + order_index: 730 + } + ]; + } +} + +// ── Hilfsfunktionen (privat, nicht exportiert) ──────────────────────────────── + +function _safeParseJson(value) { + try { + return value ? JSON.parse(value) : null; + } catch { + return null; + } +} + +function _normText(value) { + const s = String(value == null ? '' : value) + .normalize('NFC') + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + return s || null; +} + +function _normYear(value) { + if (value === null || value === undefined || String(value).trim() === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +module.exports = { CdPlugin }; diff --git a/backend/src/plugins/ConverterPlugin.js b/backend/src/plugins/ConverterPlugin.js new file mode 100644 index 0000000..97cc32b --- /dev/null +++ b/backend/src/plugins/ConverterPlugin.js @@ -0,0 +1,660 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { SourcePlugin } = require('./PluginBase'); +const { spawnTrackedProcess } = require('../services/processRunner'); +const { syncRegistrationKeyToConfig } = require('../services/makemkvKeyService'); +const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers'); +const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files'); + +const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']); +const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']); +const ISO_EXTENSIONS = new Set(['iso']); + +const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); +const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']); + +/** + * Erkennt den Medientyp anhand der Dateiendung. + * @returns {'video'|'audio'|'iso'|null} + */ +function detectMediaTypeFromPath(filePath) { + const ext = path.extname(String(filePath || '')).slice(1).toLowerCase(); + if (ISO_EXTENSIONS.has(ext)) return 'iso'; + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; + return null; +} + +function isAudioExt(ext) { + return AUDIO_EXTENSIONS.has(String(ext).toLowerCase()); +} + +/** + * Alle Audio-Dateien in einem Verzeichnis auflisten (nicht rekursiv). + */ +function listAudioFilesInDir(dirPath) { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + return entries + .filter((e) => e.isFile() && isAudioExt(path.extname(e.name).slice(1))) + .map((e) => e.name) + .sort(); + } catch (_err) { + return []; + } +} + +/** + * ffmpeg-Args zum Konvertieren einer einzelnen Audio-Datei. + */ +function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {}) { + const args = ['-y', '-i', inputFile]; + + if (format === 'flac') { + const level = Math.max(0, Math.min(12, Number(opts.flacCompression ?? 5))); + args.push('-codec:a', 'flac', '-compression_level', String(level)); + } else if (format === 'mp3') { + const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase(); + args.push('-codec:a', 'libmp3lame'); + if (mode === 'vbr') { + args.push('-q:a', String(Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4))))); + } else { + args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`); + } + } else if (format === 'opus') { + args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`); + } else if (format === 'ogg') { + args.push('-codec:a', 'libvorbis', '-q:a', String(Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6))))); + } else if (format === 'wav') { + args.push('-codec:a', 'pcm_s16le'); + } else { + args.push('-codec:a', 'copy'); + } + + // Metadata tags + const trackTitle = opts.trackTitle ? String(opts.trackTitle).trim() : null; + const trackArtist = (opts.trackArtist || opts.albumArtist) + ? String(opts.trackArtist || opts.albumArtist).trim() : null; + const albumTitle = opts.albumTitle ? String(opts.albumTitle).trim() : null; + const albumYear = opts.albumYear ? String(opts.albumYear).trim() : null; + const trackNumber = opts.trackNumber != null ? String(opts.trackNumber) : null; + + if (trackTitle) args.push('-metadata', `title=${trackTitle}`); + if (trackArtist) args.push('-metadata', `artist=${trackArtist}`); + if (albumTitle) args.push('-metadata', `album=${albumTitle}`); + if (albumYear) args.push('-metadata', `date=${albumYear}`); + if (trackNumber) args.push('-metadata', `track=${trackNumber}`); + + args.push(outputFile); + return { cmd: ffmpegCmd, args }; +} + +/** + * Source-Plugin für den Converter. + * + * Unterstützte Eingaben: + * - Video-Dateien (mkv, mp4, avi, mov, ...): HandBrake-Encode + * - Audio-Dateien (flac, mp3, wav, ...): ffmpeg-Encode + * - Audio-Ordner: alle enthaltenen Audio-Dateien mit ffmpeg + * - ISO: MakeMKV-Rip → HandBrake-Encode + * + * Erkennung via mediaProfile === 'converter'. + * + * Erforderliche Felder in ctx.extra für encode(): + * inputPath - Absoluter Pfad zur Eingabedatei/-ordner (nach Rip: raw_path) + * outputDir - Ausgabeverzeichnis (für Audio/ISO-Ordner) oder + * outputPath - Ausgabedatei (für einzelne Video/Audio-Dateien) + * encodePlan - aus encode_plan_json + * isCancelled - () => boolean + * onProcessHandle - (handle) => void [optional] + */ +class ConverterPlugin extends SourcePlugin { + get id() { return 'converter'; } + get name() { return 'Converter'; } + get priority() { return 3; } + + detect(discInfo) { + const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase(); + return profile === 'converter'; + } + + /** + * Analysiert die Eingabedatei/-ordner. + * - Video/ISO: HandBrake --scan für Track-Info + * - Audio: ffprobe für Metadaten + * - Audio-Ordner: Dateiliste + ffprobe auf erste Datei + */ + async analyze(inputPath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js' + }); + + const actualInput = ctx.extra?.filePath || inputPath; + const encodePlan = ctx.extra?.encodePlan || {}; + const converterMediaType = encodePlan.converterMediaType + || detectMediaTypeFromPath(actualInput); + + ctx.logger.info('converter:analyze:start', { + jobId: job?.id, inputPath: actualInput, converterMediaType + }); + + const settings = await ctx.settings.getSettingsMap(); + const ffprobeCmd = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + const handbrakeCmd = String(settings?.handbrake_command || 'HandBrakeCLI').trim() || 'HandBrakeCLI'; + + let analysisResult = { converterMediaType, inputPath: actualInput }; + + if (converterMediaType === 'video' || converterMediaType === 'iso') { + // HandBrake --scan für Track-Informationen + try { + const scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(actualInput); + const captured = await this._runCaptured(scanConfig.cmd, scanConfig.args, { jobId: job?.id }); + const hbInfo = this._parseHandBrakeScan(captured.stdout); + analysisResult.handbrakeInfo = hbInfo; + ctx.logger.info('converter:analyze:handbrake-scan', { + jobId: job?.id, + titleCount: hbInfo?.TitleList?.length || 0 + }); + } catch (scanError) { + ctx.logger.warn('converter:analyze:handbrake-scan-failed', { + jobId: job?.id, error: scanError?.message + }); + } + } + + if (converterMediaType === 'audio') { + const stat = fs.statSync(actualInput); + if (stat.isDirectory()) { + // Ordner mit Audio-Dateien + const audioFiles = listAudioFilesInDir(actualInput); + analysisResult.isFolder = true; + analysisResult.audioFiles = audioFiles; + // Erster Track: ffprobe für Metadaten + if (audioFiles.length > 0) { + const firstFile = path.join(actualInput, audioFiles[0]); + try { + const meta = await this._ffprobeMetadata(ffprobeCmd, firstFile, { jobId: job?.id }); + analysisResult.folderMeta = meta; + } catch (_err) { + // Metadaten optional + } + } + ctx.logger.info('converter:analyze:audio-folder', { + jobId: job?.id, fileCount: audioFiles.length + }); + } else { + // Einzelne Audio-Datei + analysisResult.isFolder = false; + try { + const meta = await this._ffprobeMetadata(ffprobeCmd, actualInput, { jobId: job?.id }); + analysisResult.fileMeta = meta; + } catch (_err) { + // Metadaten optional + } + ctx.logger.info('converter:analyze:audio-file', { jobId: job?.id }); + } + } + + return analysisResult; + } + + /** + * Rip-Schritt: + * - Video/Audio-Dateien: No-Op (Datei liegt bereits vor) + * - ISO: MakeMKV backup → raw_path + */ + async rip(job, ctx) { + ctx.markExecution('rip', { + pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js' + }); + + const encodePlan = ctx.extra?.encodePlan || {}; + const converterMediaType = encodePlan.converterMediaType; + + if (converterMediaType !== 'iso') { + ctx.logger.info('converter:rip:no-op', { jobId: job?.id, converterMediaType }); + return; + } + + // ISO: MakeMKV backup + const { + inputPath, + rawJobDir, + isCancelled, + onProcessHandle + } = ctx.extra || {}; + + if (!inputPath) { + throw new Error('ConverterPlugin.rip(): ctx.extra.inputPath fehlt'); + } + if (!rawJobDir) { + throw new Error('ConverterPlugin.rip(): ctx.extra.rawJobDir fehlt'); + } + + ensureDir(rawJobDir); + + const settings = await ctx.settings.getSettingsMap(); + const makemkvCmd = String(settings?.makemkv_command || 'makemkvcon').trim() || 'makemkvcon'; + await syncRegistrationKeyToConfig(settings?.makemkv_registration_key || ''); + + const args = ['--noscan', '--decrypt', '-r', 'backup', `iso:${inputPath}`, rawJobDir]; + + ctx.logger.info('converter:rip:iso:start', { + jobId: job?.id, inputPath, rawJobDir, cmd: makemkvCmd + }); + ctx.emitProgress(0, 'ISO: MakeMKV Backup läuft …'); + + const runInfo = await _spawnAndWait({ + cmd: makemkvCmd, args, + jobId: job?.id, + progressParser: parseMakeMkvProgress, + onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText), + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.rip', stage: 'RIPPING' } + }); + + ctx.logger.info('converter:rip:iso:done', { + jobId: job?.id, exitCode: runInfo?.exitCode ?? null + }); + + ctx.extra.ripRunInfo = runInfo; + } + + /** + * Encode-Schritt: + * - Video: HandBrakeCLI + * - Audio Einzeldatei: ffmpeg + * - Audio Ordner: ffmpeg je Datei + */ + async encode(job, ctx) { + ctx.markExecution('encode', { + pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js' + }); + + const { + inputPath, + outputPath, + outputDir, + encodePlan = {}, + isCancelled, + onProcessHandle + } = ctx.extra || {}; + + const converterMediaType = encodePlan.converterMediaType; + + if (!inputPath) { + throw new Error('ConverterPlugin.encode(): ctx.extra.inputPath fehlt'); + } + + const settings = await ctx.settings.getSettingsMap(); + + if (converterMediaType === 'video' || converterMediaType === 'iso') { + await this._encodeVideo({ + job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle + }); + } else if (converterMediaType === 'audio') { + await this._encodeAudio({ + job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle + }); + } else { + throw new Error(`ConverterPlugin.encode(): Unbekannter converterMediaType: ${converterMediaType}`); + } + } + + // ── Video-Encode (HandBrake) ───────────────────────────────────────────── + + async _encodeVideo({ job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle }) { + if (!outputPath) { + throw new Error('ConverterPlugin._encodeVideo(): outputPath fehlt'); + } + + ensureDir(path.dirname(outputPath)); + + const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, { + trackSelection: encodePlan.trackSelection || null, + titleId: encodePlan.handBrakeTitleId || null, + mediaProfile: null, + settingsMap: settings, + userPreset: encodePlan.userPreset || null + }); + + ctx.logger.info('converter:encode:video:start', { + jobId: job?.id, cmd: handBrakeConfig.cmd, titleId: encodePlan.handBrakeTitleId || null + }); + ctx.emitProgress(0, 'Converter: Video Encoding läuft …'); + + const runInfo = await _spawnAndWait({ + cmd: handBrakeConfig.cmd, args: handBrakeConfig.args, + jobId: job?.id, + progressParser: parseHandBrakeProgress, + onProgress: (pct, statusText, eta) => ctx.emitProgress( + Math.round(pct), + statusText || `Encoding ${pct}%`, + eta ?? null + ), + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'HANDBRAKE', + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + + ctx.logger.info('converter:encode:video:done', { + jobId: job?.id, outputPath, exitCode: runInfo.exitCode + }); + + ctx.extra.encodeRunInfo = runInfo; + } + + // ── Audio-Encode (ffmpeg) ──────────────────────────────────────────────── + + async _encodeAudio({ job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle }) { + const ffmpegCmd = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg'; + const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase(); + const formatOptions = encodePlan.audioFormatOptions || {}; + const isFolder = Boolean(encodePlan.isFolder); + const isSharedAudio = Boolean(encodePlan.isSharedAudio) + || (Array.isArray(encodePlan.inputPaths) && encodePlan.inputPaths.length > 0 && !isFolder); + + // Metadata from user config + const planMetadata = encodePlan.metadata || {}; + const planTracks = Array.isArray(encodePlan.tracks) ? encodePlan.tracks : []; + + if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) { + throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`); + } + + /** Build per-track metadata opts merged with album-level metadata */ + function trackMetaOpts(position, defaultBaseName) { + const trackMeta = planTracks.find((t) => t.position === position) || {}; + return { + ...formatOptions, + trackTitle: trackMeta.title || defaultBaseName || null, + trackArtist: trackMeta.artist || planMetadata.albumArtist || null, + albumTitle: planMetadata.albumTitle || null, + albumArtist: planMetadata.albumArtist || null, + albumYear: planMetadata.albumYear ? String(planMetadata.albumYear) : null, + trackNumber: position + }; + } + + /** Build output filename with optional track number + title */ + function buildOutputFileName(position, trackMeta, defaultBaseName) { + const numStr = String(position).padStart(2, '0'); + const title = trackMeta?.title ? sanitizeFileName(trackMeta.title) : null; + const templateRaw = String(encodePlan.audioTrackTemplate || '').trim(); + if (templateRaw) { + const rendered = renderTemplate(templateRaw, { + trackNr: numStr, + trackNumber: String(position), + title: title || sanitizeFileName(defaultBaseName) || `Track ${numStr}`, + artist: sanitizeFileName(trackMeta?.artist || planMetadata.albumArtist || '') || 'unknown', + album: sanitizeFileName(planMetadata.albumTitle || '') || 'unknown', + year: planMetadata.albumYear || 'unknown' + }); + const sanitized = sanitizeFileName(rendered); + if (sanitized) { + return `${sanitized}.${outputFormat}`; + } + } + if (title) return `${numStr} - ${title}.${outputFormat}`; + return `${sanitizeFileName(defaultBaseName) || numStr}.${outputFormat}`; + } + + if (isSharedAudio) { + // Freigegebene Audio-Dateien (mehrere Einzeldateien in einem Job) + const inputPaths = encodePlan.inputPaths || []; + if (inputPaths.length === 0) { + throw new Error('ConverterPlugin._encodeAudio(): inputPaths leer für shared-audio-Job'); + } + if (!outputDir) { + throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für shared-audio-Job'); + } + ensureDir(outputDir); + + ctx.logger.info('converter:encode:audio:shared:start', { + jobId: job?.id, fileCount: inputPaths.length, outputDir, format: outputFormat + }); + + for (let i = 0; i < inputPaths.length; i++) { + if (typeof isCancelled === 'function' && isCancelled()) { + const err = new Error('Job wurde vom Benutzer abgebrochen.'); + err.statusCode = 409; + throw err; + } + + const inputFile = inputPaths[i]; + const fileName = path.basename(inputFile); + const baseName = path.basename(fileName, path.extname(fileName)); + const position = i + 1; + const trackMeta = planTracks.find((t) => t.position === position) || {}; + const outputFileName = buildOutputFileName(position, trackMeta, baseName); + const outputFile = path.join(outputDir, outputFileName); + + ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile }); + ctx.emitProgress( + Math.round((i / inputPaths.length) * 100), + `Audio: ${position}/${inputPaths.length} — ${fileName}` + ); + + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputFile, outputFile, outputFormat, + trackMetaOpts(position, baseName) + ); + + await _spawnAndWait({ + cmd: encodeConfig.cmd, args: encodeConfig.args, + jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + } + + ctx.logger.info('converter:encode:audio:shared:done', { + jobId: job?.id, fileCount: inputPaths.length, outputDir + }); + + ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: inputPaths.length }; + } else if (isFolder) { + // Ordner-Modus: alle Audio-Dateien im Ordner konvertieren + const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath); + + if (!outputDir) { + throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für Ordner-Job'); + } + ensureDir(outputDir); + + ctx.logger.info('converter:encode:audio:folder:start', { + jobId: job?.id, inputPath, outputDir, format: outputFormat, fileCount: audioFiles.length + }); + + for (let i = 0; i < audioFiles.length; i++) { + if (typeof isCancelled === 'function' && isCancelled()) { + const err = new Error('Job wurde vom Benutzer abgebrochen.'); + err.statusCode = 409; + throw err; + } + + const fileName = audioFiles[i]; + const inputFile = path.join(inputPath, fileName); + const baseName = path.basename(fileName, path.extname(fileName)); + const position = i + 1; + const trackMeta = planTracks.find((t) => t.position === position) || {}; + const outputFileName = buildOutputFileName(position, trackMeta, baseName); + const outputFile = path.join(outputDir, outputFileName); + + ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile }); + ctx.emitProgress( + Math.round((i / audioFiles.length) * 100), + `Audio: ${position}/${audioFiles.length} — ${fileName}` + ); + + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputFile, outputFile, outputFormat, + trackMetaOpts(position, baseName) + ); + + await _spawnAndWait({ + cmd: encodeConfig.cmd, args: encodeConfig.args, + jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + } + + ctx.logger.info('converter:encode:audio:folder:done', { + jobId: job?.id, fileCount: audioFiles.length, outputDir + }); + + ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: audioFiles.length }; + } else { + // Einzelne Datei + if (!outputPath) { + throw new Error('ConverterPlugin._encodeAudio(): outputPath fehlt'); + } + ensureDir(path.dirname(outputPath)); + + ctx.logger.info('converter:encode:audio:file:start', { + jobId: job?.id, inputPath, outputPath, format: outputFormat + }); + ctx.emitProgress(0, `Audio: Encoding läuft …`); + + const baseName = path.basename(inputPath, path.extname(inputPath)); + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputPath, outputPath, outputFormat, + trackMetaOpts(1, baseName) + ); + + await _spawnAndWait({ + cmd: encodeConfig.cmd, args: encodeConfig.args, + jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + + ctx.logger.info('converter:encode:audio:file:done', { + jobId: job?.id, outputPath + }); + + ctx.extra.encodeResult = { outputPath, format: outputFormat }; + } + } + + // ── Hilfsmethoden ─────────────────────────────────────────────────────── + + async _runCaptured(cmd, args, options = {}) { + return new Promise((resolve, reject) => { + const { spawn } = require('child_process'); + let stdout = ''; + let stderr = ''; + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); }); + child.on('error', reject); + child.on('close', (code) => resolve({ exitCode: code, stdout, stderr })); + }); + } + + _parseHandBrakeScan(rawOutput) { + try { + const jsonStart = rawOutput.indexOf('{'); + if (jsonStart < 0) return null; + return JSON.parse(rawOutput.slice(jsonStart)); + } catch (_err) { + return null; + } + } + + async _ffprobeMetadata(ffprobeCmd, filePath, options = {}) { + const args = [ + '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath + ]; + const result = await this._runCaptured(ffprobeCmd, args, options); + try { + return JSON.parse(result.stdout); + } catch (_err) { + return null; + } + } + + getSettingsSchema() { + return []; + } +} + +async function _spawnAndWait({ + cmd, + args, + jobId, + progressParser, + onProgress, + appendLogLine, + logSource = 'PROCESS', + isCancelled, + onProcessHandle, + context +}) { + if (typeof isCancelled === 'function' && isCancelled()) { + const err = new Error('Job wurde vom Benutzer abgebrochen.'); + err.statusCode = 409; + throw err; + } + + const handleLine = (stream, line) => { + if (progressParser && typeof onProgress === 'function') { + const progress = progressParser(line); + if (progress?.percent != null) { + onProgress(progress.percent, progress.detail || null, progress.eta ?? null); + } + } + if (typeof appendLogLine === 'function') { + try { + appendLogLine(logSource, line, stream); + } catch (_error) { + // ignore log append errors in process stream + } + } + }; + + const handle = spawnTrackedProcess({ + cmd, + args, + onStdoutLine: (line) => handleLine('stdout', line), + onStderrLine: (line) => handleLine('stderr', line), + context: context || { jobId } + }); + + if (typeof onProcessHandle === 'function') { + onProcessHandle(handle); + } + + if (typeof isCancelled === 'function' && isCancelled()) { + handle.cancel(); + } + + try { + const result = await handle.promise; + return result; + } catch (error) { + if (typeof isCancelled === 'function' && isCancelled()) { + const cancelError = new Error('Job wurde vom Benutzer abgebrochen.'); + cancelError.statusCode = 409; + throw cancelError; + } + throw error; + } +} + +module.exports = { ConverterPlugin }; diff --git a/backend/src/plugins/DVDPlugin.js b/backend/src/plugins/DVDPlugin.js new file mode 100644 index 0000000..70bb51a --- /dev/null +++ b/backend/src/plugins/DVDPlugin.js @@ -0,0 +1,66 @@ +'use strict'; + +const { VideoDiscPlugin } = require('./VideoDiscPlugin'); + +/** + * Source-Plugin für DVD-Discs. + * + * Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin. + * Überschreibt nur die Identifikations-Properties und detect(). + * + * Erkennungsmerkmale: + * - mediaProfile === 'dvd' + * - Dateisystem: UDF (1.02) oder ISO9660 + * - Laufwerksmodell enthält 'dvd', aber KEIN Blu-ray-Marker + */ +class DVDPlugin extends VideoDiscPlugin { + get id() { + return 'dvd'; + } + + get name() { + return 'DVD'; + } + + get mediaProfile() { + return 'dvd'; + } + + /** + * Niedrigere Priorität als Blu-ray (5 < 10), da Blu-ray-Laufwerke + * auch DVDs lesen können — BluRayPlugin soll für BD-Discs bevorzugt werden. + */ + get priority() { + return 5; + } + + /** + * Erkennt DVD-Discs. + * Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld. + * + * @param {object} discInfo + * @param {string} [discInfo.mediaProfile] - 'dvd' + * @param {string} [discInfo.fstype] - 'udf' | 'iso9660' + * @param {string} [discInfo.driveModel] + * @returns {boolean} + */ + detect(discInfo) { + const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase(); + if (profile === 'dvd') { + return true; + } + // Fallback: ISO9660 oder UDF ohne Blu-ray-Modell-Marker + const fstype = String(discInfo?.fstype || '').trim().toLowerCase(); + const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase(); + const hasBlurayMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r\b|bd-re\b)/i.test(model); + if (hasBlurayMarker) { + return false; // Blu-ray-Laufwerk → BluRayPlugin übernimmt + } + if (fstype === 'iso9660' || (fstype === 'udf' && /dvd/i.test(model))) { + return true; + } + return false; + } +} + +module.exports = { DVDPlugin }; diff --git a/backend/src/plugins/DVDSeriesPlugin.js b/backend/src/plugins/DVDSeriesPlugin.js new file mode 100644 index 0000000..72ae297 --- /dev/null +++ b/backend/src/plugins/DVDSeriesPlugin.js @@ -0,0 +1,113 @@ +'use strict'; + +const { SourcePlugin } = require('./PluginBase'); +const dvdSeriesScanService = require('../services/dvdSeriesScanService'); +const tmdbService = require('../services/tmdbService'); + +/** + * Companion-Plugin für Serien-DVDs. + * + * Dieses Plugin ersetzt den bestehenden DVDPlugin-Flow nicht. Es wird in einem + * späteren Schritt explizit aus dem DVD-Flow aufgerufen, sobald ein Prescan eine + * serientypische Titelstruktur vermuten lässt. + */ +class DVDSeriesPlugin extends SourcePlugin { + get id() { + return 'dvd_series'; + } + + get name() { + return 'DVD Series'; + } + + detect() { + return false; + } + + async analyze(_devicePath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'DVDSeriesPlugin.js' + }); + + const fallbackSeriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([ + { + value: ctx.extra?.detectedTitle || '', + source: 'detected_title' + } + ]); + + const scanText = String(ctx.extra?.scanText || '').trim(); + if (!scanText) { + return { + parsedScan: null, + seriesAnalysis: null, + seriesLookupHint: fallbackSeriesLookupHint, + providerConfigured: await tmdbService.isConfigured() + }; + } + + const result = dvdSeriesScanService.analyzeHandBrakeScan(scanText, ctx.extra?.seriesOptions || {}); + const seriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([ + { + value: result?.parsed?.discTitle || '', + source: 'handbrake_disc_title' + }, + { + value: ctx.extra?.detectedTitle || '', + source: 'detected_title' + } + ]) || fallbackSeriesLookupHint; + return { + parsedScan: result.parsed, + seriesAnalysis: result.analysis, + seriesLookupHint, + providerConfigured: await tmdbService.isConfigured() + }; + } + + async review(_job, ctx) { + ctx.markExecution('review', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'DVDSeriesPlugin.js' + }); + return null; + } + + async rip() { + throw new Error('DVDSeriesPlugin.rip() ist ein Companion-Plugin und wird nicht direkt ausgeführt.'); + } + + async encode() { + throw new Error('DVDSeriesPlugin.encode() ist ein Companion-Plugin und wird nicht direkt ausgeführt.'); + } + + async searchSeries(query, options = {}) { + return tmdbService.searchSeries(query, options); + } + + async searchSeriesWithSeason(query, seasonNumber, options = {}) { + return tmdbService.searchSeriesWithSeason(query, seasonNumber, options); + } + + getSettingsSchema() { + return [ + { + key: 'tmdb_api_read_access_token', + category: 'Metadaten', + label: 'TMDb Read Access Token', + type: 'string', + required: 0, + description: 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', + default_value: null, + options_json: '[]', + validation_json: '{}', + order_index: 401 + } + ]; + } +} + +module.exports = { DVDSeriesPlugin }; diff --git a/backend/src/plugins/PluginBase.js b/backend/src/plugins/PluginBase.js new file mode 100644 index 0000000..28ef4e0 --- /dev/null +++ b/backend/src/plugins/PluginBase.js @@ -0,0 +1,159 @@ +'use strict'; + +/** + * Abstrakte Basisklasse für Source-Plugins. + * Jedes Plugin implementiert die Pipeline-Phasen für einen Medientyp. + * + * Lifecycle: detect() → analyze() → rip() → review() → encode() → finalize() + * + * Alle async-Methoden können einen Fehler werfen — der Orchestrator fängt + * diese ab und schreibt sie als Job-Fehler in die Datenbank. + */ +class SourcePlugin { + /** + * Eindeutiger Bezeichner des Plugins. + * Erlaubte Werte: 'bluray' | 'dvd' | 'cd' | 'audiobook' | eigener String + * @returns {string} + */ + get id() { + throw new Error(`${this.constructor.name}: id nicht implementiert`); + } + + /** + * Anzeigename des Plugins (für Logs und UI). + * @returns {string} + */ + get name() { + throw new Error(`${this.constructor.name}: name nicht implementiert`); + } + + /** + * Priorität bei detect()-Konflikten. Höhere Zahl = höhere Priorität. + * Standard: 0 + * @returns {number} + */ + get priority() { + return 0; + } + + /** + * Prüft, ob dieses Plugin für die erkannte Disc/Datei zuständig ist. + * Wird vom PluginRegistry.findPlugin() aufgerufen. + * + * @param {object} discInfo - Disc-Informationen vom DiskDetectionService + * @param {string} [discInfo.devicePath] + * @param {string} [discInfo.discType] - 'bluray' | 'dvd' | 'cd' | 'data' + * @param {string} [discInfo.filesystem] - z.B. 'UDF', 'ISO9660', 'CDFS' + * @param {string} [discInfo.driveModel] + * @returns {boolean} + */ + detect(discInfo) { // eslint-disable-line no-unused-vars + throw new Error(`${this.constructor.name}: detect() nicht implementiert`); + } + + /** + * Analysiert das Medium (z.B. makemkvcon info, cdparanoia -Q, ffprobe). + * Gibt die Rohdaten zurück, die der Orchestrator im Job speichert. + * + * @param {string} devicePath - Gerätepfad, z.B. '/dev/sr0' + * @param {object} job - Bestehender Job-Record aus der DB + * @param {PluginContext} ctx + * @returns {Promise} Analyse-Ergebnis + * Empfohlene Felder: { encodePlan, handbrakeInfo, rawDiscInfo, ... } + */ + async analyze(devicePath, job, ctx) { // eslint-disable-line no-unused-vars + throw new Error(`${this.constructor.name}: analyze() nicht implementiert`); + } + + /** + * Rippt das Medium in den RAW-Ordner. + * Fortschritt wird über ctx.emitProgress() gemeldet. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} + */ + async rip(job, ctx) { // eslint-disable-line no-unused-vars + throw new Error(`${this.constructor.name}: rip() nicht implementiert`); + } + + /** + * Bereitet die Encode-Review vor (MediaInfo-Analyse, Einstellungsvorschau). + * Optional — Plugins, die keine Review benötigen, können die Basis-Implementierung + * behalten (gibt null zurück, Orchestrator überspringt dann den Review-Schritt). + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} Review-Daten oder null + */ + async review(job, ctx) { // eslint-disable-line no-unused-vars + return null; + } + + /** + * Encodiert die gerippten Dateien (HandBrake, ffmpeg, etc.). + * Fortschritt wird über ctx.emitProgress() gemeldet. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} + */ + async encode(job, ctx) { // eslint-disable-line no-unused-vars + throw new Error(`${this.constructor.name}: encode() nicht implementiert`); + } + + /** + * Abschlussarbeiten: Umbenennen, Aufräumen, Notifications, Post-Encode-Scripts usw. + * Optional — Default-Implementierung tut nichts. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} + */ + async finalize(job, ctx) { // eslint-disable-line no-unused-vars + // Default: kein Finalize-Schritt notwendig + } + + /** + * Abbruch-Handler: Laufende Prozesse beenden, temporäre Dateien aufräumen. + * Wird vom Orchestrator bei cancel() aufgerufen. + * Optional — Default-Implementierung tut nichts. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} + */ + async onCancel(job, ctx) { // eslint-disable-line no-unused-vars + // Default: nichts zu tun + } + + /** + * Retry-Handler: Zustand zurücksetzen vor einem erneuten Versuch. + * Wird vom Orchestrator vor retry() aufgerufen. + * Optional — Default-Implementierung tut nichts. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} + */ + async onRetry(job, ctx) { // eslint-disable-line no-unused-vars + // Default: nichts zu tun + } + + /** + * Plugin-spezifische Settings-Schema-Einträge. + * Diese werden vom PluginRegistry.getSettingsSchemas() aggregiert + * und können zur Laufzeit in die DB eingetragen werden. + * + * Format je Eintrag: + * { key, category, label, type, required, description, + * default_value, options_json, validation_json, order_index } + * + * @returns {Array} + */ + getSettingsSchema() { + return []; + } +} + +module.exports = { SourcePlugin }; diff --git a/backend/src/plugins/PluginContext.js b/backend/src/plugins/PluginContext.js new file mode 100644 index 0000000..2492dfc --- /dev/null +++ b/backend/src/plugins/PluginContext.js @@ -0,0 +1,194 @@ +'use strict'; + +function normalizeExecutionStage(value) { + const stage = String(value || '').trim().toLowerCase(); + return stage || 'unknown'; +} + +function normalizePluginExecutionState(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const pluginId = String(value.pluginId || '').trim().toLowerCase() || 'unknown'; + const pluginName = String(value.pluginName || '').trim() || pluginId; + const pluginFile = String(value.pluginFile || '').trim() || null; + const markerSource = String(value.markerSource || '').trim().toLowerCase() || 'plugin-file'; + const firstMarkedAt = String(value.firstMarkedAt || '').trim() || null; + const lastMarkedAt = String(value.lastMarkedAt || '').trim() || null; + const rawLastStage = String(value.lastStage || '').trim(); + const lastStage = rawLastStage ? normalizeExecutionStage(rawLastStage) : null; + const jobIdRaw = Number(value.jobId); + const jobId = Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null; + const byStageRaw = value.byStage && typeof value.byStage === 'object' && !Array.isArray(value.byStage) + ? value.byStage + : {}; + const byStage = {}; + for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) { + const normalizedStage = normalizeExecutionStage(stageKey); + const count = Number(stageMeta?.count); + byStage[normalizedStage] = { + count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1, + lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null + }; + } + const explicitStages = Array.isArray(value.stages) + ? value.stages.map((stage) => normalizeExecutionStage(stage)).filter(Boolean) + : []; + const stages = Array.from(new Set([ + ...explicitStages, + ...Object.keys(byStage), + ...(lastStage ? [lastStage] : []) + ])); + + return { + markerSource, + pluginId, + pluginName, + pluginFile, + jobId, + firstMarkedAt, + lastMarkedAt, + lastStage, + stages, + byStage + }; +} + +function mergePluginExecutionState(existingState, marker) { + const existing = normalizePluginExecutionState(existingState); + const normalizedMarker = marker && typeof marker === 'object' ? marker : null; + if (!normalizedMarker) { + return existing; + } + + const stage = normalizeExecutionStage(normalizedMarker.stage); + const markedAt = String(normalizedMarker.markedAt || '').trim() || new Date().toISOString(); + const previousStageMeta = existing?.byStage?.[stage] || null; + const nextStageCount = Number(previousStageMeta?.count || 0) + 1; + const nextByStage = { + ...(existing?.byStage || {}), + [stage]: { + count: nextStageCount, + lastMarkedAt: markedAt + } + }; + const nextStages = Array.from(new Set([ + ...(Array.isArray(existing?.stages) ? existing.stages : []), + stage + ])); + + return { + markerSource: 'plugin-file', + pluginId: String(normalizedMarker.pluginId || existing?.pluginId || '').trim().toLowerCase() || 'unknown', + pluginName: String(normalizedMarker.pluginName || existing?.pluginName || '').trim() + || String(normalizedMarker.pluginId || existing?.pluginId || '').trim() + || 'unknown', + pluginFile: String(normalizedMarker.pluginFile || existing?.pluginFile || '').trim() || null, + jobId: Number.isFinite(Number(normalizedMarker.jobId)) && Number(normalizedMarker.jobId) > 0 + ? Math.trunc(Number(normalizedMarker.jobId)) + : (existing?.jobId || null), + firstMarkedAt: existing?.firstMarkedAt || markedAt, + lastMarkedAt: markedAt, + lastStage: stage, + stages: nextStages, + byStage: nextByStage + }; +} + +/** + * Kontext-Objekt, das an alle Plugin-Methoden weitergegeben wird. + * Kapselt den Zugriff auf alle Services, die ein Plugin benötigt, + * ohne direkte Abhängigkeiten auf Singletons zu erzwingen. + * + * Wird vom PluginOrchestrator befüllt und ist read-only für Plugins. + */ +class PluginContext { + /** + * @param {object} options + * @param {object} options.settings - settingsService (mit get(), getAll() etc.) + * @param {object} options.db - SQLite-Datenbankinstanz (getDb()) + * @param {object} options.logger - Logger-Instanz (child-Logger empfohlen) + * @param {object} options.websocket - websocketService (broadcast() etc.) + * @param {object} options.processRunner - processRunner (spawnTrackedProcess etc.) + * @param {Function} options.emitProgress - (progress: number, statusText: string, eta?: number) => void + * @param {Function} options.emitState - (newState: string, context?: object) => void + * @param {Function} options.onPluginExecution - Callback für echte Plugin-Datei-Ausführung + * @param {object} [options.extra] - Beliebige plugin-spezifische Extras + */ + constructor({ + settings, + db, + logger, + websocket, + processRunner, + emitProgress, + emitState, + onPluginExecution, + extra = {} + } = {}) { + this.settings = settings; + this.db = db; + this.logger = logger; + this.websocket = websocket; + this.processRunner = processRunner; + this.emitProgress = typeof emitProgress === 'function' ? emitProgress : () => {}; + this.emitState = typeof emitState === 'function' ? emitState : () => {}; + this.onPluginExecution = typeof onPluginExecution === 'function' ? onPluginExecution : () => {}; + this.extra = extra; + this.pluginExecution = null; + } + + markExecution(stage, payload = {}) { + const jobIdRaw = Number(payload?.jobId ?? this.extra?.jobId ?? this.extra?.job?.id ?? 0); + const marker = { + markerSource: 'plugin-file', + pluginId: String(payload?.pluginId || this.extra?.pluginId || '').trim().toLowerCase() || 'unknown', + pluginName: String(payload?.pluginName || '').trim() + || String(payload?.pluginId || this.extra?.pluginId || '').trim() + || 'unknown', + pluginFile: String(payload?.pluginFile || '').trim() || null, + jobId: Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null, + stage: normalizeExecutionStage(stage), + markedAt: new Date().toISOString() + }; + + this.pluginExecution = mergePluginExecutionState(this.pluginExecution, marker); + + try { + this.onPluginExecution(marker, this.getPluginExecution()); + } catch (error) { + if (this.logger && typeof this.logger.warn === 'function') { + this.logger.warn('plugin:execution:callback-failed', { + pluginId: marker.pluginId, + pluginFile: marker.pluginFile, + stage: marker.stage, + error: error?.message || String(error) + }); + } + } + + return this.getPluginExecution(); + } + + getPluginExecution() { + const normalized = normalizePluginExecutionState(this.pluginExecution); + if (!normalized) { + return null; + } + return { + ...normalized, + stages: [...normalized.stages], + byStage: Object.fromEntries( + Object.entries(normalized.byStage || {}).map(([stage, meta]) => [ + stage, + { + count: Number(meta?.count || 1), + lastMarkedAt: meta?.lastMarkedAt || null + } + ]) + ) + }; + } +} + +module.exports = { PluginContext }; diff --git a/backend/src/plugins/PluginRegistry.js b/backend/src/plugins/PluginRegistry.js new file mode 100644 index 0000000..081bcb2 --- /dev/null +++ b/backend/src/plugins/PluginRegistry.js @@ -0,0 +1,132 @@ +'use strict'; + +const { SourcePlugin } = require('./PluginBase'); +const logger = require('../services/logger').child('PLUGIN-REGISTRY'); + +/** + * Registry für alle Source-Plugins. + * Plugins werden beim Start registriert und anhand von detect() ausgewählt. + * + * Verwendung: + * const { registry } = require('./PluginRegistry'); + * registry.register(new BluRayPlugin()); + * const plugin = registry.findPlugin(discInfo); // → BluRayPlugin | null + */ +class PluginRegistry { + constructor() { + /** @type {Map} */ + this._plugins = new Map(); + } + + /** + * Registriert ein Plugin. + * Wirft einen Fehler wenn das übergebene Objekt keine SourcePlugin-Instanz ist. + * Überschreibt ein bereits vorhandenes Plugin mit gleicher ID (mit Warning). + * + * @param {SourcePlugin} plugin + */ + register(plugin) { + if (!(plugin instanceof SourcePlugin)) { + throw new Error( + `Plugin muss eine Instanz von SourcePlugin sein: ${plugin?.constructor?.name || typeof plugin}` + ); + } + const id = plugin.id; + if (!id || typeof id !== 'string') { + throw new Error(`Plugin muss eine nicht-leere String-ID haben (got: ${JSON.stringify(id)})`); + } + if (this._plugins.has(id)) { + logger.warn('registry:plugin-overwrite', { id, existingName: this._plugins.get(id).name }); + } + this._plugins.set(id, plugin); + logger.info('registry:plugin-registered', { id, name: plugin.name, priority: plugin.priority }); + } + + /** + * Findet das passende Plugin für eine erkannte Disc. + * + * Alle Plugins werden nach priority (absteigend) sortiert. Das erste, + * dessen detect() true zurückgibt, wird verwendet. + * Fehler in detect() werden geloggt und ignoriert (Plugin übersprungen). + * + * @param {object} discInfo - Disc-Informationen vom DiskDetectionService + * @returns {SourcePlugin|null} Passendes Plugin oder null wenn keines matched + */ + findPlugin(discInfo) { + const sorted = [...this._plugins.values()] + .sort((a, b) => (b.priority || 0) - (a.priority || 0)); + + for (const plugin of sorted) { + try { + if (plugin.detect(discInfo)) { + logger.debug('registry:plugin-matched', { id: plugin.id, discType: discInfo?.discType }); + return plugin; + } + } catch (error) { + logger.warn('registry:detect-error', { + id: plugin.id, + error: error?.message || String(error) + }); + } + } + + logger.warn('registry:no-plugin-matched', { discType: discInfo?.discType, filesystem: discInfo?.filesystem }); + return null; + } + + /** + * Gibt ein Plugin anhand seiner ID zurück. + * + * @param {string} id + * @returns {SourcePlugin|null} + */ + getPlugin(id) { + return this._plugins.get(id) ?? null; + } + + /** + * Gibt alle registrierten Plugins zurück (Reihenfolge: Registrierungsreihenfolge). + * + * @returns {SourcePlugin[]} + */ + getAllPlugins() { + return [...this._plugins.values()]; + } + + /** + * Aggregiert die Settings-Schemata aller registrierten Plugins. + * Kann genutzt werden, um Plugin-Settings dynamisch in die DB einzutragen. + * + * @returns {Array} + */ + getSettingsSchemas() { + const schemas = []; + for (const plugin of this._plugins.values()) { + try { + const pluginSchemas = plugin.getSettingsSchema(); + if (Array.isArray(pluginSchemas) && pluginSchemas.length > 0) { + schemas.push(...pluginSchemas); + } + } catch (error) { + logger.warn('registry:schema-error', { + id: plugin.id, + error: error?.message || String(error) + }); + } + } + return schemas; + } + + /** + * Gibt die Anzahl der registrierten Plugins zurück. + * @returns {number} + */ + get size() { + return this._plugins.size; + } +} + +// Modul-Level-Singleton — wird beim Start einmalig befüllt. +const registry = new PluginRegistry(); + +module.exports = { PluginRegistry, registry }; diff --git a/backend/src/plugins/VideoDiscPlugin.js b/backend/src/plugins/VideoDiscPlugin.js new file mode 100644 index 0000000..98e02fc --- /dev/null +++ b/backend/src/plugins/VideoDiscPlugin.js @@ -0,0 +1,544 @@ +'use strict'; + +const path = require('path'); +const { SourcePlugin } = require('./PluginBase'); +const tmdbService = require('../services/tmdbService'); +const { spawnTrackedProcess } = require('../services/processRunner'); +const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers'); +const { ensureDir } = require('../utils/files'); + +/** + * Gemeinsame Basisklasse für Blu-ray und DVD Plugins. + * + * Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien: + * analyze() → TMDb-Suche + Disc-Metadaten vorbereiten + * review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator) + * rip() → makemkvcon backup/mkv + * encode() → HandBrakeCLI + * + * Subklassen müssen implementieren: + * get id() → 'bluray' | 'dvd' + * get name() → Anzeigename + * get mediaProfile() → 'bluray' | 'dvd' + * detect(discInfo) → boolean + * + * ctx.extra-Felder für rip(): + * rawJobDir - Absoluter Pfad des RAW-Ausgabeordners + * deviceInfo - { path, index, mediaProfile } vom DiskDetectionService + * selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus) + * ripMode - 'backup' | 'mkv' (Default: aus Settings) + * backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD) + * isCancelled - () => boolean + * onProcessHandle - (handle) => void [optional] + * + * ctx.extra-Felder für encode(): + * inputPath - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis + * outputPath - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix) + * encodePlan - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review + * isCancelled - () => boolean + * onProcessHandle - (handle) => void [optional] + * + * ctx.extra-Felder für review(): + * deviceInfo - Disc-Device (für Pre-Rip-Scan) ODER + * rawJobDir - RAW-Ordner (für Post-Rip-Scan) + * reviewMode - 'pre_rip' | 'rip' (Default: 'pre_rip') + */ +class VideoDiscPlugin extends SourcePlugin { + /** + * Gibt das Media-Profil zurück, das settingsService-Methoden verwenden. + * Muss von Subklassen implementiert werden. + * @returns {'bluray'|'dvd'} + */ + get mediaProfile() { + throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`); + } + + /** + * Führt eine TMDb-Suche für den erkannten Disc-Titel durch. + * Gibt { detectedTitle, metadataCandidates } zurück. + * + * @param {string} devicePath + * @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten) + * @param {PluginContext} ctx + * @returns {Promise<{detectedTitle: string, metadataCandidates: Array}>} + */ + async analyze(devicePath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'VideoDiscPlugin.js' + }); + + const discInfo = ctx.extra?.discInfo || {}; + const detectedTitle = String( + discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc' + ).trim(); + + ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle }); + + const metadataCandidates = await tmdbService.searchMoviesWithDetails(detectedTitle).catch((error) => { + ctx.logger.warn(`${this.id}:analyze:tmdb-failed`, { + jobId: job?.id, + error: error?.message || String(error) + }); + return []; + }); + + ctx.logger.info(`${this.id}:analyze:done`, { + jobId: job?.id, + detectedTitle, + metadataCandidates: metadataCandidates.length + }); + + return { detectedTitle, metadataCandidates }; + } + + /** + * Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück. + * Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview). + * + * ctx.extra.reviewMode: + * 'pre_rip' → Scan direkt vom Laufwerk (deviceInfo) + * 'rip' → Scan aus dem RAW-Ordner (rawJobDir) + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise<{scanLines: string[], runInfo: object}|null>} + */ + async review(job, ctx) { + ctx.markExecution('review', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'VideoDiscPlugin.js' + }); + + const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase(); + const deviceInfo = ctx.extra?.deviceInfo || null; + const rawJobDir = ctx.extra?.rawJobDir || null; + + ctx.logger.info(`${this.id}:review:start`, { + jobId: job?.id, + reviewMode, + deviceInfo: deviceInfo?.path || null, + rawJobDir: rawJobDir || null + }); + + const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile); + + let scanConfig; + if (reviewMode === 'rip' && rawJobDir) { + // Post-Rip scan: scan from the ripped RAW folder + scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, { + mediaProfile: this.mediaProfile, + settingsMap: settings + }); + } else { + // Pre-Rip scan: scan directly from disc device + scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, { + mediaProfile: this.mediaProfile, + settingsMap: settings + }); + } + + ctx.logger.info(`${this.id}:review:scan-command`, { + jobId: job?.id, + cmd: scanConfig.cmd, + args: scanConfig.args + }); + + const scanLines = []; + const scanAnalysisLines = []; + const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; + const reviewStage = String(ctx.extra?.reviewStage || 'MEDIAINFO_CHECK').trim().toUpperCase() || 'MEDIAINFO_CHECK'; + const reviewSource = String(ctx.extra?.reviewSource || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN'; + const reviewSilent = Boolean(ctx.extra?.reviewSilent); + let runInfo; + if (runCommandFn) { + try { + runInfo = await runCommandFn({ + jobId: job?.id, + stage: reviewStage, + source: reviewSource, + cmd: scanConfig.cmd, + args: scanConfig.args, + collectLines: scanLines, + // Keep JSON parsing stable by collecting only stdout in scanLines. + // Stderr is still mirrored into scanAnalysisLines for heuristics. + collectStderrLines: false, + onStdoutLine: (line) => scanAnalysisLines.push(line), + onStderrLine: (line) => scanAnalysisLines.push(line), + silent: reviewSilent + }); + } catch (cmdError) { + // runCommand collected stdout lines via callbacks before throwing. + // Recover the runInfo from the error so the caller can still parse + // whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON). + runInfo = cmdError?.runInfo || { + status: 'ERROR', + exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1, + errorMessage: cmdError?.message || String(cmdError) + }; + } + } else { + runInfo = await _runCommandCaptured({ + cmd: scanConfig.cmd, + args: scanConfig.args, + onStdoutLine: (line) => { + scanLines.push(line); + scanAnalysisLines.push(line); + }, + onStderrLine: (line) => scanAnalysisLines.push(line), + context: { jobId: job?.id, source: `${this.id}Plugin.review` } + }); + } + + ctx.logger.info(`${this.id}:review:scan-done`, { + jobId: job?.id, + exitCode: runInfo.exitCode, + lineCount: scanAnalysisLines.length + }); + + // Return raw scan data — the Orchestrator (or legacy pipelineService) + // processes this with buildDiscScanReview() / parseMediainfoJsonOutput(). + return { + scanLines, + scanAnalysisLines, + runInfo, + reviewMode, + mediaProfile: this.mediaProfile, + sourceArg: scanConfig.sourceArg || null + }; + } + + /** + * Rippt die Disc mit makemkvcon in den rawJobDir. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} runInfo vom makemkvcon-Prozess + */ + async rip(job, ctx) { + ctx.markExecution('rip', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'VideoDiscPlugin.js' + }); + + const { + rawJobDir, + deviceInfo, + selectedTitleId = null, + backupOutputBase = null, + disableMinLengthFilter = false, + sourceArgOverride = null, + isCancelled, + onProcessHandle + } = ctx.extra || {}; + + if (!rawJobDir) { + throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`); + } + if (!deviceInfo) { + throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`); + } + + ensureDir(rawJobDir); + + const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile); + const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, { + selectedTitleId, + mediaProfile: this.mediaProfile, + settingsMap: settings, + backupOutputBase, + disableMinLengthFilter, + sourceArgOverride + }); + + const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv') + .trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; + + ctx.logger.info(`${this.id}:rip:start`, { + jobId: job?.id, + cmd: ripConfig.cmd, + args: ripConfig.args, + ripMode, + rawJobDir, + selectedTitleId, + disableMinLengthFilter, + sourceArgOverride: sourceArgOverride || null + }); + + ctx.emitProgress(0, ripMode === 'backup' + ? `${this.name}: Backup läuft …` + : `${this.name}: Ripping läuft …` + ); + + // Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand() + // des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log, + // Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests. + const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; + const makeMkvLineFilter = typeof ctx.extra?.makeMkvLineFilter === 'function' + ? ctx.extra.makeMkvLineFilter + : null; + let runInfo; + if (runCommandFn) { + runInfo = await runCommandFn({ + jobId: job?.id, + stage: 'RIPPING', + source: 'MAKEMKV_RIP', + cmd: ripConfig.cmd, + args: ripConfig.args, + parser: parseMakeMkvProgress, + lineFilter: makeMkvLineFilter + }); + } else { + runInfo = await _spawnAndWait({ + cmd: ripConfig.cmd, + args: ripConfig.args, + cwd: rawJobDir, + jobId: job?.id, + progressParser: parseMakeMkvProgress, + onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText), + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' } + }); + } + + ctx.logger.info(`${this.id}:rip:done`, { + jobId: job?.id, + rawJobDir, + exitCode: runInfo?.exitCode ?? runInfo?.code ?? null + }); + + ctx.extra.ripRunInfo = runInfo; + return runInfo; + } + + /** + * Encodiert die gerippten Dateien mit HandBrakeCLI. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} runInfo vom HandBrake-Prozess + */ + async encode(job, ctx) { + ctx.markExecution('encode', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'VideoDiscPlugin.js' + }); + + const { + inputPath, + outputPath, + encodePlan = {}, + isCancelled, + onProcessHandle + } = ctx.extra || {}; + + if (!inputPath) { + throw new Error(`${this.constructor.name}.encode(): ctx.extra.inputPath fehlt`); + } + if (!outputPath) { + throw new Error(`${this.constructor.name}.encode(): ctx.extra.outputPath fehlt`); + } + + ensureDir(path.dirname(outputPath)); + + const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile); + const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, { + trackSelection: encodePlan.trackSelection || null, + titleId: encodePlan.handBrakeTitleId || null, + mediaProfile: this.mediaProfile, + settingsMap: settings, + userPreset: encodePlan.userPreset || null + }); + + ctx.logger.info(`${this.id}:encode:start`, { + jobId: job?.id, + cmd: handBrakeConfig.cmd, + args: handBrakeConfig.args, + titleId: encodePlan.handBrakeTitleId || null + }); + + ctx.emitProgress(0, `${this.name}: Encoding läuft …`); + + const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; + const encodeSource = String(ctx.extra?.encodeSource || 'HANDBRAKE').trim().toUpperCase() || 'HANDBRAKE'; + const encodeStage = String(ctx.extra?.encodeStage || 'ENCODING').trim().toUpperCase() || 'ENCODING'; + const encodeParser = typeof ctx.extra?.progressParser === 'function' + ? ctx.extra.progressParser + : parseHandBrakeProgress; + + let runInfo; + if (runCommandFn) { + runInfo = await runCommandFn({ + jobId: job?.id, + stage: encodeStage, + source: encodeSource, + cmd: handBrakeConfig.cmd, + args: handBrakeConfig.args, + parser: encodeParser + }); + } else { + runInfo = await _spawnAndWait({ + cmd: handBrakeConfig.cmd, + args: handBrakeConfig.args, + jobId: job?.id, + progressParser: parseHandBrakeProgress, + onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`), + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' } + }); + } + + ctx.logger.info(`${this.id}:encode:done`, { + jobId: job?.id, + outputPath, + exitCode: runInfo.exitCode + }); + + ctx.extra.encodeRunInfo = runInfo; + return runInfo; + } + + /** + * Plugin-spezifische Settings (gemeinsam für BluRay + DVD, via this.mediaProfile). + */ + getSettingsSchema() { + const profile = this.mediaProfile; + const label = profile === 'bluray' ? 'Blu-ray' : 'DVD'; + const orderBase = profile === 'bluray' ? 200 : 220; + + return [ + { + key: `handbrake_preset_${profile}`, + category: 'Tools', + label: `HandBrake Preset (${label})`, + type: 'string', + required: 0, + description: `Preset Name für -Z (${label}). Leer = kein Preset, nur CLI-Parameter werden verwendet.`, + default_value: null, + options_json: '[]', + validation_json: '{}', + order_index: orderBase + }, + { + key: `output_extension_${profile}`, + category: 'Tools', + label: `Ausgabe-Format (${label})`, + type: 'select', + required: 1, + description: `Dateiendung des encodierten ${label}-Outputs.`, + default_value: 'mkv', + options_json: '["mkv","mp4"]', + validation_json: '{}', + order_index: orderBase + 1 + }, + { + key: `output_template_${profile}`, + category: 'Pfade', + label: `Output Template (${label})`, + type: 'string', + required: 1, + description: `Template für relative ${label}-Ausgabepfade ohne Dateiendung. Platzhalter: {title}, {year}. Unterordner über "/".`, + default_value: '{title} ({year})/{title} ({year})', + options_json: '[]', + validation_json: '{"minLength":1}', + order_index: 700 + (profile === 'bluray' ? 0 : 5) + } + ]; + } +} + +// ── Hilfsfunktionen (privat) ───────────────────────────────────────────────── + +function _assertNotCancelled(isCancelled) { + if (typeof isCancelled === 'function' && isCancelled()) { + const error = new Error('Job wurde vom Benutzer abgebrochen.'); + error.statusCode = 409; + throw error; + } +} + +async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) { + const lines = []; + const handle = spawnTrackedProcess({ + cmd, + args, + onStdoutLine: (line) => { + lines.push(line); + if (typeof onStdoutLine === 'function') { + onStdoutLine(line); + } + }, + onStderrLine: (line) => { + lines.push(line); + if (typeof onStderrLine === 'function') { + onStderrLine(line); + } + }, + context: context || {} + }); + + try { + await handle.promise; + return { exitCode: 0, lines }; + } catch (error) { + return { + exitCode: typeof error?.code === 'number' ? error.code : 1, + lines, + error: error?.message || String(error) + }; + } +} + +async function _spawnAndWait({ cmd, args, cwd, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) { + _assertNotCancelled(isCancelled); + + const handle = spawnTrackedProcess({ + cmd, + args, + cwd, + onStdoutLine: (line) => { + if (progressParser && typeof onProgress === 'function') { + const progress = progressParser(line); + if (progress?.percent != null) { + onProgress(progress.percent, progress.statusText || null); + } + } + }, + onStderrLine: (line) => { + if (progressParser && typeof onProgress === 'function') { + const progress = progressParser(line); + if (progress?.percent != null) { + onProgress(progress.percent, progress.statusText || null); + } + } + }, + context: context || { jobId } + }); + + if (typeof onProcessHandle === 'function') { + onProcessHandle(handle); + } + + if (typeof isCancelled === 'function' && isCancelled()) { + handle.cancel(); + } + + try { + return await handle.promise; + } catch (error) { + if (typeof isCancelled === 'function' && isCancelled()) { + const cancelError = new Error('Job wurde vom Benutzer abgebrochen.'); + cancelError.statusCode = 409; + throw cancelError; + } + throw error; + } +} + +module.exports = { VideoDiscPlugin }; diff --git a/backend/src/routes/converterRoutes.js b/backend/src/routes/converterRoutes.js new file mode 100644 index 0000000..9db5a82 --- /dev/null +++ b/backend/src/routes/converterRoutes.js @@ -0,0 +1,412 @@ +'use strict'; + +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const multer = require('multer'); +const asyncHandler = require('../middleware/asyncHandler'); +const pipelineService = require('../services/pipelineService'); +const converterScanService = require('../services/converterScanService'); +const historyService = require('../services/historyService'); +const logger = require('../services/logger').child('CONVERTER_ROUTE'); +const { tempDir } = require('../config'); + +/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */ +function resolveTarget(rawDir, input) { + const rel = converterScanService.normalizeRelPath(input != null ? String(input) : ''); + if (rel === null) return { error: 'Ungültiger Pfad' }; + const absolute = path.join(rawDir, rel || '.'); + return { rel: rel || '', absolute }; +} + +const router = express.Router(); +const converterUploadDir = path.join(tempDir, 'ripster-converter-uploads'); +fs.mkdirSync(converterUploadDir, { recursive: true }); + +const converterUpload = multer({ + dest: converterUploadDir +}); + +// ── Scan ────────────────────────────────────────────────────────────────── + +/** + * GET /api/converter/tree + * Vollständiger FS-Verzeichnisbaum des converter_raw_dir (keine DB). + */ +router.get( + '/tree', + asyncHandler(async (req, res) => { + logger.debug('get:tree'); + const result = await converterScanService.getTree(); + res.json(result); + }) +); + +/** + * GET /api/converter/browse?parent=relPath + * File-Explorer (DB-basiert, wird weiterhin für Job-Zuweisung gebraucht). + */ +router.get( + '/browse', + asyncHandler(async (req, res) => { + const parent = req.query.parent ? String(req.query.parent).trim() : null; + logger.debug('get:browse', { parent }); + const entries = await converterScanService.getEntries(parent); + const rawDir = await converterScanService.getRawDir(); + res.json({ entries, rawDir }); + }) +); + +/** + * POST /api/converter/scan + * Manuellen Scan des converter_raw_dir auslösen. + */ +router.post( + '/scan', + asyncHandler(async (req, res) => { + logger.info('post:scan'); + const result = await converterScanService.scan(); + res.json({ result }); + }) +); + +// ── Jobs erstellen ──────────────────────────────────────────────────────── + +/** + * POST /api/converter/create-jobs + * Body: { entries: [{ relPath, converterMediaType }] } + * Erstellt Jobs aus Scan-Einträgen (File-Explorer-Auswahl). + */ +router.post( + '/create-jobs', + asyncHandler(async (req, res) => { + const entries = Array.isArray(req.body?.entries) ? req.body.entries : []; + if (entries.length === 0) { + const error = new Error('Keine Einträge ausgewählt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:create-jobs', { count: entries.length }); + + const jobs = []; + for (const entry of entries) { + const relPath = String(entry?.relPath || '').trim(); + if (!relPath) continue; + const job = await pipelineService.createFileJob({ + kind: 'converter_entry', + relPath, + options: { + converterMediaType: entry?.converterMediaType || null + } + }); + jobs.push(job); + } + res.json({ jobs }); + }) +); + +/** + * POST /api/converter/upload + * Datei(en) hochladen → Unterordner anlegen (kein Job-Anlegen). + * Gibt { folders: [{folderRelPath, fileCount}] } zurück. + */ +router.post( + '/upload', + converterUpload.array('files', 50), + asyncHandler(async (req, res) => { + const files = Array.isArray(req.files) ? req.files : []; + if (files.length === 0) { + const error = new Error('Keine Dateien hochgeladen.'); + error.statusCode = 400; + throw error; + } + const folderName = req.body?.folderName ? String(req.body.folderName).trim() : null; + logger.info('post:upload', { + fileCount: files.length, + folderName, + files: files.map((f) => ({ name: f.originalname, size: f.size })) + }); + const result = await pipelineService.uploadConverterFiles(files, { folderName }); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/from-selection + * Body: { relPaths: string[], audioMode: 'individual'|'shared' } + * Erstellt Jobs aus im File-Explorer ausgewählten Dateien. + */ +router.post( + '/jobs/from-selection', + asyncHandler(async (req, res) => { + const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : []; + const audioMode = String(req.body?.audioMode || 'individual'); + if (relPaths.length === 0) { + const error = new Error('Keine Dateien ausgewählt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:jobs:from-selection', { count: relPaths.length, audioMode }); + const jobs = await pipelineService.createConverterJobsFromSelection(relPaths, audioMode); + res.json({ jobs }); + }) +); + +/** + * POST /api/converter/jobs/:jobId/assign-files + * Body: { relPaths: string[] } + * Fügt ausgewählte Dateien einem bestehenden (nicht gestarteten) Converter-Job hinzu. + */ +router.post( + '/jobs/:jobId/assign-files', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : []; + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (relPaths.length === 0) { + return res.status(400).json({ detail: 'Keine Dateien übergeben.' }); + } + logger.info('post:jobs:assign-files', { jobId, count: relPaths.length }); + const result = await pipelineService.assignConverterFilesToJob(jobId, relPaths); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/remove-file + * Body: { relPath: string } + * Entfernt eine Datei aus einem bestehenden Converter-Job. + */ +router.post( + '/jobs/:jobId/remove-file', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const relPath = String(req.body?.relPath || '').trim(); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (!relPath) { + return res.status(400).json({ detail: 'relPath fehlt.' }); + } + logger.info('post:jobs:remove-file', { jobId, relPath }); + const result = await pipelineService.removeConverterFileFromJob(jobId, relPath); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/remove-input + * Body: { inputPath: string } + * Entfernt eine Datei aus einem Converter-Job anhand des absoluten Input-Pfads. + */ +router.post( + '/jobs/:jobId/remove-input', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const inputPath = String(req.body?.inputPath || '').trim(); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (!inputPath) { + return res.status(400).json({ detail: 'inputPath fehlt.' }); + } + logger.info('post:jobs:remove-input', { jobId, inputPath }); + const result = await pipelineService.removeConverterInputFromJob(jobId, inputPath); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/config + * Body: partial config draft (outputFormat, presets, metadata, tracks, MusicBrainz-UI-Stand) + * Speichert den Draft für READY_TO_START Jobs persistent im encode_plan_json. + */ +router.post( + '/jobs/:jobId/config', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + logger.debug('post:jobs:config', { jobId }); + const result = await pipelineService.updateConverterJobConfig(jobId, req.body || {}); + res.json(result); + }) +); + +// ── Datei-Operationen (reines FS, keine DB) ─────────────────────────────── + +/** + * DELETE /api/converter/files + * Body: { relPath } + * Datei oder Ordner löschen (fs.rmSync, ohne DB). + */ +router.delete( + '/files', + asyncHandler(async (req, res) => { + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' }); + const relPath = String(req.body?.relPath || '').trim(); + if (!relPath) return res.status(400).json({ detail: 'relPath fehlt.' }); + const target = resolveTarget(rawDir, relPath); + if (target.error || !target.rel) return res.status(400).json({ detail: target.error || 'Ungültiger Pfad.' }); + logger.info('delete:files', { relPath }); + fs.rmSync(target.absolute, { recursive: true, force: true }); + res.json({ ok: true }); + }) +); + +/** + * POST /api/converter/files/rename + * Body: { relPath, newName } + * Umbenennen (fs.renameSync, ohne DB). + */ +router.post( + '/files/rename', + asyncHandler(async (req, res) => { + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' }); + const relPath = String(req.body?.relPath || '').trim(); + const newName = String(req.body?.newName || '').trim(); + if (!relPath || !newName) return res.status(400).json({ detail: 'relPath und newName erforderlich.' }); + if (newName.includes('/') || newName.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' }); + const source = resolveTarget(rawDir, relPath); + if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' }); + const parentAbs = path.dirname(source.absolute); + const destAbs = path.join(parentAbs, newName); + logger.info('post:files:rename', { relPath, newName }); + fs.renameSync(source.absolute, destAbs); + res.json({ ok: true }); + }) +); + +/** + * POST /api/converter/files/move + * Body: { relPath, targetParentRelPath } + * Verschieben (fs.renameSync, ohne DB). targetParentRelPath = '' → Root. + */ +router.post( + '/files/move', + asyncHandler(async (req, res) => { + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' }); + const relPath = String(req.body?.relPath || '').trim(); + if (!relPath) return res.status(400).json({ detail: 'relPath erforderlich.' }); + const targetParentRelPath = req.body?.targetParentRelPath != null ? String(req.body.targetParentRelPath) : ''; + const source = resolveTarget(rawDir, relPath); + if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' }); + const targetParent = resolveTarget(rawDir, targetParentRelPath); + if (targetParent.error) return res.status(400).json({ detail: targetParent.error }); + const name = path.basename(source.absolute); + const destAbs = path.join(targetParent.absolute, name); + logger.info('post:files:move', { relPath, targetParentRelPath }); + fs.renameSync(source.absolute, destAbs); + res.json({ ok: true }); + }) +); + +/** + * POST /api/converter/files/folder + * Body: { parentRelPath, name } + * Neuen Ordner anlegen (fs.mkdirSync, ohne DB). + */ +router.post( + '/files/folder', + asyncHandler(async (req, res) => { + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' }); + const parentRelPath = req.body?.parentRelPath != null ? String(req.body.parentRelPath) : ''; + const name = String(req.body?.name || '').trim(); + if (!name || name.includes('/') || name.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' }); + const parent = resolveTarget(rawDir, parentRelPath); + if (parent.error) return res.status(400).json({ detail: parent.error }); + logger.info('post:files:folder', { parentRelPath, name }); + fs.mkdirSync(path.join(parent.absolute, name), { recursive: true }); + res.json({ ok: true }); + }) +); + +// ── Job-Status ──────────────────────────────────────────────────────────── + +/** + * GET /api/converter/jobs + * Alle Converter-Jobs zurückgeben. + */ +router.get( + '/jobs', + asyncHandler(async (req, res) => { + const jobs = await pipelineService.getConverterJobs(); + res.json({ jobs }); + }) +); + +/** + * GET /api/converter/jobs/:jobId + * Einzelnen Converter-Job abrufen. + */ +router.get( + '/jobs/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + res.json({ job }); + }) +); + +/** + * POST /api/converter/jobs/:jobId/start + * Job mit finaler Konfiguration starten. + * Body: { converterMediaType, outputFormat, userPreset, trackSelection, handBrakeTitleId, audioFormatOptions } + */ +router.post( + '/jobs/:jobId/start', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const config = req.body || {}; + logger.info('post:jobs:start', { + jobId, + converterMediaType: config.converterMediaType, + outputFormat: config.outputFormat + }); + const result = await pipelineService.startConverterJob(jobId, config); + res.json({ result }); + }) +); + +/** + * POST /api/converter/jobs/:jobId/cancel + * Job abbrechen. + */ +router.post( + '/jobs/:jobId/cancel', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:jobs:cancel', { jobId }); + const result = await pipelineService.cancel(jobId); + res.json({ result }); + }) +); + +/** + * DELETE /api/converter/jobs/:jobId + * Job aus der DB löschen. + */ +router.delete( + '/jobs/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('delete:jobs', { jobId }); + await historyService.deleteJob(jobId, 'none', { includeRelated: false }); + await converterScanService.clearAssignmentsForJob(jobId); + res.json({ ok: true }); + }) +); + +module.exports = router; diff --git a/backend/src/routes/cronRoutes.js b/backend/src/routes/cronRoutes.js new file mode 100644 index 0000000..3b9ab59 --- /dev/null +++ b/backend/src/routes/cronRoutes.js @@ -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; diff --git a/backend/src/routes/downloadRoutes.js b/backend/src/routes/downloadRoutes.js new file mode 100644 index 0000000..48eb3c0 --- /dev/null +++ b/backend/src/routes/downloadRoutes.js @@ -0,0 +1,71 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const downloadService = require('../services/downloadService'); +const logger = require('../services/logger').child('DOWNLOAD_ROUTE'); + +const router = express.Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + logger.debug('get:downloads', { reqId: req.reqId }); + const items = await downloadService.listItems(); + res.json({ + items, + summary: downloadService.getSummary() + }); + }) +); + +router.get( + '/summary', + asyncHandler(async (req, res) => { + await downloadService.init(); + res.json({ summary: downloadService.getSummary() }); + }) +); + +router.post( + '/history/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const target = String(req.body?.target || 'raw').trim(); + const outputPath = String(req.body?.outputPath || '').trim() || null; + logger.info('post:downloads:history', { + reqId: req.reqId, + jobId, + target, + outputPath + }); + const result = await downloadService.enqueueHistoryJob(jobId, target, { outputPath }); + res.status(result.created ? 201 : 200).json({ + ...result, + summary: downloadService.getSummary() + }); + }) +); + +router.get( + '/:id/file', + asyncHandler(async (req, res) => { + const descriptor = await downloadService.getDownloadDescriptor(req.params.id); + res.download(descriptor.path, descriptor.archiveName); + }) +); + +router.delete( + '/:id', + asyncHandler(async (req, res) => { + logger.info('delete:downloads:item', { + reqId: req.reqId, + id: req.params.id + }); + const result = await downloadService.deleteItem(req.params.id); + res.json({ + ...result, + summary: downloadService.getSummary() + }); + }) +); + +module.exports = router; diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js new file mode 100644 index 0000000..cf1f5a1 --- /dev/null +++ b/backend/src/routes/historyRoutes.js @@ -0,0 +1,462 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const historyService = require('../services/historyService'); +const pipelineService = require('../services/pipelineService'); +const logger = require('../services/logger').child('HISTORY_ROUTE'); + +const router = express.Router(); + +function parseSelectedJobIds(value, options = {}) { + const { hasExplicitValue = true } = options; + if (!hasExplicitValue) { + return null; + } + const sourceValues = Array.isArray(value) + ? value + : String(value || '') + .split(','); + return sourceValues + .map((entry) => Number(entry)) + .filter((entry) => Number.isFinite(entry) && entry > 0) + .map((entry) => Math.trunc(entry)); +} + +router.get( + '/', + asyncHandler(async (req, res) => { + const parsedLimit = Number(req.query.limit); + const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.trunc(parsedLimit) + : null; + const statuses = String(req.query.statuses || '') + .split(',') + .map((value) => String(value || '').trim()) + .filter(Boolean); + const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase()); + const includeChildren = ['1', 'true', 'yes'].includes(String(req.query.includeChildren || '').toLowerCase()); + logger.info('get:jobs', { + reqId: req.reqId, + status: req.query.status, + statuses: statuses.length > 0 ? statuses : null, + search: req.query.search, + limit, + lite, + includeChildren + }); + + const jobs = await historyService.getJobs({ + status: req.query.status, + statuses, + search: req.query.search, + limit, + includeFsChecks: !lite, + includeChildren + }); + + res.json({ jobs }); + }) +); + +router.get( + '/orphan-raw', + asyncHandler(async (req, res) => { + logger.info('get:orphan-raw', { reqId: req.reqId }); + const result = await historyService.getOrphanRawFolders(); + res.json(result); + }) +); + +router.get( + '/tmdb-migration/pending', + asyncHandler(async (req, res) => { + const parsedLimit = Number(req.query.limit); + const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.trunc(parsedLimit) + : 500; + logger.info('get:tmdb-migration:pending', { + reqId: req.reqId, + limit + }); + const jobs = await historyService.getTmdbMigrationPendingJobs({ + limit, + includeFsChecks: false + }); + res.json({ jobs }); + }) +); + +router.post( + '/:id/tmdb-migration', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const migrated = ['1', 'true', 'yes', 'on'].includes( + String(req.body?.migrated ?? req.body?.migrateTMDB ?? '').trim().toLowerCase() + ); + logger.info('post:tmdb-migration:flag', { + reqId: req.reqId, + id, + migrated + }); + const job = await historyService.setTmdbMigrationFlag(id, migrated); + res.json({ job }); + }) +); + +router.post( + '/orphan-raw/import', + asyncHandler(async (req, res) => { + const rawPath = String(req.body?.rawPath || '').trim(); + logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath }); + const importedJob = await historyService.importOrphanRawFolder(rawPath); + const importedJobId = Number(importedJob?.id || 0); + let activation = null; + let activationError = null; + if (Number.isFinite(importedJobId) && importedJobId > 0) { + try { + activation = await pipelineService.analyzeRawImportJob(importedJobId, { + rawPath: importedJob?.raw_path || rawPath + }); + } catch (error) { + activationError = error?.message || String(error); + logger.warn('post:orphan-raw:import:activation-failed', { + reqId: req.reqId, + jobId: importedJobId, + rawPath, + error: activationError + }); + } + } + + const refreshedJob = importedJobId > 0 + ? await historyService.getJobById(importedJobId) + : null; + + res.json({ + job: refreshedJob || importedJob, + activation, + ...(activationError ? { activationError } : {}) + }); + }) +); + +router.post( + '/orphan-raw/delete', + asyncHandler(async (req, res) => { + const rawPath = String(req.body?.rawPath || '').trim(); + logger.warn('post:orphan-raw:delete', { reqId: req.reqId, rawPath }); + const result = await historyService.deleteOrphanRawFolder(rawPath); + res.json(result); + }) +); + +router.post( + '/:id/metadata/assign', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const payload = req.body || {}; + logger.info('post:job:metadata:assign', { + reqId: req.reqId, + id, + imdbId: payload?.imdbId || null, + hasTitle: Boolean(payload?.title), + hasYear: Boolean(payload?.year) + }); + + const job = await historyService.assignMetadata(id, payload); + + // Rename raw/output folders to reflect new metadata (best-effort, non-blocking) + pipelineService.renameJobFolders(id).catch((err) => { + logger.warn('post:job:metadata:assign:rename-failed', { id, error: err.message }); + }); + + res.json({ job }); + }) +); + +router.post( + '/:id/cd/assign', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const payload = req.body || {}; + logger.info('post:job:cd:assign', { + reqId: req.reqId, + id, + mbId: payload?.mbId || null, + hasTitle: Boolean(payload?.title), + hasArtist: Boolean(payload?.artist), + trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0 + }); + + const job = await pipelineService.selectCdMetadata({ + jobId: id, + title: payload?.title, + artist: payload?.artist, + year: payload?.year, + mbId: payload?.mbId, + coverUrl: payload?.coverUrl, + tracks: payload?.tracks + }); + + // Rename raw/output folders to reflect new metadata (best-effort, non-blocking) + pipelineService.renameJobFolders(id).catch((err) => { + logger.warn('post:job:cd:assign:rename-failed', { id, error: err.message }); + }); + + res.json({ job }); + }) +); + +router.post( + '/:id/error/ack', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + logger.info('post:job:error:ack', { reqId: req.reqId, id }); + const job = await historyService.acknowledgeJobError(id); + res.json({ job }); + }) +); + +router.post( + '/:id/nfo/generate', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + logger.info('post:job:nfo:generate', { reqId: req.reqId, id }); + const result = await historyService.generateJobNfo(id, { + mode: 'manual', + requireSettingDisabled: true, + failIfExists: true, + failIfOutputMissing: true + }); + const job = await historyService.getJobWithLogs(id, { includeFsChecks: true }); + res.json({ result, job }); + }) +); + +router.post( + '/:id/delete-files', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const target = String(req.body?.target || 'both'); + const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase()); + const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, { + hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds') + }); + const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths) + ? req.body.selectedRawPaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths) + ? req.body.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + + logger.warn('post:delete-files', { + reqId: req.reqId, + id, + target, + includeRelated, + selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0, + selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0, + selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0 + }); + + const result = await historyService.deleteJobFiles(id, target, { + includeRelated, + selectedJobIds, + selectedRawPaths, + selectedMoviePaths + }); + res.json(result); + }) +); + +router.get( + '/:id/delete-preview', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase()); + const selectedJobIds = parseSelectedJobIds(req.query.selectedJobIds, { + hasExplicitValue: Object.prototype.hasOwnProperty.call(req.query || {}, 'selectedJobIds') + }); + + logger.info('get:delete-preview', { + reqId: req.reqId, + id, + includeRelated, + selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0 + }); + + const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds }); + res.json({ preview }); + }) +); + +router.post( + '/:id/delete', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const target = String(req.body?.target || 'none'); + const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase()); + const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, { + hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds') + }); + const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase()); + const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase()); + const hasRequestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined; + const requestedKeepDetectedDevice = hasRequestedKeepDetectedDevice + ? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase()) + : null; + const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths) + ? req.body.selectedRawPaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths) + ? req.body.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + + logger.warn('post:delete-job', { + reqId: req.reqId, + id, + target, + includeRelated, + selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0, + requestedResetDriveState, + preserveRawForImportJobs, + requestedKeepDetectedDevice, + selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0, + selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0 + }); + + const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds }); + const containsOrphanRawImportJob = Boolean( + preview?.flags?.containsOrphanRawImportJob + || (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some( + (row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport) + )) + ); + const selectedDeleteJobIds = Array.isArray(preview?.selectedJobIds) + ? preview.selectedJobIds + : [id]; + const selectedDeleteJobs = await historyService.getJobsByIds(selectedDeleteJobIds).catch(() => []); + const autoResetDriveStateForPhysicalCd = Array.isArray(selectedDeleteJobs) && selectedDeleteJobs.some((job) => { + const mediaType = String(job?.media_type || job?.mediaType || '').trim().toLowerCase(); + const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const status = String(job?.status || '').trim().toUpperCase(); + const lastState = String(job?.last_state || '').trim().toUpperCase(); + const isCdJob = mediaType === 'cd' + || jobKind === 'cd' + || status.startsWith('CD_') + || lastState.startsWith('CD_'); + if (!isCdJob) { + return false; + } + const discDevice = String(job?.disc_device || job?.discDevice || '').trim(); + return Boolean(discDevice) && !discDevice.startsWith('__virtual__'); + }); + + const requestedResetDriveStateEffective = requestedResetDriveState || autoResetDriveStateForPhysicalCd; + const resetDriveState = containsOrphanRawImportJob + ? false + : requestedResetDriveStateEffective; + const keepDetectedDevice = containsOrphanRawImportJob + ? true + : (hasRequestedKeepDetectedDevice + ? requestedKeepDetectedDevice + : !requestedResetDriveStateEffective); + + if (containsOrphanRawImportJob && requestedResetDriveState) { + logger.info('post:delete-job:orphan-drive-reset-skipped', { + reqId: req.reqId, + id + }); + } + + if (autoResetDriveStateForPhysicalCd && !requestedResetDriveState) { + logger.info('post:delete-job:auto-drive-reset-cd', { + reqId: req.reqId, + id, + selectedJobIds: selectedDeleteJobIds + }); + } + + const relatedDevicePaths = Array.isArray(preview?.relatedJobs) + ? preview.relatedJobs + .filter((row) => Boolean(row?.selected)) + .map((row) => String(row?.discDevice || '').trim()) + .filter(Boolean) + : []; + const result = await historyService.deleteJob(id, target, { + includeRelated, + selectedJobIds, + selectedRawPaths, + selectedMoviePaths, + preserveRawForImportJobs + }); + await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], { + resetDriveState, + devicePaths: relatedDevicePaths + }).catch((error) => { + logger.warn('post:delete-job:cleanup-failed', { id, error: error?.message || String(error) }); + }); + const uiReset = await pipelineService.resetFrontendState('history_delete', { + keepDetectedDevice + }); + res.json({ + ...result, + uiReset, + safeguards: { + containsOrphanRawImportJob, + resetDriveStateApplied: resetDriveState, + keepDetectedDeviceApplied: keepDetectedDevice + } + }); + }) +); + +router.get( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase()); + const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase()); + const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase()); + const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase()); + const parsedTail = Number(req.query.logTailLines); + const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 + ? Math.trunc(parsedTail) + : null; + const includeFsChecks = !(lite || includeLiveLog); + + logger.info('get:job-detail', { + reqId: req.reqId, + id, + includeLiveLog, + includeLogs, + includeAllLogs, + logTailLines, + lite, + includeFsChecks + }); + const job = await historyService.getJobWithLogs(id, { + includeLiveLog, + includeLogs, + includeAllLogs, + logTailLines, + includeFsChecks + }); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + res.json({ job }); + }) +); + +module.exports = router; diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js new file mode 100644 index 0000000..5bd9092 --- /dev/null +++ b/backend/src/routes/pipelineRoutes.js @@ -0,0 +1,766 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const multer = require('multer'); +const asyncHandler = require('../middleware/asyncHandler'); +const pipelineService = require('../services/pipelineService'); +const historyService = require('../services/historyService'); +const diskDetectionService = require('../services/diskDetectionService'); +const hardwareMonitorService = require('../services/hardwareMonitorService'); +const settingsService = require('../services/settingsService'); +const logger = require('../services/logger').child('PIPELINE_ROUTE'); +const activationBytesService = require('../services/activationBytesService'); +const { tempDir } = require('../config'); +const { getDb } = require('../db/database'); + +const router = express.Router(); +const audiobookUploadDir = path.join(tempDir, 'ripster-audiobook-uploads'); +fs.mkdirSync(audiobookUploadDir, { recursive: true }); +const audiobookUpload = multer({ + dest: audiobookUploadDir +}); + +router.get( + '/state', + asyncHandler(async (req, res) => { + logger.debug('get:state', { reqId: req.reqId }); + res.json({ + pipeline: pipelineService.getSnapshot(), + hardwareMonitoring: hardwareMonitorService.getSnapshot() + }); + }) +); + +router.get( + '/hardware/history', + asyncHandler(async (req, res) => { + const hoursRaw = Number(req.query?.hours); + const maxPointsRaw = Number(req.query?.maxPoints); + const hours = Number.isFinite(hoursRaw) ? Math.max(1, Math.min(96, Math.trunc(hoursRaw))) : 24; + const maxPoints = Number.isFinite(maxPointsRaw) ? Math.max(120, Math.min(5000, Math.trunc(maxPointsRaw))) : 720; + const sinceIso = new Date(Date.now() - (hours * 60 * 60 * 1000)).toISOString(); + const db = await getDb(); + const rows = await db.all( + ` + SELECT + captured_at, + cpu_usage_percent, + cpu_temperature_c, + ram_usage_percent, + ram_used_bytes, + ram_total_bytes, + gpu_usage_percent, + gpu_temperature_c, + vram_used_bytes, + vram_total_bytes + FROM hardware_metrics_history + WHERE captured_at >= ? + ORDER BY captured_at ASC + `, + [sinceIso] + ); + + const stride = rows.length > maxPoints ? Math.ceil(rows.length / maxPoints) : 1; + const sampled = []; + for (let index = 0; index < rows.length; index += stride) { + sampled.push(rows[index]); + } + if (rows.length > 0 && sampled[sampled.length - 1] !== rows[rows.length - 1]) { + sampled.push(rows[rows.length - 1]); + } + + const points = sampled.map((row) => ({ + capturedAt: row.captured_at, + cpuUsagePercent: row.cpu_usage_percent, + cpuTemperatureC: row.cpu_temperature_c, + ramUsagePercent: row.ram_usage_percent, + ramUsedBytes: row.ram_used_bytes, + ramTotalBytes: row.ram_total_bytes, + gpuUsagePercent: row.gpu_usage_percent, + gpuTemperatureC: row.gpu_temperature_c, + vramUsedBytes: row.vram_used_bytes, + vramTotalBytes: row.vram_total_bytes + })); + + res.json({ + history: { + hours, + maxPoints, + totalPoints: rows.length, + points + } + }); + }) +); + +router.post( + '/analyze', + asyncHandler(async (req, res) => { + const devicePath = String(req.body?.devicePath || '').trim() || null; + logger.info('post:analyze', { reqId: req.reqId, devicePath }); + const result = await pipelineService.analyzeDisc(devicePath); + res.json({ result }); + }) +); + +router.get( + '/cd/drives', + asyncHandler(async (req, res) => { + logger.debug('get:cd:drives', { reqId: req.reqId }); + const snapshot = pipelineService.getSnapshot(); + res.json({ cdDrives: snapshot.cdDrives || {} }); + }) +); + +router.post( + '/rescan-disc', + asyncHandler(async (req, res) => { + logger.info('post:rescan-disc', { reqId: req.reqId }); + const result = await diskDetectionService.rescanAndEmit(); + res.json({ result }); + }) +); + +router.post( + '/rescan-drive', + asyncHandler(async (req, res) => { + const devicePath = String(req.body?.devicePath || '').trim(); + logger.info('post:rescan-drive', { reqId: req.reqId, devicePath }); + if (!devicePath) { + const err = new Error('devicePath ist erforderlich'); + err.statusCode = 400; + throw err; + } + const result = await diskDetectionService.rescanDriveAndEmit(devicePath); + res.json({ result }); + }) +); + +router.get( + '/tmdb/movie/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + logger.info('get:tmdb:movie-search', { reqId: req.reqId, query }); + const results = await pipelineService.searchTmdbMovies(String(query)); + res.json({ results }); + }) +); + +router.get( + '/tmdb/series/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + const seasonNumber = req.query.season || null; + logger.info('get:tmdb:series-search', { reqId: req.reqId, query, seasonNumber }); + const results = await pipelineService.searchTmdbSeries(String(query), seasonNumber); + res.json({ results }); + }) +); + +router.get( + '/cd/musicbrainz/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + const trackCountRaw = Number(req.query.trackCount); + const trackCount = Number.isFinite(trackCountRaw) && trackCountRaw > 0 + ? Math.trunc(trackCountRaw) + : null; + logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query, trackCount }); + const results = await pipelineService.searchMusicBrainz(String(query), { trackCount }); + res.json({ results }); + }) +); + +router.get( + '/cd/musicbrainz/release/:mbId', + asyncHandler(async (req, res) => { + const mbId = String(req.params.mbId || '').trim(); + if (!mbId) { + const error = new Error('mbId fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('get:cd:musicbrainz:release', { reqId: req.reqId, mbId }); + const release = await pipelineService.getMusicBrainzReleaseById(mbId); + res.json({ release }); + }) +); + +router.post( + '/cd/select-metadata', + asyncHandler(async (req, res) => { + const { jobId, title, artist, year, mbId, coverUrl, tracks } = req.body; + if (!jobId) { + const error = new Error('jobId fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:cd:select-metadata', { reqId: req.reqId, jobId, title, artist, year, mbId }); + const job = await pipelineService.selectCdMetadata({ + jobId: Number(jobId), + title, + artist, + year, + mbId, + coverUrl, + tracks + }); + res.json({ job }); + }) +); + +router.post( + '/cd/start/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const ripConfig = req.body || {}; + logger.info('post:cd:start', { + reqId: req.reqId, + jobId, + format: ripConfig.format, + selectedPreEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPreEncodeScriptIds) + ? ripConfig.selectedPreEncodeScriptIds.length + : 0, + selectedPostEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPostEncodeScriptIds) + ? ripConfig.selectedPostEncodeScriptIds.length + : 0, + selectedPreEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPreEncodeChainIds) + ? ripConfig.selectedPreEncodeChainIds.length + : 0, + selectedPostEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPostEncodeChainIds) + ? ripConfig.selectedPostEncodeChainIds.length + : 0 + }); + const result = await pipelineService.enqueueOrStartCdAction( + jobId, + ripConfig, + () => pipelineService.startCdRip(jobId, ripConfig) + ); + res.json({ result }); + }) +); + +router.post( + '/audiobook/upload', + audiobookUpload.single('file'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('Upload-Datei fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:audiobook:upload', { + reqId: req.reqId, + originalName: req.file.originalname, + sizeBytes: Number(req.file.size || 0), + mimeType: String(req.file.mimetype || '').trim() || null, + tempPath: String(req.file.path || '').trim() || null + }); + const result = await pipelineService.createFileJob({ + kind: 'audiobook_upload', + file: req.file, + options: { + format: req.body?.format, + startImmediately: req.body?.startImmediately + } + }); + res.json({ result }); + }) +); + +router.get( + '/audiobook/pending-activation', + asyncHandler(async (req, res) => { + const db = await getDb(); + // Jobs die eine Checksum haben, aber noch keine Activation Bytes im Cache + const pending = await db.all(` + SELECT j.id AS jobId, j.aax_checksum AS checksum + FROM jobs j + WHERE j.aax_checksum IS NOT NULL + AND j.status NOT IN ('DONE', 'ERROR', 'CANCELLED') + AND NOT EXISTS ( + SELECT 1 FROM aax_activation_bytes ab WHERE ab.checksum = j.aax_checksum + ) + ORDER BY j.created_at DESC + `); + res.json({ pending }); + }) +); + +router.post( + '/audiobook/start/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const config = req.body || {}; + logger.info('post:audiobook:start', { + reqId: req.reqId, + jobId, + format: config?.format, + formatOptions: config?.formatOptions && typeof config.formatOptions === 'object' + ? config.formatOptions + : null + }); + const result = await pipelineService.startAudiobookWithConfig(jobId, config); + res.json({ result }); + }) +); + +router.get( + '/audiobook/jobs', + asyncHandler(async (_req, res) => { + const jobs = await pipelineService.getAudiobookJobs(); + res.json({ jobs }); + }) +); + +router.post( + '/select-metadata', + asyncHandler(async (req, res) => { + const { + jobId, + title, + artist, + year, + imdbId, + poster, + mbId, + coverUrl, + tracks, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds, + metadataProvider, + providerId, + tmdbId, + metadataKind, + workflowKind, + seasonNumber, + seasonName, + episodeCount, + episodes, + discNumber, + duplicateAction, + existingJobId, + existingDiscNumber + } = req.body; + + if (!jobId) { + const error = new Error('jobId fehlt.'); + error.statusCode = 400; + throw error; + } + + logger.info('post:select-metadata', { + reqId: req.reqId, + jobId, + title, + artist, + year, + imdbId, + poster, + mbId, + coverUrl, + trackCount: Array.isArray(tracks) ? tracks.length : null, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, + metadataProvider, + providerId, + tmdbId, + workflowKind, + seasonNumber, + discNumber, + duplicateAction, + existingJobId, + existingDiscNumber + }); + + const looksLikeCdMetadataPayload = ( + artist !== undefined + || mbId !== undefined + || coverUrl !== undefined + || Array.isArray(tracks) + ); + if (looksLikeCdMetadataPayload) { + logger.warn('post:select-metadata:compat-cd-route', { + reqId: req.reqId, + jobId, + trackCount: Array.isArray(tracks) ? tracks.length : 0 + }); + const job = await pipelineService.selectCdMetadata({ + jobId: Number(jobId), + title, + artist, + year, + mbId, + coverUrl: coverUrl || poster || null, + tracks + }); + res.json({ job }); + return; + } + + const job = await pipelineService.selectMetadata({ + jobId: Number(jobId), + title, + year, + imdbId, + poster, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds, + metadataProvider, + providerId, + tmdbId, + metadataKind, + workflowKind, + seasonNumber, + seasonName, + episodeCount, + episodes, + discNumber, + duplicateAction, + existingJobId, + existingDiscNumber + }); + + res.json({ job }); + }) +); + +router.post( + '/jobs/:jobId/raw-decision', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const { decision } = req.body; + logger.info('post:raw-decision', { reqId: req.reqId, jobId, decision }); + const result = await pipelineService.submitRawDecision(jobId, decision); + res.json({ result }); + }) +); + +router.post( + '/start/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:start-job', { reqId: req.reqId, jobId }); + const result = await pipelineService.startPreparedJob(jobId); + res.json({ result }); + }) +); + +router.post( + '/multipart-merge/:jobId/reorder', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const orderedSourceJobIds = Array.isArray(req.body?.orderedSourceJobIds) + ? req.body.orderedSourceJobIds + : []; + logger.info('post:multipart-merge:reorder', { + reqId: req.reqId, + jobId, + orderedCount: orderedSourceJobIds.length + }); + const job = await pipelineService.updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds); + res.json({ job }); + }) +); + +router.post( + '/multipart-merge/:jobId/settings', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const deleteInputsAfterMerge = Boolean(req.body?.deleteInputsAfterMerge); + logger.info('post:multipart-merge:settings', { + reqId: req.reqId, + jobId, + deleteInputsAfterMerge + }); + const job = await pipelineService.updateMultipartMergeSettings(jobId, { + deleteInputsAfterMerge + }); + res.json({ job }); + }) +); + +router.get( + '/multipart-merge/:jobId/preview', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('get:multipart-merge:preview', { + reqId: req.reqId, + jobId + }); + const preview = await pipelineService.getMultipartMergePreview(jobId); + res.json({ preview }); + }) +); + +router.post( + '/multipart-merge/:containerJobId/restore', + asyncHandler(async (req, res) => { + const containerJobId = Number(req.params.containerJobId); + logger.info('post:multipart-merge:restore', { + reqId: req.reqId, + containerJobId + }); + const job = await pipelineService.restoreMultipartMergeJobForContainer(containerJobId); + await pipelineService.emitQueueChanged().catch((error) => { + logger.warn('post:multipart-merge:restore:queue-emit-failed', { + reqId: req.reqId, + containerJobId, + error: error?.message || String(error) + }); + }); + res.json({ + result: { + restored: true, + mergeJobId: Number(job?.id || 0) || null + }, + job + }); + }) +); + +router.post( + '/confirm-encode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const body = req.body && typeof req.body === 'object' ? req.body : {}; + const hasSelectedUserPresetId = Object.prototype.hasOwnProperty.call(body, 'selectedUserPresetId'); + const hasSelectedHandBrakePreset = Object.prototype.hasOwnProperty.call(body, 'selectedHandBrakePreset'); + const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null; + const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null; + const selectedTrackSelection = req.body?.selectedTrackSelection ?? null; + const episodeAssignments = req.body?.episodeAssignments ?? null; + const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds; + const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds; + const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds; + const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds; + const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate); + const selectedUserPresetId = hasSelectedUserPresetId + ? body.selectedUserPresetId + : undefined; + const selectedHandBrakePreset = hasSelectedHandBrakePreset + ? body.selectedHandBrakePreset + : undefined; + logger.info('post:confirm-encode', { + reqId: req.reqId, + jobId, + selectedEncodeTitleId, + selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0, + selectedTrackSelectionProvided: Boolean(selectedTrackSelection), + episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'), + skipPipelineStateUpdate, + selectedUserPresetId, + selectedHandBrakePreset, + selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds) + ? selectedPostEncodeScriptIds.length + : 0, + selectedPreEncodeScriptIdsCount: Array.isArray(selectedPreEncodeScriptIds) + ? selectedPreEncodeScriptIds.length + : 0, + selectedPostEncodeChainIdsCount: Array.isArray(selectedPostEncodeChainIds) + ? selectedPostEncodeChainIds.length + : 0, + selectedPreEncodeChainIdsCount: Array.isArray(selectedPreEncodeChainIds) + ? selectedPreEncodeChainIds.length + : 0 + }); + const job = await pipelineService.confirmEncodeReview(jobId, { + selectedEncodeTitleId, + selectedEncodeTitleIds, + selectedTrackSelection, + episodeAssignments, + selectedPostEncodeScriptIds, + selectedPreEncodeScriptIds, + selectedPostEncodeChainIds, + selectedPreEncodeChainIds, + skipPipelineStateUpdate, + selectedUserPresetId, + selectedHandBrakePreset + }); + res.json({ job }); + }) +); + +router.post( + '/cancel', + asyncHandler(async (req, res) => { + const rawJobId = req.body?.jobId; + const jobId = rawJobId === null || rawJobId === undefined || String(rawJobId).trim() === '' + ? null + : Number(rawJobId); + logger.warn('post:cancel', { reqId: req.reqId, jobId }); + const result = await pipelineService.cancel(jobId); + res.json({ result }); + }) +); + +router.post( + '/retry/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const createNewJob = Boolean(req.body?.createNewJob); + logger.info('post:retry', { reqId: req.reqId, jobId, createNewJob }); + const result = await pipelineService.retry(jobId, { createNewJob }); + res.json({ result }); + }) +); + +router.post( + '/resume-ready/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:resume-ready', { reqId: req.reqId, jobId }); + const job = await pipelineService.resumeReadyToEncodeJob(jobId); + res.json({ job }); + }) +); + +router.get( + '/output-folders/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const folders = await historyService.getJobOutputFoldersForLineage(jobId); + res.json({ folders }); + }) +); + +router.post( + '/delete-output-folders/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const folderPaths = Array.isArray(req.body?.folderPaths) ? req.body.folderPaths : []; + logger.info('post:delete-output-folders', { reqId: req.reqId, jobId, count: folderPaths.length }); + const result = await historyService.deleteSpecificOutputFolders(jobId, folderPaths); + res.json({ result }); + }) +); + +router.post( + '/reencode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + logger.info('post:reencode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); + const result = await pipelineService.reencodeFromRaw(jobId, { keepBoth, deleteFolders }); + res.json({ result }); + }) +); + +router.post( + '/restart-review/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + const createNewJob = Boolean(req.body?.createNewJob); + const reuseCurrentJob = Object.prototype.hasOwnProperty.call(req.body || {}, 'reuseCurrentJob') + ? Boolean(req.body?.reuseCurrentJob) + : !createNewJob; + logger.info('post:restart-review', { + reqId: req.reqId, + jobId, + keepBoth, + createNewJob, + reuseCurrentJob, + deleteFolderCount: deleteFolders.length + }); + const result = await pipelineService.restartReviewFromRaw(jobId, { + keepBoth, + deleteFolders, + reuseCurrentJob, + createNewJob + }); + res.json({ result }); + }) +); + +router.post( + '/restart-encode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all'; + const createNewJob = Boolean(req.body?.createNewJob); + logger.info('post:restart-encode', { + reqId: req.reqId, + jobId, + keepBoth, + createNewJob, + restartMode, + deleteFolderCount: deleteFolders.length + }); + const result = await pipelineService.restartEncodeWithLastSettings(jobId, { + keepBoth, + deleteFolders, + restartMode, + createNewJob + }); + res.json({ result }); + }) +); + +router.post( + '/restart-cd-review/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + logger.info('post:restart-cd-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); + const result = await pipelineService.restartCdReviewFromRaw(jobId, { keepBoth, deleteFolders }); + res.json({ result }); + }) +); + +router.get( + '/queue', + asyncHandler(async (req, res) => { + logger.debug('get:queue', { reqId: req.reqId }); + const queue = await pipelineService.getQueueSnapshot(); + res.json({ queue }); + }) +); + +router.post( + '/queue/reorder', + asyncHandler(async (req, res) => { + // Accept orderedEntryIds (new) or orderedJobIds (legacy fallback for job-only queues). + const orderedEntryIds = Array.isArray(req.body?.orderedEntryIds) + ? req.body.orderedEntryIds + : (Array.isArray(req.body?.orderedJobIds) ? req.body.orderedJobIds : []); + logger.info('post:queue:reorder', { reqId: req.reqId, orderedEntryIds }); + const queue = await pipelineService.reorderQueue(orderedEntryIds); + res.json({ queue }); + }) +); + +router.post( + '/queue/entry', + asyncHandler(async (req, res) => { + const { type, scriptId, chainId, waitSeconds, insertAfterEntryId } = req.body || {}; + logger.info('post:queue:entry', { reqId: req.reqId, type }); + const result = await pipelineService.enqueueNonJobEntry( + type, + { scriptId, chainId, waitSeconds }, + insertAfterEntryId ?? null + ); + const queue = await pipelineService.getQueueSnapshot(); + res.json({ result, queue }); + }) +); + +router.delete( + '/queue/entry/:entryId', + asyncHandler(async (req, res) => { + const entryId = req.params.entryId; + logger.info('delete:queue:entry', { reqId: req.reqId, entryId }); + const queue = await pipelineService.removeQueueEntry(entryId); + res.json({ queue }); + }) +); + +module.exports = router; diff --git a/backend/src/routes/runtimeRoutes.js b/backend/src/routes/runtimeRoutes.js new file mode 100644 index 0000000..560be59 --- /dev/null +++ b/backend/src/routes/runtimeRoutes.js @@ -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; diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js new file mode 100644 index 0000000..1eec7c2 --- /dev/null +++ b/backend/src/routes/settingsRoutes.js @@ -0,0 +1,667 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const settingsService = require('../services/settingsService'); +const scriptService = require('../services/scriptService'); +const scriptChainService = require('../services/scriptChainService'); +const notificationService = require('../services/notificationService'); +const pipelineService = require('../services/pipelineService'); +const wsService = require('../services/websocketService'); +const hardwareMonitorService = require('../services/hardwareMonitorService'); +const userPresetService = require('../services/userPresetService'); +const userPresetDefaultsService = require('../services/userPresetDefaultsService'); +const activationBytesService = require('../services/activationBytesService'); +const diskDetectionService = require('../services/diskDetectionService'); +const coverArtRecoveryService = require('../services/coverArtRecoveryService'); +const { fetchCurrentBetaKey, getCachedBetaKey } = require('../services/makemkvKeyService'); +const logger = require('../services/logger').child('SETTINGS_ROUTE'); + +const { getDb } = require('../db/database'); + +const router = express.Router(); + +function isSensitiveSettingKey(key) { + const normalized = String(key || '').trim().toLowerCase(); + if (!normalized) { + return false; + } + return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized); +} + +router.get( + '/', + asyncHandler(async (req, res) => { + logger.debug('get:settings', { reqId: req.reqId }); + const categories = await settingsService.getCategorizedSettings(); + res.json({ categories }); + }) +); + +router.get( + '/effective-paths', + asyncHandler(async (req, res) => { + logger.debug('get:settings:effective-paths', { reqId: req.reqId }); + const paths = await settingsService.getEffectivePaths(); + res.json(paths); + }) +); + +router.get( + '/handbrake-presets', + asyncHandler(async (req, res) => { + logger.debug('get:settings:handbrake-presets', { reqId: req.reqId }); + const presets = await settingsService.getHandBrakePresetOptions(); + res.json(presets); + }) +); + +router.get( + '/makemkv/beta-key', + asyncHandler(async (req, res) => { + logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId }); + const currentSettings = await settingsService.getSettingsMap({ forceRefresh: false }); + const existingKey = String(currentSettings?.makemkv_registration_key || '').trim(); + const cached = await getCachedBetaKey({ allowExpired: true }); + + res.json({ + betaKey: cached?.key || '', + sourceUrl: cached?.sourceUrl || null, + validUntil: cached?.validUntil || null, + fetchedAt: cached?.fetchedAt || null, + cached: cached?.cached === true, + stale: cached?.stale === true, + appliedKey: existingKey || null, + appliedMatchesCache: Boolean(cached?.key) && existingKey === cached.key + }); + }) +); + +router.post( + '/makemkv/beta-key/check', + asyncHandler(async (req, res) => { + logger.info('post:settings:makemkv:beta-key:check', { reqId: req.reqId }); + const betaKeyResult = await fetchCurrentBetaKey({ forceRefresh: true }); + res.json({ + betaKey: betaKeyResult.key, + sourceUrl: betaKeyResult.sourceUrl, + validUntil: betaKeyResult.validUntil || null, + fetchedAt: betaKeyResult.fetchedAt || null, + cached: betaKeyResult.cached === true, + stale: betaKeyResult.stale === true + }); + }) +); + +router.post( + '/makemkv/beta-key/apply', + asyncHandler(async (req, res) => { + logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId }); + const requestedKey = String(req.body?.betaKey || '').trim(); + const cachedResult = requestedKey + ? { key: requestedKey } + : await getCachedBetaKey({ allowExpired: true }); + const betaKey = String(cachedResult?.key || '').trim(); + + if (!betaKey) { + const error = new Error('Kein geprüfter Betakey vorhanden. Bitte zuerst prüfen.'); + error.statusCode = 400; + throw error; + } + + const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKey); + wsService.broadcast('SETTINGS_UPDATED', updated); + + res.json({ + applied: true, + setting: updated, + betaKey, + sourceUrl: String(cachedResult?.sourceUrl || '').trim() || null + }); + }) +); + +router.get( + '/scripts', + asyncHandler(async (req, res) => { + logger.debug('get:settings:scripts', { reqId: req.reqId }); + const scripts = await scriptService.listScripts(); + res.json({ scripts }); + }) +); + +router.post( + '/scripts', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:settings:scripts:create', { + reqId: req.reqId, + name: String(payload?.name || '').trim() || null, + scriptBodyLength: String(payload?.scriptBody || '').length + }); + const script = await scriptService.createScript(payload); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'created', id: script.id }); + res.status(201).json({ script }); + }) +); + +router.post( + '/scripts/reorder', + asyncHandler(async (req, res) => { + const orderedScriptIds = Array.isArray(req.body?.orderedScriptIds) ? req.body.orderedScriptIds : []; + logger.info('post:settings:scripts:reorder', { + reqId: req.reqId, + count: orderedScriptIds.length + }); + const scripts = await scriptService.reorderScripts(orderedScriptIds); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'reordered', count: scripts.length }); + res.json({ scripts }); + }) +); + +router.put( + '/scripts/:id', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:settings:scripts:update', { + reqId: req.reqId, + scriptId, + name: String(payload?.name || '').trim() || null, + scriptBodyLength: String(payload?.scriptBody || '').length + }); + const script = await scriptService.updateScript(scriptId, payload); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'updated', id: script.id }); + res.json({ script }); + }) +); + +router.put( + '/scripts/:id/favorite', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + const isFavorite = req.body?.isFavorite === true; + logger.info('put:settings:scripts:favorite', { + reqId: req.reqId, + scriptId, + isFavorite + }); + const script = await scriptService.setScriptFavorite(scriptId, isFavorite); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { + action: 'favorite-updated', + id: script.id, + isFavorite: script.isFavorite === true + }); + res.json({ script }); + }) +); + +router.delete( + '/scripts/:id', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + logger.info('delete:settings:scripts', { + reqId: req.reqId, + scriptId + }); + const removed = await scriptService.deleteScript(scriptId); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + +router.post( + '/scripts/:id/test', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + logger.info('post:settings:scripts:test', { + reqId: req.reqId, + scriptId + }); + const result = await scriptService.testScript(scriptId); + res.json({ result }); + }) +); + +router.post( + '/script-chains/:id/test', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + logger.info('post:settings:script-chains:test', { reqId: req.reqId, chainId }); + const result = await scriptChainService.executeChain(chainId, { source: 'settings_test', mode: 'test' }); + res.json({ result }); + }) +); + +router.get( + '/script-chains', + asyncHandler(async (req, res) => { + logger.debug('get:settings:script-chains', { reqId: req.reqId }); + const chains = await scriptChainService.listChains(); + res.json({ chains }); + }) +); + +router.post( + '/script-chains', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:settings:script-chains:create', { reqId: req.reqId, name: payload?.name }); + const chain = await scriptChainService.createChain(payload); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'created', id: chain.id }); + res.status(201).json({ chain }); + }) +); + +router.post( + '/script-chains/reorder', + asyncHandler(async (req, res) => { + const orderedChainIds = Array.isArray(req.body?.orderedChainIds) ? req.body.orderedChainIds : []; + logger.info('post:settings:script-chains:reorder', { + reqId: req.reqId, + count: orderedChainIds.length + }); + const chains = await scriptChainService.reorderChains(orderedChainIds); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'reordered', count: chains.length }); + res.json({ chains }); + }) +); + +router.get( + '/script-chains/:id', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + logger.debug('get:settings:script-chains:one', { reqId: req.reqId, chainId }); + const chain = await scriptChainService.getChainById(chainId); + res.json({ chain }); + }) +); + +router.put( + '/script-chains/:id', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:settings:script-chains:update', { reqId: req.reqId, chainId, name: payload?.name }); + const chain = await scriptChainService.updateChain(chainId, payload); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'updated', id: chain.id }); + res.json({ chain }); + }) +); + +router.put( + '/script-chains/:id/favorite', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + const isFavorite = req.body?.isFavorite === true; + logger.info('put:settings:script-chains:favorite', { reqId: req.reqId, chainId, isFavorite }); + const chain = await scriptChainService.setChainFavorite(chainId, isFavorite); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { + action: 'favorite-updated', + id: chain.id, + isFavorite: chain.isFavorite === true + }); + res.json({ chain }); + }) +); + +router.delete( + '/script-chains/:id', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + logger.info('delete:settings:script-chains', { reqId: req.reqId, chainId }); + const removed = await scriptChainService.deleteChain(chainId); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + +router.post( + '/coverart/recover', + asyncHandler(async (req, res) => { + logger.info('post:settings:coverart:recover', { reqId: req.reqId }); + const result = await coverArtRecoveryService.runNow({ + trigger: 'manual', + force: true, + logFailures: true + }); + res.json({ + result, + scheduler: coverArtRecoveryService.getStatus() + }); + }) +); + +// ── User Presets ────────────────────────────────────────────────────────────── + +router.get( + '/user-presets', + asyncHandler(async (req, res) => { + const mediaType = req.query.media_type || null; + logger.debug('get:user-presets', { reqId: req.reqId, mediaType }); + const presets = await userPresetService.listPresets(mediaType); + res.json({ presets }); + }) +); + +router.get( + '/user-preset-defaults', + asyncHandler(async (req, res) => { + logger.debug('get:user-preset-defaults', { reqId: req.reqId }); + const defaults = await userPresetDefaultsService.listDefaults(); + res.json({ defaults }); + }) +); + +router.put( + '/user-preset-defaults', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('put:user-preset-defaults:update', { reqId: req.reqId }); + const defaults = await userPresetDefaultsService.updateDefaults(payload); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'defaults-updated' }); + res.json({ defaults }); + }) +); + +router.post( + '/user-presets', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name }); + const preset = await userPresetService.createPreset(payload); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id }); + res.status(201).json({ preset }); + }) +); + +router.put( + '/user-presets/:id', + asyncHandler(async (req, res) => { + const presetId = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:user-presets:update', { reqId: req.reqId, presetId }); + const preset = await userPresetService.updatePreset(presetId, payload); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id }); + res.json({ preset }); + }) +); + +router.delete( + '/user-presets/:id', + asyncHandler(async (req, res) => { + const presetId = Number(req.params.id); + logger.info('delete:user-presets', { reqId: req.reqId, presetId }); + const removed = await userPresetService.deletePreset(presetId); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + +router.put( + '/:key', + asyncHandler(async (req, res) => { + const { key } = req.params; + const { value } = req.body; + + logger.info('put:setting', { + reqId: req.reqId, + key, + value: isSensitiveSettingKey(key) ? '[redacted]' : value + }); + const updated = await settingsService.setSettingValue(key, value); + let reviewRefresh = null; + try { + reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave([key]); + if (reviewRefresh?.triggered) { + logger.info('put:setting:review-refresh-started', { + reqId: req.reqId, + key, + jobId: reviewRefresh.jobId + }); + } + } catch (error) { + logger.warn('put:setting:review-refresh-failed', { + reqId: req.reqId, + key, + error: { + name: error?.name, + message: error?.message + } + }); + reviewRefresh = { + triggered: false, + reason: 'refresh_error', + message: error?.message || 'unknown' + }; + } + try { + await hardwareMonitorService.handleSettingsChanged([key]); + } catch (error) { + logger.warn('put:setting:hardware-monitor-refresh-failed', { + reqId: req.reqId, + key, + error: { + name: error?.name, + message: error?.message + } + }); + } + try { + await coverArtRecoveryService.handleSettingsChanged([key]); + } catch (error) { + logger.warn('put:setting:coverart-scheduler-refresh-failed', { + reqId: req.reqId, + key, + error: { + name: error?.name, + message: error?.message + } + }); + } + wsService.broadcast('SETTINGS_UPDATED', updated); + + res.json({ setting: updated, reviewRefresh }); + }) +); + +router.put( + '/', + asyncHandler(async (req, res) => { + const { settings } = req.body || {}; + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + const error = new Error('settings fehlt oder ist ungültig.'); + error.statusCode = 400; + throw error; + } + + logger.info('put:settings:bulk', { reqId: req.reqId, count: Object.keys(settings).length }); + const changes = await settingsService.setSettingsBulk(settings); + let reviewRefresh = null; + try { + reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave(changes.map((item) => item.key)); + if (reviewRefresh?.triggered) { + logger.info('put:settings:bulk:review-refresh-started', { + reqId: req.reqId, + jobId: reviewRefresh.jobId, + relevantKeys: reviewRefresh.relevantKeys + }); + } + } catch (error) { + logger.warn('put:settings:bulk:review-refresh-failed', { + reqId: req.reqId, + error: { + name: error?.name, + message: error?.message + } + }); + reviewRefresh = { + triggered: false, + reason: 'refresh_error', + message: error?.message || 'unknown' + }; + } + try { + await hardwareMonitorService.handleSettingsChanged(changes.map((item) => item.key)); + } catch (error) { + logger.warn('put:settings:bulk:hardware-monitor-refresh-failed', { + reqId: req.reqId, + error: { + name: error?.name, + message: error?.message + } + }); + } + try { + await coverArtRecoveryService.handleSettingsChanged(changes.map((item) => item.key)); + } catch (error) { + logger.warn('put:settings:bulk:coverart-scheduler-refresh-failed', { + reqId: req.reqId, + error: { + name: error?.name, + message: error?.message + } + }); + } + wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) }); + + res.json({ changes, reviewRefresh }); + }) +); + +router.post( + '/pushover/test', + asyncHandler(async (req, res) => { + const title = req.body?.title; + const message = req.body?.message; + logger.info('post:pushover:test', { + reqId: req.reqId, + hasTitle: Boolean(title), + hasMessage: Boolean(message) + }); + const result = await notificationService.sendTest({ title, message }); + res.json({ result }); + }) +); + +router.get( + '/activation-bytes', + asyncHandler(async (req, res) => { + logger.debug('get:settings:activation-bytes', { reqId: req.reqId }); + const entries = await activationBytesService.listCachedEntries(); + res.json({ entries }); + }) +); + +router.post( + '/activation-bytes', + asyncHandler(async (req, res) => { + const { checksum, activationBytes } = req.body || {}; + if (!checksum || !activationBytes) { + const error = new Error('checksum und activationBytes sind erforderlich'); + error.statusCode = 400; + throw error; + } + logger.debug('post:settings:activation-bytes', { reqId: req.reqId, checksum }); + const saved = await activationBytesService.saveActivationBytes(checksum, activationBytes); + res.json({ success: true, checksum, activationBytes: saved }); + }) +); + +// ── Optical drive scan ──────────────────────────────────────────────────────── +router.get( + '/drives', + asyncHandler(async (req, res) => { + logger.debug('get:settings:drives', { reqId: req.reqId }); + const devices = await diskDetectionService.getBlockDeviceInfo(); + const drives = devices + .filter((d) => d.type === 'rom') + .map((d) => { + const name = String(d.name || ''); + const devicePath = d.path || (name ? `/dev/${name}` : null); + const resolvedIndex = Number(d.makemkvIndex); + const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0 + ? Math.trunc(resolvedIndex) + : null; + return { + path: devicePath, + name, + model: d.model || null, + discIndex, + discArg: discIndex != null ? `disc:${discIndex}` : null + }; + }) + .filter((d) => d.path); + res.json({ drives }); + }) +); + +router.post( + '/drives/force-unlock', + asyncHandler(async (req, res) => { + const { devicePath, all } = req.body || {}; + const settings = await settingsService.getSettingsMap(); + const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase()); + if (!expertMode) { + const error = new Error('Expertenmodus erforderlich.'); + error.statusCode = 403; + throw error; + } + + const devices = await diskDetectionService.getBlockDeviceInfo(); + const drives = devices + .filter((d) => d.type === 'rom') + .map((d) => { + const name = String(d.name || ''); + return d.path || (name ? `/dev/${name}` : null); + }) + .filter(Boolean); + + const activeLockPaths = (diskDetectionService.getActiveLocks?.() || []) + .map((entry) => String(entry?.path || '').trim()) + .filter(Boolean); + + const targetPaths = all + ? Array.from(new Set([...drives, ...activeLockPaths])) + : [String(devicePath || '').trim()].filter(Boolean); + if (targetPaths.length === 0) { + const error = new Error('Kein Laufwerk angegeben.'); + error.statusCode = 400; + throw error; + } + + const results = []; + for (const target of targetPaths) { + const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' }); + results.push(result); + } + + res.json({ results }); + }) +); + +// User preferences (UI state, persisted per-installation) +router.get( + '/prefs/:key', + asyncHandler(async (req, res) => { + const { key } = req.params; + const db = await getDb(); + const row = await db.get('SELECT value FROM user_prefs WHERE key = ?', [key]); + res.json({ key, value: row ? row.value : null }); + }) +); + +router.put( + '/prefs/:key', + asyncHandler(async (req, res) => { + const { key } = req.params; + const { value } = req.body || {}; + const db = await getDb(); + await db.run( + `INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + [key, value ?? null] + ); + res.json({ key, value: value ?? null }); + }) +); + +module.exports = router; diff --git a/backend/src/services/activationBytesService.js b/backend/src/services/activationBytesService.js new file mode 100644 index 0000000..450cd8d --- /dev/null +++ b/backend/src/services/activationBytesService.js @@ -0,0 +1,79 @@ +const fs = require('fs'); +const crypto = require('crypto'); +const { getDb } = require('../db/database'); +const logger = require('./logger').child('ActivationBytes'); + +const FIXED_KEY = Buffer.from([0x77, 0x21, 0x4d, 0x4b, 0x19, 0x6a, 0x87, 0xcd, 0x52, 0x00, 0x45, 0xfd, 0x20, 0xa5, 0x1d, 0x67]); +const AAX_CHECKSUM_OFFSET = 653; +const AAX_CHECKSUM_LENGTH = 20; + +function sha1(data) { + return crypto.createHash('sha1').update(data).digest(); +} + +function verifyActivationBytes(activationBytesHex, expectedChecksumHex) { + const bytes = Buffer.from(activationBytesHex, 'hex'); + const ik = sha1(Buffer.concat([FIXED_KEY, bytes])); + const iv = sha1(Buffer.concat([FIXED_KEY, ik, bytes])); + const checksum = sha1(Buffer.concat([ik.subarray(0, 16), iv.subarray(0, 16)])); + return checksum.toString('hex') === expectedChecksumHex; +} + +function readAaxChecksum(filePath) { + const fd = fs.openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(AAX_CHECKSUM_LENGTH); + const bytesRead = fs.readSync(fd, buf, 0, AAX_CHECKSUM_LENGTH, AAX_CHECKSUM_OFFSET); + if (bytesRead !== AAX_CHECKSUM_LENGTH) { + throw new Error(`Konnte Checksum nicht lesen (nur ${bytesRead} Bytes)`); + } + return buf.toString('hex'); + } finally { + fs.closeSync(fd); + } +} + +async function lookupCached(checksum) { + const db = await getDb(); + const row = await db.get('SELECT activation_bytes FROM aax_activation_bytes WHERE checksum = ?', checksum); + return row ? row.activation_bytes : null; +} + +async function saveActivationBytes(checksum, activationBytesHex) { + const normalized = String(activationBytesHex || '').trim().toLowerCase(); + if (!/^[0-9a-f]{8}$/.test(normalized)) { + throw new Error('Activation Bytes müssen genau 8 Hex-Zeichen (4 Bytes) sein'); + } + if (!verifyActivationBytes(normalized, checksum)) { + throw new Error('Activation Bytes passen nicht zur Checksum – bitte nochmals prüfen'); + } + const db = await getDb(); + await db.run( + 'INSERT OR REPLACE INTO aax_activation_bytes (checksum, activation_bytes) VALUES (?, ?)', + checksum, + normalized + ); + logger.info({ checksum, activationBytes: normalized }, 'Activation Bytes manuell gespeichert'); + return normalized; +} + +async function resolveActivationBytes(filePath) { + const checksum = readAaxChecksum(filePath); + logger.info({ checksum }, 'AAX Checksum gelesen'); + + const cached = await lookupCached(checksum); + if (cached) { + logger.info({ checksum }, 'Activation Bytes aus lokalem Cache'); + return { checksum, activationBytes: cached }; + } + + logger.info({ checksum }, 'Keine Activation Bytes im Cache – manuelle Eingabe erforderlich'); + return { checksum, activationBytes: null }; +} + +async function listCachedEntries() { + const db = await getDb(); + return db.all('SELECT checksum, activation_bytes, created_at FROM aax_activation_bytes ORDER BY created_at DESC'); +} + +module.exports = { resolveActivationBytes, readAaxChecksum, saveActivationBytes, verifyActivationBytes, listCachedEntries }; diff --git a/backend/src/services/audiobookService.js b/backend/src/services/audiobookService.js new file mode 100644 index 0000000..74286d4 --- /dev/null +++ b/backend/src/services/audiobookService.js @@ -0,0 +1,819 @@ +const path = require('path'); +const { sanitizeFileName } = require('../utils/files'); + +const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']); +const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']); +const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})'; +const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})'; +const DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}'; +const AUDIOBOOK_FORMAT_DEFAULTS = { + m4b: {}, + flac: { + flacCompression: 5 + }, + mp3: { + mp3Mode: 'cbr', + mp3Bitrate: 192, + mp3Quality: 4 + } +}; + +function normalizeText(value) { + return String(value || '') + .normalize('NFC') + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function parseOptionalYear(value) { + const text = normalizeText(value); + if (!text) { + return null; + } + const match = text.match(/\b(19|20)\d{2}\b/); + if (!match) { + return null; + } + return Number(match[0]); +} + +function parseOptionalNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseTimebaseToSeconds(value) { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + if (/^\d+\/\d+$/u.test(raw)) { + const [num, den] = raw.split('/').map(Number); + if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) { + return num / den; + } + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function secondsToMs(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return Math.max(0, Math.round(parsed * 1000)); +} + +function ticksToMs(value, timebase) { + const ticks = Number(value); + const factor = parseTimebaseToSeconds(timebase); + if (!Number.isFinite(ticks) || ticks < 0 || !Number.isFinite(factor) || factor <= 0) { + return null; + } + return Math.max(0, Math.round(ticks * factor * 1000)); +} + +function normalizeOutputFormat(value) { + const format = String(value || '').trim().toLowerCase(); + return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3'; +} + +function clonePlainObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}; +} + +function clampInteger(value, min, max, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.trunc(parsed))); +} + +function getDefaultFormatOptions(format) { + const normalizedFormat = normalizeOutputFormat(format); + return clonePlainObject(AUDIOBOOK_FORMAT_DEFAULTS[normalizedFormat]); +} + +function normalizeFormatOptions(format, formatOptions = {}) { + const normalizedFormat = normalizeOutputFormat(format); + const source = clonePlainObject(formatOptions); + const defaults = getDefaultFormatOptions(normalizedFormat); + + if (normalizedFormat === 'flac') { + return { + flacCompression: clampInteger(source.flacCompression, 0, 8, defaults.flacCompression) + }; + } + + if (normalizedFormat === 'mp3') { + const mp3Mode = String(source.mp3Mode || defaults.mp3Mode || 'cbr').trim().toLowerCase() === 'vbr' + ? 'vbr' + : 'cbr'; + const allowedBitrates = new Set([128, 160, 192, 256, 320]); + const normalizedBitrate = clampInteger(source.mp3Bitrate, 96, 320, defaults.mp3Bitrate); + return { + mp3Mode, + mp3Bitrate: allowedBitrates.has(normalizedBitrate) ? normalizedBitrate : defaults.mp3Bitrate, + mp3Quality: clampInteger(source.mp3Quality, 0, 9, defaults.mp3Quality) + }; + } + + return {}; +} + +function normalizeInputExtension(filePath) { + return path.extname(String(filePath || '')).trim().toLowerCase(); +} + +function isSupportedInputFile(filePath) { + return SUPPORTED_INPUT_EXTENSIONS.has(normalizeInputExtension(filePath)); +} + +function normalizeTagMap(tags = null) { + const source = tags && typeof tags === 'object' ? tags : {}; + const result = {}; + for (const [key, value] of Object.entries(source)) { + const normalizedKey = String(key || '').trim().toLowerCase(); + if (!normalizedKey) { + continue; + } + const normalizedValue = normalizeText(value); + if (!normalizedValue) { + continue; + } + result[normalizedKey] = normalizedValue; + } + return result; +} + +function pickTag(tags, keys = []) { + const normalized = normalizeTagMap(tags); + for (const key of keys) { + const value = normalized[String(key || '').trim().toLowerCase()]; + if (value) { + return value; + } + } + return null; +} + +function sanitizeTemplateValue(value, fallback = '') { + const normalized = normalizeText(value); + if (!normalized) { + return fallback; + } + return sanitizeFileName(normalized); +} + +function normalizeChapterTitle(value, index) { + const normalized = normalizeText(value); + return normalized || `Kapitel ${index}`; +} + +function buildChapterList(probe = null) { + const chapters = Array.isArray(probe?.chapters) ? probe.chapters : []; + return chapters.map((chapter, index) => { + const chapterIndex = index + 1; + const tags = normalizeTagMap(chapter?.tags); + const startSeconds = parseOptionalNumber(chapter?.start_time); + const endSeconds = parseOptionalNumber(chapter?.end_time); + const startMs = secondsToMs(startSeconds) ?? ticksToMs(chapter?.start, chapter?.time_base) ?? 0; + const endMs = secondsToMs(endSeconds) ?? ticksToMs(chapter?.end, chapter?.time_base) ?? 0; + const title = normalizeChapterTitle(tags.title || tags.chapter, chapterIndex); + return { + index: chapterIndex, + title, + startSeconds: Number((startMs / 1000).toFixed(3)), + endSeconds: Number((endMs / 1000).toFixed(3)), + startMs, + endMs, + timeBase: String(chapter?.time_base || '').trim() || null + }; + }); +} + +function normalizeChapterList(chapters = [], options = {}) { + const source = Array.isArray(chapters) ? chapters : []; + const durationMs = Number(options?.durationMs || 0); + const fallbackTitle = normalizeText(options?.fallbackTitle || ''); + const createFallback = options?.createFallback === true; + + const normalized = source.map((chapter, index) => { + const chapterIndex = Number(chapter?.index); + const safeIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 + ? Math.trunc(chapterIndex) + : index + 1; + const rawStartMs = parseOptionalNumber(chapter?.startMs) + ?? secondsToMs(chapter?.startSeconds) + ?? ticksToMs(chapter?.start, chapter?.timeBase || chapter?.time_base) + ?? 0; + const rawEndMs = parseOptionalNumber(chapter?.endMs) + ?? secondsToMs(chapter?.endSeconds) + ?? ticksToMs(chapter?.end, chapter?.timeBase || chapter?.time_base) + ?? 0; + return { + index: safeIndex, + title: normalizeChapterTitle(chapter?.title, safeIndex), + startMs: Math.max(0, rawStartMs), + endMs: Math.max(0, rawEndMs) + }; + }); + + const repaired = normalized.map((chapter, index) => { + const nextStartMs = normalized[index + 1]?.startMs ?? null; + let endMs = chapter.endMs; + if (!(endMs > chapter.startMs)) { + if (Number.isFinite(nextStartMs) && nextStartMs > chapter.startMs) { + endMs = nextStartMs; + } else if (durationMs > chapter.startMs) { + endMs = durationMs; + } else { + endMs = chapter.startMs; + } + } + return { + ...chapter, + endMs, + startSeconds: Number((chapter.startMs / 1000).toFixed(3)), + endSeconds: Number((endMs / 1000).toFixed(3)), + durationMs: Math.max(0, endMs - chapter.startMs) + }; + }).filter((chapter) => chapter.endMs > chapter.startMs || normalized.length === 1); + + if (repaired.length > 0) { + return repaired; + } + + if (createFallback && durationMs > 0) { + return [{ + index: 1, + title: fallbackTitle || 'Kapitel 1', + startMs: 0, + endMs: durationMs, + startSeconds: 0, + endSeconds: Number((durationMs / 1000).toFixed(3)), + durationMs + }]; + } + + return []; +} + +function looksLikeDescription(value) { + const normalized = normalizeText(value); + if (!normalized) { + return false; + } + return normalized.length >= 120 || /[.!?]\s/u.test(normalized); +} + +function detectCoverStream(probe = null) { + const streams = Array.isArray(probe?.streams) ? probe.streams : []; + for (const stream of streams) { + const codecType = String(stream?.codec_type || '').trim().toLowerCase(); + const codecName = String(stream?.codec_name || '').trim().toLowerCase(); + const dispositionAttachedPic = Number(stream?.disposition?.attached_pic || 0) === 1; + const mimetype = String(stream?.tags?.mimetype || '').trim().toLowerCase(); + const looksLikeImageStream = codecType === 'video' + && (dispositionAttachedPic || mimetype.startsWith('image/') || ['jpeg', 'jpg', 'png', 'mjpeg'].includes(codecName)); + + if (!looksLikeImageStream) { + continue; + } + + const streamIndex = Number(stream?.index); + return { + streamIndex: Number.isFinite(streamIndex) ? Math.trunc(streamIndex) : 0, + codecName: codecName || null, + mimetype: mimetype || null, + attachedPic: dispositionAttachedPic + }; + } + return null; +} + +function parseProbeOutput(rawOutput) { + if (!rawOutput) { + return null; + } + try { + return JSON.parse(rawOutput); + } catch (_error) { + return null; + } +} + +function buildMetadataFromProbe(probe = null, originalName = null) { + const format = probe?.format && typeof probe.format === 'object' ? probe.format : {}; + const tags = normalizeTagMap(format.tags); + const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || ''))); + const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook'; + const title = pickTag(tags, ['title', 'album']) || fallbackTitle; + const author = pickTag(tags, ['author', 'artist', 'writer', 'album_artist', 'composer']) || 'Unknown Author'; + const description = pickTag(tags, [ + 'description', + 'synopsis', + 'summary', + 'long_description', + 'longdescription', + 'publisher_summary', + 'publishersummary', + 'comment' + ]) || null; + let narrator = pickTag(tags, ['narrator', 'performer', 'album_artist']) || null; + if (narrator && (narrator === author || narrator === description || looksLikeDescription(narrator))) { + narrator = null; + } + const series = pickTag(tags, ['series', 'grouping', 'series_title', 'show']) || null; + const part = pickTag(tags, ['part', 'part_number', 'disc', 'discnumber', 'volume']) || null; + const year = parseOptionalYear(pickTag(tags, ['date', 'year', 'creation_time'])); + const durationSeconds = Number(format.duration || 0); + const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0 + ? Math.round(durationSeconds * 1000) + : 0; + const chapters = normalizeChapterList(buildChapterList(probe), { + durationMs, + fallbackTitle: title, + createFallback: false + }); + const cover = detectCoverStream(probe); + return { + title, + author, + narrator, + description, + series, + part, + year, + album: title, + artist: author, + durationMs, + chapters, + cover, + hasEmbeddedCover: Boolean(cover), + tags + }; +} + +function normalizeTemplateTokenKey(rawKey) { + const key = String(rawKey || '').trim().toLowerCase(); + if (!key) { + return ''; + } + if (key === 'artist') { + return 'author'; + } + if (key === 'chapternr' || key === 'chapternumberpadded' || key === 'chapternopadded') { + return 'chapterNr'; + } + if (key === 'chapterno' || key === 'chapternumber' || key === 'chapternum') { + return 'chapterNo'; + } + if (key === 'chaptertitle') { + return 'chapterTitle'; + } + return key; +} + +function cleanupRenderedTemplate(value) { + return String(value || '') + .replace(/\(\s*\)/g, '') + .replace(/\[\s*]/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +function renderTemplate(template, values) { + const source = String(template || DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE).trim() + || DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE; + const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => { + const normalizedKey = normalizeTemplateTokenKey(keyA || keyB); + const rawValue = values?.[normalizedKey]; + if (rawValue === undefined || rawValue === null || rawValue === '') { + return ''; + } + return String(rawValue); + }); + return cleanupRenderedTemplate(rendered); +} + +function buildTemplateValues(metadata = {}, format = null, chapter = null) { + const chapterIndex = Number(chapter?.index || chapter?.chapterNo || 0); + const safeChapterIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 ? Math.trunc(chapterIndex) : 1; + const author = sanitizeTemplateValue(metadata.author || metadata.artist || 'Unknown Author', 'Unknown Author'); + const title = sanitizeTemplateValue(metadata.title || metadata.album || 'Unknown Audiobook', 'Unknown Audiobook'); + const narrator = sanitizeTemplateValue(metadata.narrator || ''); + const series = sanitizeTemplateValue(metadata.series || ''); + const part = sanitizeTemplateValue(metadata.part || ''); + const chapterTitle = sanitizeTemplateValue(chapter?.title || `Kapitel ${safeChapterIndex}`, `Kapitel ${safeChapterIndex}`); + const year = metadata.year ? String(metadata.year) : ''; + return { + author, + title, + narrator, + series, + part, + year, + format: format ? String(format).trim().toLowerCase() : '', + chapterNr: String(safeChapterIndex).padStart(2, '0'), + chapterNo: String(safeChapterIndex), + chapterTitle + }; +} + +function splitRenderedPath(value) { + return String(value || '') + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((segment) => sanitizeFileName(segment)) + .filter(Boolean); +} + +function resolveTemplatePathParts(template, values, fallbackBaseName) { + const rendered = renderTemplate(template, values); + const parts = splitRenderedPath(rendered); + if (parts.length === 0) { + return { + folderParts: [], + baseName: sanitizeFileName(fallbackBaseName || 'untitled') + }; + } + return { + folderParts: parts.slice(0, -1), + baseName: parts[parts.length - 1] + }; +} + +function buildRawStoragePaths(metadata, jobId, rawBaseDir, rawTemplate = DEFAULT_AUDIOBOOK_RAW_TEMPLATE, inputFileName = 'input.aax') { + const ext = normalizeInputExtension(inputFileName) || '.aax'; + const values = buildTemplateValues(metadata); + const fallbackBaseName = path.basename(String(inputFileName || 'input.aax'), ext); + const { folderParts, baseName } = resolveTemplatePathParts(rawTemplate, values, fallbackBaseName); + const rawDirName = `${baseName} - RAW - job-${jobId}`; + const rawDir = path.join(String(rawBaseDir || ''), ...folderParts, rawDirName); + const rawFilePath = path.join(rawDir, `${baseName}${ext}`); + return { + rawDir, + rawFilePath, + rawFileName: `${baseName}${ext}`, + rawDirName + }; +} + +function buildOutputPath(metadata, movieBaseDir, outputTemplate = DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, outputFormat = 'mp3') { + const normalizedFormat = normalizeOutputFormat(outputFormat); + const values = buildTemplateValues(metadata, normalizedFormat); + const fallbackBaseName = values.title || 'audiobook'; + const { folderParts, baseName } = resolveTemplatePathParts(outputTemplate, values, fallbackBaseName); + return path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`); +} + +function findCommonDirectory(paths = []) { + const segmentsList = (Array.isArray(paths) ? paths : []) + .map((entry) => String(entry || '').trim()) + .filter(Boolean) + .map((entry) => path.resolve(entry).split(path.sep).filter((segment, index, list) => !(index === 0 && list[0] === ''))); + + if (segmentsList.length === 0) { + return null; + } + + const common = [...segmentsList[0]]; + for (let index = 1; index < segmentsList.length; index += 1) { + const next = segmentsList[index]; + let matchLength = 0; + while (matchLength < common.length && matchLength < next.length && common[matchLength] === next[matchLength]) { + matchLength += 1; + } + common.length = matchLength; + if (common.length === 0) { + break; + } + } + + if (common.length === 0) { + return null; + } + + return path.join(path.sep, ...common); +} + +function buildChapterOutputPlan( + metadata, + chapters, + movieBaseDir, + chapterTemplate = DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE, + outputFormat = 'mp3' +) { + const normalizedFormat = normalizeOutputFormat(outputFormat); + const normalizedChapters = normalizeChapterList(chapters, { + durationMs: metadata?.durationMs, + fallbackTitle: metadata?.title || metadata?.album || 'Audiobook', + createFallback: true + }); + const outputFiles = normalizedChapters.map((chapter, index) => { + const values = buildTemplateValues(metadata, normalizedFormat, chapter); + const fallbackBaseName = `${values.chapterNr} ${values.chapterTitle}`.trim() || `Kapitel ${index + 1}`; + const { folderParts, baseName } = resolveTemplatePathParts(chapterTemplate, values, fallbackBaseName); + const outputPath = path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`); + return { + chapter, + outputPath + }; + }); + const outputDir = findCommonDirectory(outputFiles.map((entry) => path.dirname(entry.outputPath))) + || String(movieBaseDir || '').trim() + || '.'; + + return { + outputDir, + outputFiles, + chapters: normalizedChapters, + format: normalizedFormat + }; +} + +function buildProbeCommand(ffprobeCommand, inputPath) { + const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe'; + return { + cmd, + args: [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + '-show_streams', + '-show_chapters', + inputPath + ] + }; +} + +function pushMetadataArg(args, key, value) { + const normalizedKey = String(key || '').trim(); + const normalizedValue = normalizeText(value); + if (!normalizedKey || !normalizedValue) { + return; + } + args.push('-metadata', `${normalizedKey}=${normalizedValue}`); +} + +function buildMetadataArgs(metadata = {}, options = {}) { + const source = metadata && typeof metadata === 'object' ? metadata : {}; + const titleOverride = normalizeText(options?.title || ''); + const albumOverride = normalizeText(options?.album || ''); + const trackNo = Number(options?.trackNo || 0); + const trackTotal = Number(options?.trackTotal || 0); + const args = []; + const bookTitle = normalizeText(source.title || source.album || ''); + const author = normalizeText(source.author || source.artist || ''); + + pushMetadataArg(args, 'title', titleOverride || bookTitle); + pushMetadataArg(args, 'album', albumOverride || bookTitle); + pushMetadataArg(args, 'artist', author); + pushMetadataArg(args, 'album_artist', author); + pushMetadataArg(args, 'author', author); + pushMetadataArg(args, 'narrator', source.narrator); + pushMetadataArg(args, 'performer', source.narrator); + pushMetadataArg(args, 'grouping', source.series); + pushMetadataArg(args, 'series', source.series); + pushMetadataArg(args, 'disc', source.part); + pushMetadataArg(args, 'description', source.description); + pushMetadataArg(args, 'comment', source.description); + if (source.year) { + pushMetadataArg(args, 'date', String(source.year)); + pushMetadataArg(args, 'year', String(source.year)); + } + if (Number.isFinite(trackNo) && trackNo > 0) { + const formattedTrack = Number.isFinite(trackTotal) && trackTotal > 0 + ? `${Math.trunc(trackNo)}/${Math.trunc(trackTotal)}` + : String(Math.trunc(trackNo)); + pushMetadataArg(args, 'track', formattedTrack); + } + return args; +} + +function buildCodecArgs(format, normalizedOptions) { + if (format === 'm4b') { + return ['-c:a', 'copy']; + } + if (format === 'flac') { + return ['-codec:a', 'flac', '-compression_level', String(normalizedOptions.flacCompression)]; + } + if (normalizedOptions.mp3Mode === 'vbr') { + return ['-codec:a', 'libmp3lame', '-q:a', String(normalizedOptions.mp3Quality)]; + } + return ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`]; +} + +function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}, options = {}) { + const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg'; + const format = normalizeOutputFormat(outputFormat); + const normalizedOptions = normalizeFormatOptions(format, formatOptions); + const extra = options && typeof options === 'object' ? options : {}; + const commonArgs = [ + '-y', + ...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []), + '-i', inputPath + ]; + if (extra.chapterMetadataPath) { + commonArgs.push('-f', 'ffmetadata', '-i', extra.chapterMetadataPath); + } + commonArgs.push( + '-map', '0:a:0?', + '-map_metadata', '0', + '-map_chapters', extra.chapterMetadataPath ? '1' : '0', + '-vn', + '-sn', + '-dn' + ); + const metadataArgs = buildMetadataArgs(extra.metadata, extra.metadataOptions); + const codecArgs = buildCodecArgs(format, normalizedOptions); + return { + cmd, + args: [...commonArgs, ...codecArgs, ...metadataArgs, outputPath], + metadataArgs, + formatOptions: normalizedOptions + }; +} + +function formatSecondsArg(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return '0'; + } + return parsed.toFixed(3).replace(/\.?0+$/u, ''); +} + +function buildChapterEncodeCommand( + ffmpegCommand, + inputPath, + outputPath, + outputFormat = 'mp3', + formatOptions = {}, + metadata = {}, + chapter = {}, + chapterTotal = 1, + options = {} +) { + const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg'; + const format = normalizeOutputFormat(outputFormat); + const normalizedOptions = normalizeFormatOptions(format, formatOptions); + const extra = options && typeof options === 'object' ? options : {}; + const safeChapter = normalizeChapterList([chapter], { + durationMs: metadata?.durationMs, + fallbackTitle: metadata?.title || 'Kapitel', + createFallback: true + })[0]; + const durationSeconds = Number(((safeChapter?.durationMs || 0) / 1000).toFixed(3)); + const metadataArgs = buildMetadataArgs(metadata, { + title: safeChapter?.title, + album: metadata?.title || metadata?.album || null, + trackNo: safeChapter?.index || 1, + trackTotal: chapterTotal + }); + const codecArgs = buildCodecArgs(format, normalizedOptions); + return { + cmd, + args: [ + '-y', + ...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []), + '-i', inputPath, + '-ss', formatSecondsArg(safeChapter?.startSeconds), + '-t', formatSecondsArg(durationSeconds), + '-map', '0:a:0?', + '-map_metadata', '-1', + '-map_chapters', '-1', + '-vn', + '-sn', + '-dn', + ...codecArgs, + ...metadataArgs, + outputPath + ], + metadataArgs, + formatOptions: normalizedOptions + }; +} + +function escapeFfmetadataValue(value) { + return String(value == null ? '' : value) + .replace(/\\/g, '\\\\') + .replace(/=/g, '\\=') + .replace(/;/g, '\\;') + .replace(/#/g, '\\#') + .replace(/\r?\n/g, ' '); +} + +function buildChapterMetadataContent(chapters = [], metadata = {}) { + const normalizedChapters = normalizeChapterList(chapters, { + durationMs: metadata?.durationMs, + fallbackTitle: metadata?.title || metadata?.album || 'Audiobook', + createFallback: true + }); + + const chapterBlocks = normalizedChapters.map((chapter) => { + const startMs = Math.max(0, Math.round(chapter.startMs || 0)); + const endMs = Math.max(startMs, Math.round(chapter.endMs || startMs)); + return [ + '[CHAPTER]', + 'TIMEBASE=1/1000', + `START=${startMs}`, + `END=${endMs}`, + `title=${escapeFfmetadataValue(chapter.title || `Kapitel ${chapter.index || 1}`)}` + ].join('\n'); + }).join('\n\n'); + + return `;FFMETADATA1\n\n${chapterBlocks}`; +} + +function buildCoverExtractionCommand(ffmpegCommand, inputPath, outputPath, cover = null) { + const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg'; + const streamIndex = Number(cover?.streamIndex); + const streamSpecifier = Number.isFinite(streamIndex) && streamIndex >= 0 + ? `0:${Math.trunc(streamIndex)}` + : '0:v:0'; + return { + cmd, + args: [ + '-y', + '-i', inputPath, + '-map', streamSpecifier, + '-an', + '-sn', + '-dn', + '-frames:v', '1', + '-c:v', 'mjpeg', + '-q:v', '2', + outputPath + ] + }; +} + +function parseFfmpegTimestampToMs(rawValue) { + const value = String(rawValue || '').trim(); + const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/); + if (!match) { + return null; + } + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const fraction = match[4] ? Number(`0.${match[4]}`) : 0; + if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) { + return null; + } + return Math.round((((hours * 60) + minutes) * 60 + seconds + fraction) * 1000); +} + +function buildProgressParser(totalDurationMs) { + const durationMs = Number(totalDurationMs || 0); + if (!Number.isFinite(durationMs) || durationMs <= 0) { + return null; + } + return (line) => { + const match = String(line || '').match(/time=(\d+:\d{2}:\d{2}(?:\.\d+)?)/i); + if (!match) { + return null; + } + const currentMs = parseFfmpegTimestampToMs(match[1]); + if (!Number.isFinite(currentMs)) { + return null; + } + const percent = Math.max(0, Math.min(100, Number(((currentMs / durationMs) * 100).toFixed(2)))); + return { + percent, + eta: null + }; + }; +} + +module.exports = { + SUPPORTED_INPUT_EXTENSIONS, + SUPPORTED_OUTPUT_FORMATS, + DEFAULT_AUDIOBOOK_RAW_TEMPLATE, + DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, + DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE, + AUDIOBOOK_FORMAT_DEFAULTS, + normalizeOutputFormat, + getDefaultFormatOptions, + normalizeFormatOptions, + isSupportedInputFile, + buildMetadataFromProbe, + normalizeChapterList, + buildRawStoragePaths, + buildOutputPath, + buildChapterOutputPlan, + buildProbeCommand, + parseProbeOutput, + buildEncodeCommand, + buildChapterEncodeCommand, + buildChapterMetadataContent, + buildCoverExtractionCommand, + buildProgressParser +}; diff --git a/backend/src/services/audnexService.js b/backend/src/services/audnexService.js new file mode 100644 index 0000000..f361eee --- /dev/null +++ b/backend/src/services/audnexService.js @@ -0,0 +1,196 @@ +const fs = require('fs'); +const logger = require('./logger').child('AUDNEX'); + +const AUDNEX_BASE_URL = 'https://api.audnex.us'; +const AUDNEX_TIMEOUT_MS = 10000; +const ASIN_PATTERN = /B0[0-9A-Z]{8}/u; + +function normalizeAsin(value) { + const raw = String(value || '').trim().toUpperCase(); + return ASIN_PATTERN.test(raw) ? raw : null; +} + +async function extractAsinFromAaxFile(filePath) { + const sourcePath = String(filePath || '').trim(); + if (!sourcePath) { + return null; + } + + return new Promise((resolve, reject) => { + let printableWindow = ''; + let settled = false; + const stream = fs.createReadStream(sourcePath, { highWaterMark: 64 * 1024 }); + + const finish = (value) => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; + + stream.on('data', (chunk) => { + if (settled) { + return; + } + + for (const byte of chunk) { + if (byte >= 32 && byte <= 126) { + printableWindow = `${printableWindow}${String.fromCharCode(byte)}`.slice(-48); + const match = printableWindow.match(/B0[0-9A-Z]{8}/u); + if (match?.[0]) { + const asin = normalizeAsin(match[0]); + if (asin) { + logger.info('asin:detected', { filePath: sourcePath, asin }); + stream.destroy(); + finish(asin); + return; + } + } + } else { + printableWindow = ''; + } + } + }); + + stream.on('error', (error) => { + if (settled) { + return; + } + settled = true; + reject(error); + }); + + stream.on('close', () => { + if (!settled) { + finish(null); + } + }); + }); +} + +async function audnexFetch(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), AUDNEX_TIMEOUT_MS); + try { + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Ripster/1.0' + }, + signal: controller.signal + }); + clearTimeout(timer); + if (!response.ok) { + throw new Error(`Audnex Anfrage fehlgeschlagen (${response.status})`); + } + return response.json(); + } catch (error) { + clearTimeout(timer); + throw error; + } +} + +function extractChapterArray(payload) { + if (Array.isArray(payload)) { + return payload; + } + const candidates = [ + payload?.chapters, + payload?.data?.chapters, + payload?.content?.chapters, + payload?.results?.chapters + ]; + return candidates.find((entry) => Array.isArray(entry)) || []; +} + +function normalizeAudnexChapter(entry, index) { + const startOffsetMs = Number( + entry?.startOffsetMs + ?? entry?.startMs + ?? entry?.offsetMs + ?? 0 + ); + const lengthMs = Number( + entry?.lengthMs + ?? entry?.durationMs + ?? entry?.length + ?? 0 + ); + const title = String(entry?.title || entry?.chapterTitle || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`; + const safeStartMs = Number.isFinite(startOffsetMs) && startOffsetMs >= 0 ? Math.round(startOffsetMs) : 0; + const safeLengthMs = Number.isFinite(lengthMs) && lengthMs > 0 ? Math.round(lengthMs) : 0; + + return { + index: index + 1, + title, + startMs: safeStartMs, + endMs: safeStartMs + safeLengthMs, + startSeconds: Math.round(safeStartMs / 1000), + endSeconds: Math.round((safeStartMs + safeLengthMs) / 1000) + }; +} + +function normalizeNameList(items) { + if (!Array.isArray(items) || items.length === 0) { + return null; + } + const names = items + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + return names.length > 0 ? names.join(', ') : null; +} + +async function fetchBookByAsin(asin, region = 'de') { + const normalizedAsin = normalizeAsin(asin); + if (!normalizedAsin) { + return null; + } + + const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}`); + url.searchParams.set('region', String(region || 'de').trim() || 'de'); + logger.info('book:fetch:start', { asin: normalizedAsin, url: url.toString() }); + + const payload = await audnexFetch(url.toString()); + if (!payload || typeof payload !== 'object') { + return null; + } + + const narrator = normalizeNameList(payload.narrators); + const author = normalizeNameList(payload.authors); + const title = String(payload.title || '').trim() || null; + const series = String(payload.seriesName || payload.series || '').trim() || null; + const part = String(payload.seriesPart || payload.part || '').trim() || null; + const description = String(payload.summary || payload.description || '').trim() || null; + const year = payload.releaseDate + ? String(payload.releaseDate).slice(0, 4) + : null; + + logger.info('book:fetch:done', { asin: normalizedAsin, narrator, author, title }); + return { narrator, author, title, series, part, description, year }; +} + +async function fetchChaptersByAsin(asin, region = 'de') { + const normalizedAsin = normalizeAsin(asin); + if (!normalizedAsin) { + return []; + } + + const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}/chapters`); + url.searchParams.set('region', String(region || 'de').trim() || 'de'); + logger.info('chapters:fetch:start', { asin: normalizedAsin, url: url.toString() }); + + const payload = await audnexFetch(url.toString()); + const chapters = extractChapterArray(payload) + .map((entry, index) => normalizeAudnexChapter(entry, index)) + .filter((chapter) => chapter.endMs > chapter.startMs && chapter.title); + + logger.info('chapters:fetch:done', { asin: normalizedAsin, count: chapters.length }); + return chapters; +} + +module.exports = { + extractAsinFromAaxFile, + fetchBookByAsin, + fetchChaptersByAsin +}; diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js new file mode 100644 index 0000000..90e73bf --- /dev/null +++ b/backend/src/services/cdRipService.js @@ -0,0 +1,784 @@ +const fs = require('fs'); +const path = require('path'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const logger = require('./logger').child('CD_RIP'); +const { spawnTrackedProcess } = require('./processRunner'); +const { parseCdParanoiaProgress } = require('../utils/progressParsers'); +const { ensureDir, transliterateForFilename } = require('../utils/files'); +const { errorToMeta } = require('../utils/errorMeta'); + +const execFileAsync = promisify(execFile); + +const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']); +const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}'; + +/** + * Parse cdparanoia -Q output to extract track information. + * Supports both bracket styles shown by different builds: + * track 1: 0 (00:00.00) 24218 (05:22.43) + * track 1: 0 [00:00.00] 24218 [05:22.43] + */ +function parseToc(tocOutput) { + const lines = String(tocOutput || '').split(/\r?\n/); + const tracks = []; + const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g; + const tocTablePattern = + /^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i; + + for (const line of lines) { + const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i); + if (trackMatch) { + const position = Number(trackMatch[1]); + const payloadWithoutTimes = String(trackMatch[2] || '') + .replace(tocTimePattern, ' '); + const sectorValues = payloadWithoutTimes.match(/\d+/g) || []; + if (sectorValues.length < 2) { + continue; + } + + const startSector = Number(sectorValues[0]); + const lengthSector = Number(sectorValues[1]); + if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) { + continue; + } + if (position <= 0 || startSector < 0 || lengthSector <= 0) { + continue; + } + + // duration in seconds: sectors / 75 + const durationSec = Math.round(lengthSector / 75); + tracks.push({ + position, + startSector, + lengthSector, + durationSec, + durationMs: durationSec * 1000 + }); + continue; + } + + // Alternative cdparanoia -Q table style: + // 1. 16503 [03:40.03] 0 [00:00.00] no no 2 + // ^ length sectors ^ start sector + const tableMatch = line.match(tocTablePattern); + if (!tableMatch) { + continue; + } + + const position = Number(tableMatch[1]); + const lengthSector = Number(tableMatch[2]); + const startSector = Number(tableMatch[3]); + if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) { + continue; + } + if (position <= 0 || startSector < 0 || lengthSector <= 0) { + continue; + } + + const durationSec = Math.round(lengthSector / 75); + tracks.push({ + position, + startSector, + lengthSector, + durationSec, + durationMs: durationSec * 1000 + }); + } + + return tracks; +} + +async function readToc(devicePath, cmd) { + const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia'; + logger.info('toc:read', { devicePath, cmd: cdparanoia }); + try { + // Depending on distro/build, TOC can appear on stderr and/or stdout. + const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], { + timeout: 15000 + }); + const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`); + logger.info('toc:done', { devicePath, trackCount: tracks.length }); + return tracks; + } catch (error) { + // cdparanoia -Q may exit non-zero even when TOC is readable. + const stderr = String(error?.stderr || ''); + const stdout = String(error?.stdout || ''); + const tracks = parseToc(`${stderr}\n${stdout}`); + if (tracks.length > 0) { + logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length }); + return tracks; + } + logger.warn('toc:failed', { devicePath, error: errorToMeta(error) }); + return []; + } +} + +function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) { + const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate); + const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav'; + return `${relativeBasePath}.${ext}`; +} + +function sanitizePathSegment(value, fallback = 'unknown') { + const raw = transliterateForFilename(String(value == null ? '' : value)) + .replace(/[\\/:*?"<>|]/g, '-') + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!raw || raw === '.' || raw === '..') { + return fallback; + } + return raw.slice(0, 180); +} + +function normalizeTemplateTokenKey(rawKey) { + const key = String(rawKey || '').trim().toLowerCase(); + if (!key) { + return ''; + } + if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') { + return 'trackNr'; + } + if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') { + return 'trackNo'; + } + if (key === 'trackartist' || key === 'track_artist') { + return 'trackArtist'; + } + if (key === 'albumartist') { + return 'albumArtist'; + } + if (key === 'interpret') { + return 'artist'; + } + return key; +} + +function cleanupRenderedTemplate(value) { + return String(value || '') + .replace(/\(\s*\)/g, '') + .replace(/\[\s*]/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +function renderOutputTemplate(template, values) { + const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE; + const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => { + const normalizedKey = normalizeTemplateTokenKey(keyA || keyB); + const rawValue = values[normalizedKey]; + if (rawValue === undefined || rawValue === null) { + return ''; + } + return String(rawValue); + }); + return cleanupRenderedTemplate(rendered); +} + +function buildTemplateValues(track, meta, format = null) { + const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1; + const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`); + const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist'); + const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist'); + const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album'); + const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), ''); + return { + artist: albumArtist, + albumArtist, + trackArtist, + album, + year, + title: trackTitle, + trackNr: String(trackNo).padStart(2, '0'), + trackNo: String(trackNo), + format: format ? String(format).trim().toLowerCase() : '' + }; +} + +function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) { + const values = buildTemplateValues(track, meta, format); + const rendered = renderOutputTemplate(outputTemplate, values) + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, ''); + + const parts = rendered + .split('/') + .map((part) => sanitizePathSegment(part, 'unknown')) + .filter(Boolean); + + if (parts.length === 0) { + return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`; + } + + return path.join(...parts); +} + +function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) { + const sampleTrack = { + position: 1, + title: 'Track 1' + }; + const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate); + const relativeDir = path.dirname(relativeBasePath); + if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) { + return baseDir; + } + return path.join(baseDir, relativeDir); +} + +function splitPathSegments(value) { + return String(value || '') + .replace(/\\/g, '/') + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean); +} + +function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) { + const outputSegments = splitPathSegments(outputBaseDir); + const relativeSegments = splitPathSegments(relativeDir); + if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) { + return false; + } + const offset = outputSegments.length - relativeSegments.length; + for (let i = 0; i < relativeSegments.length; i++) { + const outputPart = outputSegments[offset + i]; + const relativePart = relativeSegments[i]; + if (outputPart === relativePart) { + continue; + } + // Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2"). + // Treat this as containing the same relative directory to avoid nesting: + // Album (2007)_2/Album (2007)/... + const isLastRelativeSegment = i === relativeSegments.length - 1; + const matchesNumberedSuffix = isLastRelativeSegment + && outputPart.startsWith(`${relativePart}_`) + && /^\d+$/.test(outputPart.slice(relativePart.length + 1)); + if (!matchesNumberedSuffix) { + return false; + } + } + return true; +} + +function stripLeadingRelativeDir(relativeFilePath, relativeDir) { + const fileSegments = splitPathSegments(relativeFilePath); + const dirSegments = splitPathSegments(relativeDir); + if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) { + return relativeFilePath; + } + for (let i = 0; i < dirSegments.length; i++) { + if (fileSegments[i] !== dirSegments[i]) { + return relativeFilePath; + } + } + return path.join(...fileSegments.slice(dirSegments.length)); +} + +function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) { + const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format); + const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav'; + const relativeDir = path.dirname(relativeBasePath); + let relativeFilePath = `${relativeBasePath}.${ext}`; + if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) { + if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) { + relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir); + } + } + const outFile = path.join(outputBaseDir, relativeFilePath); + return { + outFile, + relativeFilePath, + outFilename: path.basename(relativeFilePath) + }; +} + +function buildCancelledError() { + const error = new Error('Job wurde vom Benutzer abgebrochen.'); + error.statusCode = 409; + return error; +} + +function assertNotCancelled(isCancelled) { + if (typeof isCancelled === 'function' && isCancelled()) { + throw buildCancelledError(); + } +} + +function normalizeExitCode(error) { + const code = Number(error?.code); + if (Number.isFinite(code)) { + return Math.trunc(code); + } + return 1; +} + +function quoteShellArg(value) { + const text = String(value == null ? '' : value); + if (!text) { + return "''"; + } + if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) { + return text; + } + return `'${text.replace(/'/g, "'\\''")}'`; +} + +function formatCommandLine(cmd, args = []) { + const normalizedArgs = Array.isArray(args) ? args : []; + return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' '); +} + +function copyFilePreservingRaw(sourcePath, targetPath) { + const rawSource = String(sourcePath || '').trim(); + const rawTarget = String(targetPath || '').trim(); + if (!rawSource || !rawTarget) { + return; + } + const source = path.resolve(rawSource); + const target = path.resolve(rawTarget); + if (source === target) { + return; + } + fs.copyFileSync(source, target); +} + +async function runProcessTracked({ + cmd, + args, + cwd, + onStdoutLine, + onStderrLine, + context, + onProcessHandle, + isCancelled +}) { + assertNotCancelled(isCancelled); + const handle = spawnTrackedProcess({ + cmd, + args, + cwd, + onStdoutLine, + onStderrLine, + context + }); + if (typeof onProcessHandle === 'function') { + onProcessHandle(handle); + } + if (typeof isCancelled === 'function' && isCancelled()) { + handle.cancel(); + } + try { + return await handle.promise; + } catch (error) { + if (typeof isCancelled === 'function' && isCancelled()) { + throw buildCancelledError(); + } + throw error; + } +} + +/** + * Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir. + * Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files). + */ +function getSortedPlainWavFiles(rawWavDir) { + try { + return fs.readdirSync(rawWavDir) + .filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f)) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + } catch (_) { + return []; + } +} + +/** + * Resolves the WAV file path for a given track position. + * Primary: track01.cdda.wav (cdparanoia format) + * Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames) + */ +function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) { + const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`); + if (fs.existsSync(cddaPath)) return cddaPath; + if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) { + const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]); + if (fs.existsSync(fallback)) return fallback; + } + return cddaPath; +} + +/** + * Rip and encode a CD. + * + * @param {object} options + * @param {string} options.jobId - Job ID for logging + * @param {string} options.devicePath - e.g. /dev/sr0 + * @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia + * @param {string} options.rawWavDir - temp dir for WAV files + * @param {string} options.outputDir - final output dir + * @param {string} options.format - wav|flac|mp3|opus|ogg + * @param {object} options.formatOptions - encoder-specific options + * @param {number[]} options.selectedTracks - track positions to rip (empty = all) + * @param {object[]} options.tracks - TOC track list [{position, durationMs, title}] + * @param {object} options.meta - album metadata {title, artist, year} + * @param {string} options.outputTemplate - template for relative output path without extension + * @param {boolean} options.skipEncode - true => rip only (no encode) + * @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void + * @param {Function} options.onLog - (level, msg) => void + * @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration + * @param {Function} options.isCancelled - returns true when user requested cancellation + * @param {object} options.context - passed to spawnTrackedProcess + */ +async function ripAndEncode(options) { + const { + jobId, + devicePath, + cdparanoiaCmd = 'cdparanoia', + rawWavDir, + outputDir, + format = 'flac', + formatOptions = {}, + selectedTracks = [], + tracks = [], + meta = {}, + outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, + onProgress, + onLog, + onProcessHandle, + isCancelled, + context, + skipRip = false, + skipEncode = false + } = options; + + if (!skipEncode && !SUPPORTED_FORMATS.has(format)) { + throw new Error(`Unbekanntes Ausgabeformat: ${format}`); + } + + const tracksToRip = selectedTracks.length > 0 + ? tracks.filter((t) => selectedTracks.includes(t.position)) + : tracks; + + if (tracksToRip.length === 0) { + throw new Error('Keine Tracks zum Rippen ausgewählt.'); + } + + await ensureDir(rawWavDir); + if (!skipEncode) { + await ensureDir(outputDir); + } + + logger.info('rip:start', { + jobId, + devicePath, + format, + trackCount: tracksToRip.length + }); + + const log = (level, msg) => { + logger[level] && logger[level](msg, { jobId }); + onLog && onLog(level, msg); + }; + const ripPercentSpan = skipEncode ? 100 : 50; + const encodePercentStart = ripPercentSpan; + const encodePercentSpan = Math.max(0, 100 - encodePercentStart); + + // ── Phase 1: Rip each selected track to WAV ────────────────────────────── + const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null; + + if (skipRip) { + // Encode-only: WAV-Dateien müssen bereits vorhanden sein + for (const track of tracksToRip) { + const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles); + if (!fs.existsSync(wavFile)) { + throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`); + } + } + } else { + for (let i = 0; i < tracksToRip.length; i++) { + assertNotCancelled(isCancelled); + const track = tracksToRip[i]; + const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); + const ripArgs = ['-d', devicePath, String(track.position), wavFile]; + + onProgress && onProgress({ + phase: 'rip', + trackEvent: 'start', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + trackPercent: 0, + percent: (i / tracksToRip.length) * ripPercentSpan + }); + + log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`); + log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`); + + try { + await runProcessTracked({ + cmd: cdparanoiaCmd, + args: ripArgs, + cwd: rawWavDir, + onStderrLine(line) { + const parsed = parseCdParanoiaProgress(line); + if (parsed && parsed.percent !== null) { + const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * ripPercentSpan; + onProgress && onProgress({ + phase: 'rip', + trackEvent: 'progress', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + trackPercent: parsed.percent, + percent: overallPercent + }); + } + }, + context, + onProcessHandle, + isCancelled + }); + } catch (error) { + if (String(error?.message || '').toLowerCase().includes('abgebrochen')) { + throw error; + } + throw new Error( + `cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})` + ); + } + + onProgress && onProgress({ + phase: 'rip', + trackEvent: 'complete', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + trackPercent: 100, + percent: ((i + 1) / tracksToRip.length) * ripPercentSpan + }); + + log('info', `Track ${track.position} gerippt.`); + } + } // end if (!skipRip) + + if (skipEncode) { + return { + outputDir: null, + format: 'raw', + trackCount: tracksToRip.length, + encodeResults: [] + }; + } + + // ── Phase 2: Encode WAVs to target format ───────────────────────────────── + const encodeResults = []; + + if (format === 'wav') { + // Keep RAW WAVs in place and copy them to the final output structure. + for (let i = 0; i < tracksToRip.length; i++) { + assertNotCancelled(isCancelled); + const track = tracksToRip[i]; + const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles); + const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate); + onProgress && onProgress({ + phase: 'encode', + trackEvent: 'start', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + trackPercent: 0, + percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan) + }); + ensureDir(path.dirname(outFile)); + log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`); + copyFilePreservingRaw(wavFile, outFile); + onProgress && onProgress({ + phase: 'encode', + trackEvent: 'complete', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + trackPercent: 100, + percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan + }); + log('info', `WAV für Track ${track.position} gespeichert.`); + encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true }); + } + return { outputDir, format, trackCount: tracksToRip.length, encodeResults }; + } + + for (let i = 0; i < tracksToRip.length; i++) { + assertNotCancelled(isCancelled); + const track = tracksToRip[i]; + const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles); + + if (!fs.existsSync(wavFile)) { + throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`); + } + + const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate); + ensureDir(path.dirname(outFile)); + + onProgress && onProgress({ + phase: 'encode', + trackEvent: 'start', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + trackPercent: 0, + percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan) + }); + + log('info', `Encodiere Track ${track.position} → ${outFilename} …`); + + const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile); + log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`); + + try { + await runProcessTracked({ + cmd: encodeArgs.cmd, + args: encodeArgs.args, + cwd: rawWavDir, + onStdoutLine() {}, + onStderrLine() {}, + context, + onProcessHandle, + isCancelled + }); + } catch (error) { + if (String(error?.message || '').toLowerCase().includes('abgebrochen')) { + throw error; + } + encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false }); + throw new Error( + `${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})` + ); + } + + // Safety net: some encoders (e.g. older flac without --no-delete-input-file) may remove + // the source WAV. Restore it from the encoded output so the RAW folder stays filled. + if (!fs.existsSync(wavFile) && fs.existsSync(outFile)) { + try { + fs.copyFileSync(outFile, wavFile); + log('info', `Track ${track.position}: WAV-Quelldatei vom Encoder gelöscht – aus Output wiederhergestellt.`); + } catch (restoreErr) { + log('warn', `Track ${track.position}: WAV-Wiederherstellung fehlgeschlagen: ${restoreErr.message}`); + } + } + + onProgress && onProgress({ + phase: 'encode', + trackEvent: 'complete', + trackIndex: i + 1, + trackTotal: tracksToRip.length, + trackPosition: track.position, + trackPercent: 100, + percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan + }); + + log('info', `Track ${track.position} encodiert.`); + encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: true }); + } + + return { outputDir, format, trackCount: tracksToRip.length, encodeResults }; +} + +function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) { + const artist = track?.artist || meta?.artist || ''; + const album = meta?.title || ''; + const year = meta?.year ? String(meta.year) : ''; + const trackTitle = track.title || `Track ${track.position}`; + const trackNum = String(track.position); + + if (format === 'flac') { + const level = Number(opts.flacCompression ?? 5); + const clampedLevel = Math.max(0, Math.min(8, level)); + return { + cmd: 'flac', + args: [ + `--compression-level-${clampedLevel}`, + '--no-delete-input-file', // flac deletes input WAV by default; keep RAW folder filled + '--tag', `TITLE=${trackTitle}`, + '--tag', `ARTIST=${artist}`, + '--tag', `ALBUM=${album}`, + '--tag', `DATE=${year}`, + '--tag', `TRACKNUMBER=${trackNum}`, + wavFile, + '-o', outFile + ] + }; + } + + if (format === 'mp3') { + const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase(); + const args = ['--id3v2-only', '--noreplaygain']; + if (mode === 'vbr') { + const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4))); + args.push('-V', String(quality)); + } else { + const bitrate = Number(opts.mp3Bitrate ?? 192); + args.push('-b', String(bitrate)); + } + args.push( + '--tt', trackTitle, + '--ta', artist, + '--tl', album, + '--ty', year, + '--tn', trackNum, + wavFile, + outFile + ); + return { cmd: 'lame', args }; + } + + if (format === 'opus') { + const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160))); + const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10))); + return { + cmd: 'opusenc', + args: [ + '--bitrate', String(bitrate), + '--comp', String(complexity), + '--title', trackTitle, + '--artist', artist, + '--album', album, + '--date', year, + '--tracknumber', trackNum, + wavFile, + outFile + ] + }; + } + + if (format === 'ogg') { + const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6))); + return { + cmd: 'oggenc', + args: [ + '-q', String(quality), + '-t', trackTitle, + '-a', artist, + '-l', album, + '-d', year, + '-N', trackNum, + '-o', outFile, + wavFile + ] + }; + } + + throw new Error(`Unbekanntes Format: ${format}`); +} + +module.exports = { + parseToc, + readToc, + ripAndEncode, + buildOutputDir, + buildOutputFilename, + DEFAULT_CD_OUTPUT_TEMPLATE, + SUPPORTED_FORMATS +}; diff --git a/backend/src/services/converterScanService.js b/backend/src/services/converterScanService.js new file mode 100644 index 0000000..b215d36 --- /dev/null +++ b/backend/src/services/converterScanService.js @@ -0,0 +1,697 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { getDb } = require('../db/database'); +const settingsService = require('./settingsService'); +const wsService = require('./websocketService'); +const logger = require('./logger').child('CONVERTER_SCAN'); +const { + defaultConverterRawDir +} = require('../config'); + +const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']); +const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']); +const ISO_EXTENSIONS = new Set(['iso']); +const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]); + +let _pollingTimer = null; +let _pollingEnabled = false; +let _pollingInterval = 300; + +function detectMediaType(fileName) { + const ext = path.extname(String(fileName || '')).slice(1).toLowerCase(); + if (ISO_EXTENSIONS.has(ext)) return 'iso'; + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; + return null; +} + +function detectFormat(fileName) { + return path.extname(String(fileName || '')).slice(1).toLowerCase() || null; +} + +function getConfiguredExtensions(settings) { + const raw = String(settings?.converter_scan_extensions || '').trim(); + if (!raw) { + return new Set(SUPPORTED_SCAN_EXTENSIONS); + } + const configured = raw.split(',') + .map((ext) => ext.trim().toLowerCase()) + .filter(Boolean); + const filtered = configured.filter((ext) => SUPPORTED_SCAN_EXTENSIONS.has(ext)); + if (filtered.length === 0) { + return new Set(SUPPORTED_SCAN_EXTENSIONS); + } + return new Set(filtered); +} + +function getFileSize(fullPath) { + try { + return fs.statSync(fullPath).size; + } catch (_err) { + return null; + } +} + +const SCAN_MAX_DEPTH = 4; + +/** + * Rekursiv alle Dateien und direkten Unterordner im rawDir scannen. + * Gibt ein flaches Array von { relPath, entryType, fileSize, detectedMediaType, detectedFormat } zurück. + * Maximale Tiefe: SCAN_MAX_DEPTH (verhindert unendliche Rekursion bei geschachtelten Ordnern). + */ +function scanDirectory(rawDir, allowedExtensions, parentRelPath = '', depth = 0) { + const entries = []; + + if (depth >= SCAN_MAX_DEPTH) { + return entries; + } + + let dirEntries; + try { + dirEntries = fs.readdirSync(rawDir, { withFileTypes: true }); + } catch (_err) { + return entries; + } + + for (const dirent of dirEntries) { + const relPath = parentRelPath ? `${parentRelPath}/${dirent.name}` : dirent.name; + const fullPath = path.join(rawDir, relPath); + + if (dirent.isDirectory()) { + entries.push({ + relPath, + entryType: 'directory', + fileSize: null, + detectedMediaType: null, + detectedFormat: null + }); + // Rekursiv in Unterordner (mit Tiefenbegrenzung) + const subEntries = scanDirectory(rawDir, allowedExtensions, relPath, depth + 1); + entries.push(...subEntries); + } else if (dirent.isFile()) { + const ext = path.extname(dirent.name).slice(1).toLowerCase(); + if (!allowedExtensions.has(ext)) { + continue; + } + entries.push({ + relPath, + entryType: 'file', + fileSize: getFileSize(fullPath), + detectedMediaType: detectMediaType(dirent.name), + detectedFormat: detectFormat(dirent.name) + }); + } + } + + return entries; +} + +/** + * Scan-Ergebnisse in die DB schreiben (INSERT OR REPLACE) und + * nicht mehr vorhandene Einträge ohne Job entfernen. + */ +async function persistScanResults(rawDir, entries) { + const db = await getDb(); + + if (entries.length > 0) { + const stmt = await db.prepare(` + INSERT INTO converter_scan_entries (rel_path, entry_type, file_size, detected_media_type, detected_format, last_seen_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(rel_path) DO UPDATE SET + entry_type = excluded.entry_type, + file_size = excluded.file_size, + detected_media_type = excluded.detected_media_type, + detected_format = excluded.detected_format, + last_seen_at = excluded.last_seen_at + `); + + for (const entry of entries) { + await stmt.run( + entry.relPath, + entry.entryType, + entry.fileSize, + entry.detectedMediaType, + entry.detectedFormat + ); + } + await stmt.finalize(); + } + + // Einträge ohne zugewiesenen Job entfernen, wenn Datei nicht mehr vorhanden + const existingEntries = await db.all( + `SELECT rel_path FROM converter_scan_entries WHERE job_id IS NULL` + ); + const currentRelPaths = new Set(entries.map((e) => e.relPath)); + + for (const row of existingEntries) { + if (!currentRelPaths.has(row.rel_path)) { + const fullPath = path.join(rawDir, row.rel_path); + if (!fs.existsSync(fullPath)) { + await db.run( + `DELETE FROM converter_scan_entries WHERE rel_path = ? AND job_id IS NULL`, + [row.rel_path] + ); + } + } + } +} + +/** + * Hauptmethode: rawDir scannen, DB aktualisieren, WebSocket-Event senden. + */ +async function scan() { + const settings = await settingsService.getSettingsMap(); + const rawDir = String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim(); + + if (!rawDir) { + logger.warn('converter:scan:no-raw-dir'); + return { rawDir: null, entryCount: 0 }; + } + + if (!fs.existsSync(rawDir)) { + logger.warn('converter:scan:dir-missing', { rawDir }); + return { rawDir, entryCount: 0 }; + } + + const allowedExtensions = getConfiguredExtensions(settings); + logger.info('converter:scan:start', { rawDir, allowedExtensions: [...allowedExtensions] }); + + const entries = scanDirectory(rawDir, allowedExtensions); + await persistScanResults(rawDir, entries); + + logger.info('converter:scan:done', { rawDir, entryCount: entries.length }); + + wsService.broadcast('CONVERTER_SCAN_UPDATE', { entryCount: entries.length }); + + return { rawDir, entryCount: entries.length }; +} + +/** + * Alle Einträge für den File-Explorer zurückgeben. + * Optionaler parentRelPath filtert auf Kinder eines bestimmten Verzeichnisses. + */ +async function getEntries(parentRelPath = null) { + const db = await getDb(); + + let rows; + if (!parentRelPath) { + // Root-Ebene: nur Einträge ohne '/' im rel_path + rows = await db.all(` + SELECT e.*, j.status AS job_status, j.title AS job_title + FROM converter_scan_entries e + LEFT JOIN jobs j ON j.id = e.job_id + ORDER BY e.entry_type DESC, e.rel_path ASC + `); + // Nur direkte Kinder (kein '/' im rel_path) + rows = rows.filter((r) => !String(r.rel_path).includes('/')); + } else { + const prefix = parentRelPath.endsWith('/') ? parentRelPath : `${parentRelPath}/`; + rows = await db.all(` + SELECT e.*, j.status AS job_status, j.title AS job_title + FROM converter_scan_entries e + LEFT JOIN jobs j ON j.id = e.job_id + ORDER BY e.entry_type DESC, e.rel_path ASC + `); + // Direkte Kinder des angegebenen Verzeichnisses + rows = rows.filter((r) => { + const rel = String(r.rel_path); + if (!rel.startsWith(prefix)) return false; + const remainder = rel.slice(prefix.length); + return !remainder.includes('/'); + }); + } + + return rows.map((r) => ({ + id: r.id, + relPath: r.rel_path, + entryType: r.entry_type, + fileSize: r.file_size, + detectedMediaType: r.detected_media_type, + detectedFormat: r.detected_format, + jobId: r.job_id, + jobStatus: r.job_status || null, + jobTitle: r.job_title || null, + lastSeenAt: r.last_seen_at + })); +} + +async function getEntryById(id) { + const db = await getDb(); + const row = await db.get( + `SELECT * FROM converter_scan_entries WHERE id = ?`, + [Number(id)] + ); + return row || null; +} + +async function getEntryByRelPath(relPath) { + const db = await getDb(); + const row = await db.get( + `SELECT * FROM converter_scan_entries WHERE rel_path = ?`, + [relPath] + ); + return row || null; +} + +async function setEntryJobAssignment(relPath, jobId) { + const normalizedRelPath = normalizeRelPath(relPath); + if (normalizedRelPath === null || normalizedRelPath === '') { + throw makeError('Ungültiger relPath für Job-Zuweisung.', 400); + } + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + throw makeError('Ungültige jobId für Job-Zuweisung.', 400); + } + + const db = await getDb(); + const existing = await db.get( + `SELECT entry_type, file_size, detected_media_type, detected_format + FROM converter_scan_entries + WHERE rel_path = ?`, + [normalizedRelPath] + ); + + let entryType = String(existing?.entry_type || 'file').trim() || 'file'; + let fileSize = existing?.file_size ?? null; + let detectedMediaType = existing?.detected_media_type ?? null; + let detectedFormat = existing?.detected_format ?? null; + + const rawDir = await getRawDir(); + const absPath = rawDir ? path.join(rawDir, normalizedRelPath) : null; + if (absPath && fs.existsSync(absPath)) { + try { + const stat = fs.statSync(absPath); + entryType = stat.isDirectory() ? 'directory' : 'file'; + fileSize = stat.isFile() ? stat.size : null; + if (stat.isFile()) { + const fileName = path.basename(normalizedRelPath); + detectedMediaType = detectMediaType(fileName); + detectedFormat = detectFormat(fileName); + } + } catch (_err) { + // Keep existing metadata values if stat/read fails. + } + } + + await db.run( + ` + INSERT INTO converter_scan_entries ( + rel_path, + entry_type, + file_size, + detected_media_type, + detected_format, + job_id, + last_seen_at + ) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(rel_path) DO UPDATE SET + entry_type = excluded.entry_type, + file_size = excluded.file_size, + detected_media_type = excluded.detected_media_type, + detected_format = excluded.detected_format, + job_id = excluded.job_id, + last_seen_at = excluded.last_seen_at + `, + [ + normalizedRelPath, + entryType, + fileSize, + detectedMediaType, + detectedFormat, + Math.trunc(normalizedJobId) + ] + ); +} + +async function clearEntryJobAssignment(relPath, expectedJobId = null) { + const normalizedRelPath = normalizeRelPath(relPath); + if (normalizedRelPath === null || normalizedRelPath === '') { + throw makeError('Ungültiger relPath für Job-Entfernung.', 400); + } + const db = await getDb(); + const normalizedExpected = Number(expectedJobId); + if (Number.isFinite(normalizedExpected) && normalizedExpected > 0) { + await db.run( + `UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ? AND job_id = ?`, + [normalizedRelPath, Math.trunc(normalizedExpected)] + ); + return; + } + await db.run( + `UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ?`, + [normalizedRelPath] + ); +} + +async function clearAssignmentsForJob(jobId) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + throw makeError('Ungültige jobId für Job-Entfernung.', 400); + } + const db = await getDb(); + await db.run( + `UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`, + [Math.trunc(normalizedJobId)] + ); +} + +async function assignEntriesToJob(relPaths, jobId) { + const normalizedPaths = Array.isArray(relPaths) ? relPaths : []; + for (const relPath of normalizedPaths) { + await setEntryJobAssignment(relPath, jobId); + } +} + +async function markEntryAsJob(relPath, jobId) { + await setEntryJobAssignment(relPath, jobId); +} + +async function getRawDir() { + const settings = await settingsService.getSettingsMap(); + return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim(); +} + +/** + * Polling-Loop starten. + */ +async function startPolling() { + const settings = await settingsService.getSettingsMap(); + _pollingEnabled = String(settings?.converter_polling_enabled || 'false').toLowerCase() === 'true'; + _pollingInterval = Math.max(30, Number(settings?.converter_polling_interval || 300)) * 1000; + + stopPolling(); + + if (!_pollingEnabled) { + logger.info('converter:polling:disabled'); + return; + } + + logger.info('converter:polling:start', { intervalMs: _pollingInterval }); + + const tick = async () => { + try { + await scan(); + } catch (error) { + logger.error('converter:polling:error', { error: error?.message }); + } + if (_pollingEnabled) { + _pollingTimer = setTimeout(tick, _pollingInterval); + } + }; + + _pollingTimer = setTimeout(tick, _pollingInterval); +} + +function stopPolling() { + if (_pollingTimer) { + clearTimeout(_pollingTimer); + _pollingTimer = null; + } +} + +async function restartPolling() { + await startPolling(); +} + +// ── Datei-Operationen (Löschen, Umbenennen, Verschieben, Ordner erstellen) ── + +/** + * Relativen Pfad normalisieren und auf Path-Traversal prüfen. + * Gibt null zurück wenn der Pfad ungültig ist. + */ +function normalizeRelPath(input) { + if (input === null || input === undefined) return ''; + const raw = String(input).replace(/\\/g, '/').trim(); + if (!raw || raw === '.') return ''; + if (raw.startsWith('/')) return null; + const normalized = path.posix.normalize(raw); + if (normalized.startsWith('..')) return null; + if (normalized === '.') return ''; + return normalized; +} + +function makeError(msg, code) { + const err = new Error(msg); + err.statusCode = code; + return err; +} + +/** + * Datei oder Ordner löschen. Aktualisiert DB-Einträge. + */ +async function deleteEntry(relPath) { + const rawDir = await getRawDir(); + if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400); + + const rel = normalizeRelPath(relPath); + if (rel === null || rel === '') throw makeError('Ungültiger oder leerer Pfad (Root kann nicht gelöscht werden).', 400); + + const absPath = path.join(rawDir, rel); + // Traversal-Schutz + if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400); + + const db = await getDb(); + const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]); + if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht gelöscht werden.', 409); + + if (!fs.existsSync(absPath)) throw makeError(`Pfad existiert nicht: ${rel}`, 404); + + fs.rmSync(absPath, { recursive: true, force: true }); + + await db.run( + `DELETE FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`, + [rel, `${rel}/%`] + ); + + return { deleted: rel }; +} + +/** + * Datei oder Ordner umbenennen. Aktualisiert DB-Einträge. + */ +async function renameEntry(relPath, newName) { + const rawDir = await getRawDir(); + if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400); + + const rel = normalizeRelPath(relPath); + if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400); + + const safeName = String(newName || '').trim(); + if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') { + throw makeError('Ungültiger Name.', 400); + } + + const parentRel = path.posix.dirname(rel); + const newRel = (parentRel === '.' || parentRel === '') ? safeName : `${parentRel}/${safeName}`; + + const absOld = path.join(rawDir, rel); + const absNew = path.join(rawDir, newRel); + + if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400); + if (!absNew.startsWith(rawDir + path.sep)) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400); + + const db = await getDb(); + const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]); + if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht umbenannt werden.', 409); + + if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404); + if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409); + + fs.renameSync(absOld, absNew); + + const rows = await db.all( + `SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`, + [rel, `${rel}/%`] + ); + for (const row of rows) { + const updatedRel = newRel + row.rel_path.slice(rel.length); + await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]); + } + + return { oldRelPath: rel, newRelPath: newRel }; +} + +/** + * Datei oder Ordner in ein anderes Verzeichnis verschieben. Aktualisiert DB-Einträge. + */ +async function moveEntry(relPath, targetParentRelPath) { + const rawDir = await getRawDir(); + if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400); + + const rel = normalizeRelPath(relPath); + if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400); + + const targetParentRel = normalizeRelPath(targetParentRelPath != null ? targetParentRelPath : ''); + if (targetParentRel === null) throw makeError('Ungültiger Zielpfad.', 400); + + const name = path.posix.basename(rel); + const newRel = targetParentRel === '' ? name : `${targetParentRel}/${name}`; + + if (rel === newRel) throw makeError('Quelle und Ziel sind identisch.', 400); + if (newRel.startsWith(`${rel}/`)) throw makeError('Kann nicht in eigenen Unterordner verschoben werden.', 400); + + const absOld = path.join(rawDir, rel); + const absNew = path.join(rawDir, newRel); + + if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400); + if (!absNew.startsWith(rawDir + path.sep) && absNew !== rawDir) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400); + + const db = await getDb(); + const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]); + if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht verschoben werden.', 409); + + if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404); + if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409); + + fs.renameSync(absOld, absNew); + + const rows = await db.all( + `SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`, + [rel, `${rel}/%`] + ); + for (const row of rows) { + const updatedRel = newRel + row.rel_path.slice(rel.length); + await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]); + } + + return { oldRelPath: rel, newRelPath: newRel, targetParentRelPath: targetParentRel }; +} + +/** + * Neuen Ordner erstellen (kein DB-Eintrag — erscheint beim nächsten Scan). + */ +async function createFolder(parentRelPath, name) { + const rawDir = await getRawDir(); + if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400); + + const parentRel = normalizeRelPath(parentRelPath != null ? parentRelPath : ''); + if (parentRel === null) throw makeError('Ungültiger übergeordneter Pfad.', 400); + + const safeName = String(name || '').trim(); + if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') { + throw makeError('Ungültiger Ordnername.', 400); + } + + const newRel = parentRel === '' ? safeName : `${parentRel}/${safeName}`; + const absPath = path.join(rawDir, newRel); + + if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400); + if (fs.existsSync(absPath)) throw makeError('Ordner existiert bereits.', 409); + + fs.mkdirSync(absPath, { recursive: true }); + + return { relPath: newRel }; +} + +// ── Reines FS-Baum-Listing (keine DB) ───────────────────────────────────── + +const TREE_MAX_DEPTH = 8; + +function buildRawTree(rawDir, relPath, depth, assignments = new Map()) { + if (depth >= TREE_MAX_DEPTH) return []; + const absDir = relPath ? path.join(rawDir, relPath) : rawDir; + let dirents; + try { + dirents = fs.readdirSync(absDir, { withFileTypes: true }); + } catch (_) { + return []; + } + + dirents.sort((a, b) => { + const ad = a.isDirectory() ? 0 : 1; + const bd = b.isDirectory() ? 0 : 1; + if (ad !== bd) return ad - bd; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + + const nodes = []; + for (const dirent of dirents) { + // Versteckte Einträge überspringen + if (dirent.name.startsWith('.')) continue; + const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name; + if (dirent.isDirectory()) { + const children = buildRawTree(rawDir, childRel, depth + 1, assignments); + const size = children.reduce((s, c) => s + (c.size || 0), 0); + nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children }); + } else if (dirent.isFile()) { + let size = 0; + try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {} + const assignment = assignments.get(childRel) || null; + nodes.push({ + name: dirent.name, + type: 'file', + path: childRel, + size, + detectedMediaType: detectMediaType(dirent.name), + detectedFormat: detectFormat(dirent.name), + jobId: assignment?.jobId || null, + jobTitle: assignment?.jobTitle || null, + jobStatus: assignment?.jobStatus || null + }); + } + } + return nodes; +} + +async function getTree() { + const rawDir = await getRawDir(); + if (!rawDir || !fs.existsSync(rawDir)) { + return { rawDir: rawDir || null, tree: null }; + } + const db = await getDb(); + const rows = await db.all(` + SELECT + e.rel_path, + e.job_id, + j.title AS job_title, + j.detected_title AS job_detected_title, + j.status AS job_status + FROM converter_scan_entries e + LEFT JOIN jobs j ON j.id = e.job_id + WHERE e.job_id IS NOT NULL + `); + const assignments = new Map(); + for (const row of rows) { + const rel = String(row?.rel_path || '').trim(); + const jobId = Number(row?.job_id); + if (!rel || !Number.isFinite(jobId) || jobId <= 0) continue; + assignments.set(rel, { + jobId: Math.trunc(jobId), + jobTitle: String(row?.job_title || row?.job_detected_title || '').trim() || null, + jobStatus: String(row?.job_status || '').trim() || null + }); + } + const children = buildRawTree(rawDir, '', 0, assignments); + const size = children.reduce((s, c) => s + (c.size || 0), 0); + return { + rawDir, + tree: { name: path.basename(rawDir) || 'raw', type: 'folder', path: '', size, children } + }; +} + +module.exports = { + scan, + getEntries, + getEntryById, + getEntryByRelPath, + markEntryAsJob, + setEntryJobAssignment, + clearEntryJobAssignment, + clearAssignmentsForJob, + assignEntriesToJob, + getRawDir, + normalizeRelPath, + getTree, + startPolling, + stopPolling, + restartPolling, + detectMediaType, + detectFormat, + deleteEntry, + renameEntry, + moveEntry, + createFolder +}; diff --git a/backend/src/services/coverArtRecoveryService.js b/backend/src/services/coverArtRecoveryService.js new file mode 100644 index 0000000..2db62cf --- /dev/null +++ b/backend/src/services/coverArtRecoveryService.js @@ -0,0 +1,386 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('COVERART_RECOVERY'); +const settingsService = require('./settingsService'); +const historyService = require('./historyService'); +const thumbnailService = require('./thumbnailService'); + +const COVERART_RECOVERY_ENABLED_KEY = 'coverart_recovery_enabled'; +const COVERART_RECOVERY_INTERVAL_HOURS_KEY = 'coverart_recovery_interval_hours'; +const DEFAULT_INTERVAL_HOURS = 6; +const MIN_INTERVAL_HOURS = 1; +const MAX_INTERVAL_HOURS = 168; +const RUNNING_JOB_STATUSES = new Set([ + 'ANALYZING', + 'RIPPING', + 'MEDIAINFO_CHECK', + 'ENCODING', + 'CD_ANALYZING', + 'CD_RIPPING', + 'CD_ENCODING' +]); + +function parseJsonSafe(raw, fallback = null) { + if (!raw) { + return fallback; + } + try { + return JSON.parse(raw); + } catch (_error) { + return fallback; + } +} + +function toBoolean(value, fallback = false) { + if (value === null || value === undefined) { + return fallback; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return fallback; + } + return ['1', 'true', 'yes', 'on'].includes(normalized); +} + +function normalizeIntervalHours(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return DEFAULT_INTERVAL_HOURS; + } + return Math.max(MIN_INTERVAL_HOURS, Math.min(MAX_INTERVAL_HOURS, Math.trunc(parsed))); +} + +function normalizeExternalUrl(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return null; + } + if (!/^https?:\/\//i.test(normalized)) { + return null; + } + return normalized; +} + +function deriveCoverArtArchiveUrl(mbId) { + const normalized = String(mbId || '').trim(); + if (!normalized) { + return null; + } + return `https://coverartarchive.org/release/${encodeURIComponent(normalized)}/front-250`; +} + +function isLikelyMusicBrainzId(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return false; + } + if (/^tt\d{6,12}$/i.test(normalized)) { + return false; + } + return /^[a-z0-9-]{8,}$/i.test(normalized); +} + +function collectCoverCandidates(row) { + const candidates = []; + const seen = new Set(); + const push = (url, source) => { + const normalized = normalizeExternalUrl(url); + if (!normalized) { + return; + } + const dedupeKey = normalized.toLowerCase(); + if (seen.has(dedupeKey)) { + return; + } + seen.add(dedupeKey); + candidates.push({ + url: normalized, + source: String(source || '').trim() || null + }); + }; + + push(row?.poster_url, 'job.poster_url'); + + const makemkvInfo = parseJsonSafe(row?.makemkv_info_json, {}); + const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + + push(selectedMetadata?.coverUrl, 'selectedMetadata.coverUrl'); + push(selectedMetadata?.poster, 'selectedMetadata.poster'); + push(selectedMetadata?.posterUrl, 'selectedMetadata.posterUrl'); + + const mbId = String( + selectedMetadata?.mbId + || selectedMetadata?.musicBrainzId + || selectedMetadata?.musicbrainzId + || selectedMetadata?.musicbrainz_id + || selectedMetadata?.music_brainz_id + || selectedMetadata?.musicbrainz + || selectedMetadata?.mbid + || row?.imdb_id + || '' + ).trim(); + if (isLikelyMusicBrainzId(mbId)) { + push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive'); + } + + return candidates; +} + +class CoverArtRecoveryService { + constructor() { + this.timer = null; + this.inFlight = null; + this.nextRunAt = null; + this.schedulerEnabled = false; + this.intervalHours = DEFAULT_INTERVAL_HOURS; + this.lastRunSummary = null; + } + + getStatus() { + return { + enabled: this.schedulerEnabled, + intervalHours: this.intervalHours, + nextRunAt: this.nextRunAt, + running: Boolean(this.inFlight), + lastRunSummary: this.lastRunSummary + }; + } + + stop() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.nextRunAt = null; + } + + async init() { + await this.refreshSchedule({ runStartupCheck: true }); + } + + async handleSettingsChanged(changedKeys = []) { + const normalizedKeys = Array.isArray(changedKeys) + ? changedKeys.map((key) => String(key || '').trim().toLowerCase()).filter(Boolean) + : []; + if ( + normalizedKeys.length > 0 + && !normalizedKeys.includes(COVERART_RECOVERY_ENABLED_KEY) + && !normalizedKeys.includes(COVERART_RECOVERY_INTERVAL_HOURS_KEY) + ) { + return this.getStatus(); + } + return this.refreshSchedule({ runStartupCheck: false }); + } + + async refreshSchedule(options = {}) { + const runStartupCheck = options?.runStartupCheck !== false; + this.stop(); + + const settings = await settingsService.getSettingsMap(); + this.schedulerEnabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true); + this.intervalHours = normalizeIntervalHours(settings?.[COVERART_RECOVERY_INTERVAL_HOURS_KEY]); + + logger.info('scheduler:refresh', { + enabled: this.schedulerEnabled, + intervalHours: this.intervalHours, + runStartupCheck + }); + + if (this.schedulerEnabled && runStartupCheck) { + this.runNow({ trigger: 'startup' }).catch((error) => { + logger.warn('scheduler:startup-run-failed', { + error: error?.message || String(error) + }); + }); + } + + if (this.schedulerEnabled) { + this.scheduleNextAutoRun(); + } + return this.getStatus(); + } + + scheduleNextAutoRun() { + this.stop(); + if (!this.schedulerEnabled) { + return; + } + const delayMs = this.intervalHours * 60 * 60 * 1000; + this.nextRunAt = new Date(Date.now() + delayMs).toISOString(); + this.timer = setTimeout(() => { + this.runNow({ trigger: 'auto' }) + .catch((error) => { + logger.warn('scheduler:auto-run-failed', { + error: error?.message || String(error) + }); + }) + .finally(() => { + this.scheduleNextAutoRun(); + }); + }, delayMs); + } + + async runNow(options = {}) { + if (this.inFlight) { + return this.inFlight; + } + let promise = null; + promise = this._runNowInternal(options) + .finally(() => { + if (this.inFlight === promise) { + this.inFlight = null; + } + }); + this.inFlight = promise; + return promise; + } + + async _runNowInternal(options = {}) { + const trigger = String(options?.trigger || 'manual').trim().toLowerCase() || 'manual'; + const force = Boolean(options?.force); + const logFailures = options?.logFailures !== false; + const startedMs = Date.now(); + const startedAt = new Date().toISOString(); + const settings = await settingsService.getSettingsMap(); + const enabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true); + + if (!enabled && !force) { + const skipped = { + trigger, + startedAt, + finishedAt: new Date().toISOString(), + durationMs: 0, + skipped: true, + reason: 'disabled' + }; + this.lastRunSummary = skipped; + return skipped; + } + + const db = await getDb(); + const rows = await db.all( + ` + SELECT + id, + title, + detected_title, + status, + poster_url, + imdb_id, + makemkv_info_json + FROM jobs + ORDER BY COALESCE(updated_at, created_at) DESC, id DESC + ` + ); + + const summary = { + trigger, + startedAt, + finishedAt: null, + durationMs: 0, + scannedJobs: Array.isArray(rows) ? rows.length : 0, + runningSkipped: 0, + alreadyLocal: 0, + noCandidate: 0, + recovered: 0, + failed: 0, + failedJobs: [] + }; + + for (const row of rows || []) { + const jobId = Number(row?.id || 0); + if (!jobId) { + continue; + } + + const status = String(row?.status || '').trim().toUpperCase(); + if (RUNNING_JOB_STATUSES.has(status)) { + summary.runningSkipped += 1; + continue; + } + + const currentPosterUrl = String(row?.poster_url || '').trim(); + if ( + currentPosterUrl + && thumbnailService.isLocalUrl(currentPosterUrl) + && thumbnailService.localThumbnailUrlExists(currentPosterUrl) + ) { + summary.alreadyLocal += 1; + continue; + } + + const candidates = collectCoverCandidates(row); + if (candidates.length === 0) { + summary.noCandidate += 1; + continue; + } + + let recovered = false; + let lastError = null; + for (const candidate of candidates) { + const result = await historyService.cacheAndPromoteExternalPoster(jobId, candidate.url, { + source: 'Coverart', + logFailures: false + }); + if (result?.ok && result?.localUrl) { + recovered = true; + summary.recovered += 1; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Coverart nachgeladen (${trigger}): ${candidate.url}${candidate?.source ? ` [${candidate.source}]` : ''}` + ); + break; + } + lastError = String(result?.error || result?.reason || 'download_failed'); + } + + if (!recovered) { + summary.failed += 1; + const failedInfo = { + jobId, + title: String(row?.title || row?.detected_title || `Job #${jobId}`), + attemptedUrls: candidates.map((item) => item.url), + error: lastError + }; + summary.failedJobs.push(failedInfo); + if (logFailures && trigger !== 'auto') { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Coverart-Download fehlgeschlagen (${trigger}). Versuchte Links: ${failedInfo.attemptedUrls.join(', ')}${lastError ? ` | Fehler: ${lastError}` : ''}` + ); + } + } + } + + const finishedAt = new Date().toISOString(); + const durationMs = Math.max(0, Date.now() - startedMs); + const result = { + ...summary, + finishedAt, + durationMs + }; + this.lastRunSummary = result; + logger.info('recovery:done', { + trigger: result.trigger, + scannedJobs: result.scannedJobs, + recovered: result.recovered, + failed: result.failed, + alreadyLocal: result.alreadyLocal, + noCandidate: result.noCandidate, + runningSkipped: result.runningSkipped, + durationMs: result.durationMs + }); + return result; + } +} + +module.exports = new CoverArtRecoveryService(); diff --git a/backend/src/services/cronService.js b/backend/src/services/cronService.js new file mode 100644 index 0000000..c871df1 --- /dev/null +++ b/backend/src/services/cronService.js @@ -0,0 +1,662 @@ +/** + * cronService.js + * Verwaltet und führt Cronjobs aus (Skripte oder Skriptketten). + * Kein externer Package nötig – eigener Cron-Expression-Parser. + */ + +const { getDb } = require('../db/database'); +const logger = require('./logger').child('CRON'); +const notificationService = require('./notificationService'); +const settingsService = require('./settingsService'); +const wsService = require('./websocketService'); +const runtimeActivityService = require('./runtimeActivityService'); +const { spawnTrackedProcess } = require('./processRunner'); +const { errorToMeta } = require('../utils/errorMeta'); + +// Maximale Zeilen pro Log-Eintrag (Output-Truncation) +const MAX_OUTPUT_CHARS = 100000; +// Maximale Log-Einträge pro Cron-Job (ältere werden gelöscht) +const MAX_LOGS_PER_JOB = 50; + +// ─── Cron-Expression-Parser ──────────────────────────────────────────────── + +// Parst ein einzelnes Cron-Feld (z.B. "* /5", "1,3,5", "1-5", "*") und gibt +// alle erlaubten Werte als Set zurück. +function parseCronField(field, min, max) { + const values = new Set(); + + for (const part of field.split(',')) { + const trimmed = part.trim(); + if (trimmed === '*') { + for (let i = min; i <= max; i++) values.add(i); + } else if (trimmed.startsWith('*/')) { + const step = parseInt(trimmed.slice(2), 10); + if (!Number.isFinite(step) || step < 1) throw new Error(`Ungültiges Step: ${trimmed}`); + for (let i = min; i <= max; i += step) values.add(i); + } else if (trimmed.includes('-')) { + const [startStr, endStr] = trimmed.split('-'); + const start = parseInt(startStr, 10); + const end = parseInt(endStr, 10); + if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Ungültiger Bereich: ${trimmed}`); + for (let i = Math.max(min, start); i <= Math.min(max, end); i++) values.add(i); + } else { + const num = parseInt(trimmed, 10); + if (!Number.isFinite(num) || num < min || num > max) throw new Error(`Ungültiger Wert: ${trimmed}`); + values.add(num); + } + } + + return values; +} + +/** + * Validiert eine Cron-Expression (5 Felder: minute hour day month weekday). + * Gibt { valid: true } oder { valid: false, error: string } zurück. + */ +function validateCronExpression(expr) { + try { + const parts = String(expr || '').trim().split(/\s+/); + if (parts.length !== 5) { + return { valid: false, error: 'Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag).' }; + } + parseCronField(parts[0], 0, 59); // minute + parseCronField(parts[1], 0, 23); // hour + parseCronField(parts[2], 1, 31); // day of month + parseCronField(parts[3], 1, 12); // month + parseCronField(parts[4], 0, 7); // weekday (0 und 7 = Sonntag) + return { valid: true }; + } catch (error) { + return { valid: false, error: error.message }; + } +} + +/** + * Berechnet den nächsten Ausführungszeitpunkt nach einem Datum. + * Gibt ein Date-Objekt zurück oder null bei Fehler. + */ +function getNextRunTime(expr, fromDate = new Date()) { + try { + const parts = String(expr || '').trim().split(/\s+/); + if (parts.length !== 5) return null; + + const minutes = parseCronField(parts[0], 0, 59); + const hours = parseCronField(parts[1], 0, 23); + const days = parseCronField(parts[2], 1, 31); + const months = parseCronField(parts[3], 1, 12); + const weekdays = parseCronField(parts[4], 0, 7); + + // Normalisiere Wochentag: 7 → 0 (beide = Sonntag) + if (weekdays.has(7)) weekdays.add(0); + + // Suche ab der nächsten Minute + const candidate = new Date(fromDate); + candidate.setSeconds(0, 0); + candidate.setMinutes(candidate.getMinutes() + 1); + + // Maximal 2 Jahre in die Zukunft suchen + const limit = new Date(fromDate); + limit.setFullYear(limit.getFullYear() + 2); + + while (candidate < limit) { + const month = candidate.getMonth() + 1; // 1-12 + const day = candidate.getDate(); + const hour = candidate.getHours(); + const minute = candidate.getMinutes(); + const weekday = candidate.getDay(); // 0 = Sonntag + + if (!months.has(month)) { + candidate.setMonth(candidate.getMonth() + 1, 1); + candidate.setHours(0, 0, 0, 0); + continue; + } + if (!days.has(day) || !weekdays.has(weekday)) { + candidate.setDate(candidate.getDate() + 1); + candidate.setHours(0, 0, 0, 0); + continue; + } + if (!hours.has(hour)) { + candidate.setHours(candidate.getHours() + 1, 0, 0, 0); + continue; + } + if (!minutes.has(minute)) { + candidate.setMinutes(candidate.getMinutes() + 1, 0, 0); + continue; + } + + return candidate; + } + + return null; + } catch (_error) { + return null; + } +} + +// ─── DB-Helpers ──────────────────────────────────────────────────────────── + +function mapJobRow(row) { + if (!row) return null; + return { + id: Number(row.id), + name: String(row.name || ''), + cronExpression: String(row.cron_expression || ''), + sourceType: String(row.source_type || ''), + sourceId: Number(row.source_id), + sourceName: row.source_name != null ? String(row.source_name) : null, + enabled: Boolean(row.enabled), + pushoverEnabled: Boolean(row.pushover_enabled), + lastRunAt: row.last_run_at || null, + lastRunStatus: row.last_run_status || null, + nextRunAt: row.next_run_at || null, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function mapLogRow(row) { + if (!row) return null; + return { + id: Number(row.id), + cronJobId: Number(row.cron_job_id), + startedAt: row.started_at, + finishedAt: row.finished_at || null, + status: String(row.status || ''), + output: row.output || null, + errorMessage: row.error_message || null + }; +} + +async function fetchJobWithSource(db, id) { + return db.get( + ` + SELECT + c.*, + CASE c.source_type + WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id) + WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id) + ELSE NULL + END AS source_name + FROM cron_jobs c + WHERE c.id = ? + LIMIT 1 + `, + [id] + ); +} + +async function fetchAllJobsWithSource(db) { + return db.all( + ` + SELECT + c.*, + CASE c.source_type + WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id) + WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id) + ELSE NULL + END AS source_name + FROM cron_jobs c + ORDER BY c.id ASC + ` + ); +} + +// ─── Ausführungslogik ────────────────────────────────────────────────────── + +async function runCronJob(job) { + const db = await getDb(); + const startedAt = new Date().toISOString(); + const cronActivityId = runtimeActivityService.startActivity('cron', { + name: job?.name || `Cron #${job?.id || '?'}`, + source: 'cron', + cronJobId: job?.id || null, + currentStep: 'Starte Cronjob' + }); + + logger.info('cron:run:start', { cronJobId: job.id, name: job.name, sourceType: job.sourceType, sourceId: job.sourceId }); + + // Log-Eintrag anlegen (status = 'running') + const insertResult = await db.run( + `INSERT INTO cron_run_logs (cron_job_id, started_at, status) VALUES (?, ?, 'running')`, + [job.id, startedAt] + ); + const logId = insertResult.lastID; + + // Job als laufend markieren + await db.run( + `UPDATE cron_jobs SET last_run_at = ?, last_run_status = 'running', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [startedAt, job.id] + ); + wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: 'running', lastRunAt: startedAt }); + + let output = ''; + let errorMessage = null; + let success = false; + + try { + if (job.sourceType === 'script') { + const scriptService = require('./scriptService'); + const script = await scriptService.getScriptById(job.sourceId); + runtimeActivityService.updateActivity(cronActivityId, { + currentStepType: 'script', + currentStep: `Skript: ${script.name}`, + currentScriptName: script.name, + scriptId: script.id + }); + const scriptActivityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'cron', + scriptId: script.id, + cronJobId: job.id, + parentActivityId: cronActivityId, + currentStep: `Cronjob: ${job.name}` + }); + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id }); + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + const processHandle = spawnTrackedProcess({ + cmd: prepared.cmd, + args: prepared.args, + context: { source: 'cron', cronJobId: job.id, scriptId: script.id }, + onStdoutLine: (line) => { + const next = stdout.length <= MAX_OUTPUT_CHARS + ? `${stdout}${line}\n` + : stdout; + stdout = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next; + stdoutTruncated = stdoutTruncated || next.length > MAX_OUTPUT_CHARS; + runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line }); + }, + onStderrLine: (line) => { + const next = stderr.length <= MAX_OUTPUT_CHARS + ? `${stderr}${line}\n` + : stderr; + stderr = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next; + stderrTruncated = stderrTruncated || next.length > MAX_OUTPUT_CHARS; + runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line }); + } + }); + let exitCode = 0; + try { + const result = await processHandle.promise; + exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0; + } catch (error) { + exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null; + if (exitCode === null) { + throw error; + } + } + + output = [stdout, stderr].filter(Boolean).join('\n'); + if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]'; + success = exitCode === 0; + if (!success) errorMessage = `Exit-Code ${exitCode}`; + runtimeActivityService.completeActivity(scriptActivityId, { + status: success ? 'success' : 'error', + success, + outcome: success ? 'success' : 'error', + exitCode, + message: success ? null : errorMessage, + output: output || null, + stdout: stdout || null, + stderr: stderr || null, + stdoutTruncated, + stderrTruncated, + errorMessage: success ? null : (errorMessage || null) + }); + } catch (error) { + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'error', + message: error?.message || 'Skriptfehler', + errorMessage: error?.message || 'Skriptfehler' + }); + throw error; + } finally { + if (prepared?.cleanup) { + await prepared.cleanup(); + } + } + } else if (job.sourceType === 'chain') { + const scriptChainService = require('./scriptChainService'); + const logLines = []; + runtimeActivityService.updateActivity(cronActivityId, { + currentStepType: 'chain', + currentStep: `Kette: ${job.sourceName || `#${job.sourceId}`}`, + currentScriptName: null, + chainId: job.sourceId + }); + const result = await scriptChainService.executeChain( + job.sourceId, + { + source: 'cron', + cronJobId: job.id, + runtimeParentActivityId: cronActivityId, + onRuntimeStep: (payload = {}) => { + const currentScriptName = payload?.stepType === 'script' + ? (payload?.scriptName || payload?.currentScriptName || null) + : null; + runtimeActivityService.updateActivity(cronActivityId, { + currentStepType: payload?.stepType || 'chain', + currentStep: payload?.currentStep || null, + currentScriptName, + scriptId: payload?.scriptId || null + }); + } + }, + { + appendLog: async (_source, line) => { + logLines.push(line); + } + } + ); + + output = logLines.join('\n'); + if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]'; + success = result && typeof result === 'object' + ? !(Boolean(result.aborted) || Number(result.failed || 0) > 0) + : Boolean(result); + if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.'; + } else { + throw new Error(`Unbekannter source_type: ${job.sourceType}`); + } + } catch (error) { + success = false; + errorMessage = error.message || String(error); + logger.error('cron:run:error', { cronJobId: job.id, error: errorToMeta(error) }); + } + + const finishedAt = new Date().toISOString(); + const status = success ? 'success' : 'error'; + const nextRunAt = getNextRunTime(job.cronExpression)?.toISOString() || null; + + // Log-Eintrag abschließen + await db.run( + `UPDATE cron_run_logs SET finished_at = ?, status = ?, output = ?, error_message = ? WHERE id = ?`, + [finishedAt, status, output || null, errorMessage, logId] + ); + + // Job-Status aktualisieren + await db.run( + `UPDATE cron_jobs SET last_run_status = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [status, nextRunAt, job.id] + ); + + // Alte Logs trimmen + await db.run( + ` + DELETE FROM cron_run_logs + WHERE cron_job_id = ? + AND id NOT IN ( + SELECT id FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ? + ) + `, + [job.id, job.id, MAX_LOGS_PER_JOB] + ); + + logger.info('cron:run:done', { cronJobId: job.id, status, durationMs: new Date(finishedAt) - new Date(startedAt) }); + runtimeActivityService.completeActivity(cronActivityId, { + status, + success, + outcome: success ? 'success' : 'error', + finishedAt, + currentStep: null, + currentScriptName: null, + message: success ? 'Cronjob abgeschlossen' : (errorMessage || 'Cronjob fehlgeschlagen'), + output: output || null, + errorMessage: success ? null : (errorMessage || null) + }); + + wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: status, lastRunAt: finishedAt, nextRunAt }); + + // Pushover-Benachrichtigung (nur wenn am Cron aktiviert UND global aktiviert) + if (job.pushoverEnabled) { + try { + const settings = await settingsService.getSettingsMap(); + const eventKey = success ? 'cron_success' : 'cron_error'; + const title = `Ripster Cron: ${job.name}`; + const message = success + ? `Cronjob "${job.name}" erfolgreich ausgeführt.` + : `Cronjob "${job.name}" fehlgeschlagen: ${errorMessage || 'Unbekannter Fehler'}`; + + await notificationService.notifyWithSettings(settings, eventKey, { title, message }); + } catch (notifyError) { + logger.warn('cron:run:notify-failed', { cronJobId: job.id, error: errorToMeta(notifyError) }); + } + } + + return { success, status, output, errorMessage, finishedAt, nextRunAt }; +} + +// ─── Scheduler ───────────────────────────────────────────────────────────── + +class CronService { + constructor() { + this._timer = null; + this._running = new Set(); // IDs aktuell laufender Jobs + } + + async init() { + logger.info('cron:scheduler:init'); + // Beim Start next_run_at für alle enabled Jobs neu berechnen + await this._recalcNextRuns(); + this._scheduleNextTick(); + } + + stop() { + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + logger.info('cron:scheduler:stopped'); + } + + _scheduleNextTick() { + // Auf den Beginn der nächsten vollen Minute warten + const now = new Date(); + const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 500; + this._timer = setTimeout(() => this._tick(), msUntilNextMinute); + } + + async _tick() { + try { + await this._checkAndRunDueJobs(); + } catch (error) { + logger.error('cron:scheduler:tick-error', { error: errorToMeta(error) }); + } + this._scheduleNextTick(); + } + + async _recalcNextRuns() { + const db = await getDb(); + const jobs = await db.all(`SELECT id, cron_expression FROM cron_jobs WHERE enabled = 1`); + for (const job of jobs) { + const nextRunAt = getNextRunTime(job.cron_expression)?.toISOString() || null; + await db.run(`UPDATE cron_jobs SET next_run_at = ? WHERE id = ?`, [nextRunAt, job.id]); + } + } + + async _checkAndRunDueJobs() { + const db = await getDb(); + const now = new Date(); + const nowIso = now.toISOString(); + + // Jobs, deren next_run_at <= jetzt ist und die nicht gerade laufen + const dueJobs = await db.all( + `SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?`, + [nowIso] + ); + + for (const jobRow of dueJobs) { + const id = Number(jobRow.id); + if (this._running.has(id)) { + logger.warn('cron:scheduler:skip-still-running', { cronJobId: id }); + continue; + } + + const job = mapJobRow(jobRow); + this._running.add(id); + + // Asynchron ausführen, damit der Scheduler nicht blockiert + runCronJob(job) + .catch((error) => { + logger.error('cron:run:unhandled-error', { cronJobId: id, error: errorToMeta(error) }); + }) + .finally(() => { + this._running.delete(id); + }); + } + } + + // ─── Public API ────────────────────────────────────────────────────────── + + async listJobs() { + const db = await getDb(); + const rows = await fetchAllJobsWithSource(db); + return rows.map(mapJobRow); + } + + async getJobById(id) { + const db = await getDb(); + const row = await fetchJobWithSource(db, id); + if (!row) { + const error = new Error(`Cronjob #${id} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + return mapJobRow(row); + } + + async createJob(payload) { + const { name, cronExpression, sourceType, sourceId, enabled = true, pushoverEnabled = true } = payload || {}; + + const trimmedName = String(name || '').trim(); + const trimmedExpr = String(cronExpression || '').trim(); + + if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 }); + if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 }); + + const validation = validateCronExpression(trimmedExpr); + if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 }); + + if (!['script', 'chain'].includes(sourceType)) { + throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 }); + } + + const normalizedSourceId = Number(sourceId); + if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) { + throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 }); + } + + const nextRunAt = getNextRunTime(trimmedExpr)?.toISOString() || null; + const db = await getDb(); + + const result = await db.run( + ` + INSERT INTO cron_jobs (name, cron_expression, source_type, source_id, enabled, pushover_enabled, next_run_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, + [trimmedName, trimmedExpr, sourceType, normalizedSourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt] + ); + + logger.info('cron:create', { cronJobId: result.lastID, name: trimmedName, cronExpression: trimmedExpr }); + return this.getJobById(result.lastID); + } + + async updateJob(id, payload) { + const db = await getDb(); + const existing = await this.getJobById(id); + + const trimmedName = Object.prototype.hasOwnProperty.call(payload, 'name') + ? String(payload.name || '').trim() + : existing.name; + const trimmedExpr = Object.prototype.hasOwnProperty.call(payload, 'cronExpression') + ? String(payload.cronExpression || '').trim() + : existing.cronExpression; + + if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 }); + if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 }); + + const validation = validateCronExpression(trimmedExpr); + if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 }); + + const sourceType = Object.prototype.hasOwnProperty.call(payload, 'sourceType') ? payload.sourceType : existing.sourceType; + const sourceId = Object.prototype.hasOwnProperty.call(payload, 'sourceId') ? Number(payload.sourceId) : existing.sourceId; + const enabled = Object.prototype.hasOwnProperty.call(payload, 'enabled') ? Boolean(payload.enabled) : existing.enabled; + const pushoverEnabled = Object.prototype.hasOwnProperty.call(payload, 'pushoverEnabled') ? Boolean(payload.pushoverEnabled) : existing.pushoverEnabled; + + if (!['script', 'chain'].includes(sourceType)) { + throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 }); + } + if (!Number.isFinite(sourceId) || sourceId <= 0) { + throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 }); + } + + const nextRunAt = enabled ? (getNextRunTime(trimmedExpr)?.toISOString() || null) : null; + + await db.run( + ` + UPDATE cron_jobs + SET name = ?, cron_expression = ?, source_type = ?, source_id = ?, + enabled = ?, pushover_enabled = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [trimmedName, trimmedExpr, sourceType, sourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt, id] + ); + + logger.info('cron:update', { cronJobId: id }); + return this.getJobById(id); + } + + async deleteJob(id) { + const db = await getDb(); + const job = await this.getJobById(id); + await db.run(`DELETE FROM cron_jobs WHERE id = ?`, [id]); + logger.info('cron:delete', { cronJobId: id }); + return job; + } + + async getJobLogs(id, limit = 20) { + await this.getJobById(id); // Existenz prüfen + const db = await getDb(); + const rows = await db.all( + `SELECT * FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?`, + [id, Math.min(Number(limit) || 20, 100)] + ); + return rows.map(mapLogRow); + } + + async triggerJobManually(id) { + const job = await this.getJobById(id); + if (this._running.has(id)) { + throw Object.assign(new Error('Cronjob läuft bereits.'), { statusCode: 409 }); + } + this._running.add(id); + logger.info('cron:manual-trigger', { cronJobId: id }); + + // Asynchron starten + runCronJob(job) + .catch((error) => { + logger.error('cron:manual-trigger:error', { cronJobId: id, error: errorToMeta(error) }); + }) + .finally(() => { + this._running.delete(id); + }); + + return { triggered: true, cronJobId: id }; + } + + validateExpression(expr) { + return validateCronExpression(expr); + } + + getNextRunTime(expr) { + const next = getNextRunTime(expr); + return next ? next.toISOString() : null; + } +} + +module.exports = new CronService(); diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js new file mode 100644 index 0000000..993f576 --- /dev/null +++ b/backend/src/services/diskDetectionService.js @@ -0,0 +1,1362 @@ +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 normalizeOpticalDevicePath(entry) { + const directPath = String(entry?.path || '').trim(); + if (directPath) { + return directPath; + } + const name = String(entry?.name || '').trim(); + return name ? `/dev/${name}` : ''; +} + +function compareOpticalDevicePaths(left, right) { + return String(left || '').localeCompare(String(right || ''), undefined, { + numeric: true, + sensitivity: 'base' + }); +} + +function buildMakeMkvIndexByDevicePath(entries = []) { + const candidates = (Array.isArray(entries) ? entries : []) + .filter((entry) => String(entry?.type || '').trim() === 'rom') + .map((entry) => normalizeOpticalDevicePath(entry)) + .filter(Boolean); + const sortedUniquePaths = Array.from(new Set(candidates)).sort(compareOpticalDevicePaths); + const map = new Map(); + for (let index = 0; index < sortedUniquePaths.length; index += 1) { + const devicePath = sortedUniquePaths[index]; + map.set(devicePath, index); + const devName = devicePath.startsWith('/dev/') ? devicePath.slice(5) : ''; + if (devName) { + map.set(devName, index); + } + } + return map; +} + +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 isIsoLikeFsType(rawFsType) { + const fstype = String(rawFsType || '').trim().toLowerCase(); + return fstype.includes('iso9660') || fstype.includes('cdfs'); +} + +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 parseTrackCount = (rawValue) => { + const normalized = String(rawValue ?? '').trim(); + if (!normalized) { + return null; + } + const parsed = Number.parseInt(normalized, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; + }; + + const hasExactFlag = (key) => flags[String(key || '').trim().toUpperCase()] === '1'; + // Only use exact media-presence keys here. Prefix matching would also catch + // drive capability flags (e.g. ID_CDROM_MEDIA_BD_R=1) and misclassify DVDs + // in BD-capable drives as Blu-ray media. + const hasBD = hasExactFlag('ID_CDROM_MEDIA_BD'); + const hasDVD = hasExactFlag('ID_CDROM_MEDIA_DVD'); + const hasCD = hasExactFlag('ID_CDROM_MEDIA_CD'); + const audioTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO); + const dataTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_DATA); + const hasAudioTracks = Number.isFinite(audioTrackCount) && audioTrackCount > 0; + const hasDataTracks = Number.isFinite(dataTrackCount) && dataTrackCount > 0; + + // Prefer audio-CD detection when udev exposes track counters. + if (hasCD && hasAudioTracks && !hasDataTracks) { + return 'cd'; + } + if (hasCD && !hasDVD && !hasBD) { + return 'cd'; + } + if (hasBD) { + return 'bluray'; + } + if (hasDVD) { + return 'dvd'; + } + if (hasCD) { + return 'cd'; + } + return null; +} + +class DiskDetectionService extends EventEmitter { + constructor() { + super(); + this.running = false; + this.timer = null; + this.lastDetected = null; + this.lastPresent = false; + this.detectedDiscs = new Map(); // devicePath → device object (multi-drive tracking) + this.deviceLocks = new Map(); + this.pollingSuspended = false; + } + + 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'); + } + + suspendPolling() { + if (!this.pollingSuspended) { + this.pollingSuspended = true; + logger.info('polling:suspended'); + } + } + + resumePolling() { + if (this.pollingSuspended) { + this.pollingSuspended = false; + logger.info('polling:resumed'); + } + } + + scheduleNext(delayMs) { + if (!this.running) { + return; + } + + this.timer = setTimeout(async () => { + let nextDelay = 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, + suspended: this.pollingSuspended + }); + if (this.pollingSuspended) { + logger.debug('poll:skip:suspended', { nextDelay }); + } else if (autoDetectionEnabled) { + const detectedList = await this.detectAllDiscs(map); + this.applyMultiDetectionResults(detectedList, { 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 detectedList = await this.detectAllDiscs(map); + const results = this.applyMultiDetectionResults(detectedList, { forceInsertEvent: true }); + + const insertedResults = results.filter((r) => r.emitted === 'discInserted'); + const removedResults = results.filter((r) => r.emitted === 'discRemoved'); + const present = detectedList.length > 0; + const firstInserted = insertedResults[0] || null; + + logger.info('rescan:done', { + driveCount: detectedList.length, + inserted: insertedResults.length, + removed: removedResults.length + }); + + // Backward-compat return: report on first inserted device (or removed if nothing inserted) + return { + present, + changed: insertedResults.length > 0 || removedResults.length > 0, + emitted: insertedResults.length > 0 + ? 'discInserted' + : (removedResults.length > 0 ? 'discRemoved' : 'none'), + device: firstInserted?.device || null, + allDetected: detectedList + }; + } catch (error) { + logger.error('rescan:error', { error: errorToMeta(error) }); + throw error; + } + } + + normalizeDevicePath(devicePath) { + return String(devicePath || '').trim(); + } + + _resolveDeviceRealPath(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized || !normalized.startsWith('/')) { + return null; + } + try { + if (fs.realpathSync && typeof fs.realpathSync.native === 'function') { + return fs.realpathSync.native(normalized); + } + return fs.realpathSync(normalized); + } catch (_error) { + return null; + } + } + + _deviceBaseName(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return ''; + } + const parts = normalized.split('/').filter(Boolean); + return String(parts[parts.length - 1] || '').trim(); + } + + _isSameDevicePath(leftPath, rightPath) { + const left = this.normalizeDevicePath(leftPath); + const right = this.normalizeDevicePath(rightPath); + if (!left || !right) { + return false; + } + if (left === right) { + return true; + } + const leftReal = this._resolveDeviceRealPath(left); + const rightReal = this._resolveDeviceRealPath(right); + if (leftReal && rightReal && leftReal === rightReal) { + return true; + } + const leftBase = this._deviceBaseName(left); + const rightBase = this._deviceBaseName(right); + if (leftBase && rightBase && leftBase === rightBase && /^sr\d+$/i.test(leftBase)) { + return true; + } + return false; + } + + _resolveLockKey(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return null; + } + return this._resolveDeviceRealPath(normalized) || normalized; + } + + lockDevice(devicePath, owner = null) { + const lockKey = this._resolveLockKey(devicePath); + if (!lockKey) { + return null; + } + + const entry = this.deviceLocks.get(lockKey) || { + count: 0, + owners: [] + }; + + entry.count += 1; + if (owner) { + entry.owners.push(owner); + } + this.deviceLocks.set(lockKey, entry); + + logger.info('lock:add', { + devicePath: lockKey, + count: entry.count, + owner + }); + + return { + devicePath: lockKey, + owner + }; + } + + unlockDevice(devicePath, owner = null) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return; + } + + const directKey = this._resolveLockKey(normalized); + const candidateKeys = Array.from(this.deviceLocks.keys()); + const lockKey = (directKey && this.deviceLocks.has(directKey)) + ? directKey + : (candidateKeys.find((key) => this._isSameDevicePath(key, normalized)) || null); + if (!lockKey) { + return; + } + + const entry = this.deviceLocks.get(lockKey); + if (!entry) { + return; + } + + entry.count = Math.max(0, entry.count - 1); + if (entry.count === 0) { + this.deviceLocks.delete(lockKey); + logger.info('lock:remove', { + devicePath: lockKey, + owner + }); + return; + } + + this.deviceLocks.set(lockKey, entry); + logger.info('lock:decrement', { + devicePath: lockKey, + count: entry.count, + owner + }); + } + + forceUnlockDevice(devicePath, options = {}) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return 0; + } + + const candidateKeys = Array.from(this.deviceLocks.keys()) + .filter((key) => this._isSameDevicePath(key, normalized)); + if (candidateKeys.length === 0) { + return 0; + } + + let removed = 0; + for (const lockKey of candidateKeys) { + const entry = this.deviceLocks.get(lockKey); + const previousCount = Math.max(0, Number(entry?.count) || 0); + removed += previousCount; + this.deviceLocks.delete(lockKey); + logger.warn('lock:force-remove', { + devicePath: lockKey, + previousCount, + reason: String(options?.reason || '').trim() || null, + deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : [] + }); + } + return removed; + } + + isDeviceLocked(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return false; + } + const directKey = this._resolveLockKey(normalized); + if (directKey && this.deviceLocks.has(directKey)) { + return true; + } + return Array.from(this.deviceLocks.keys()).some((key) => this._isSameDevicePath(key, 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(); + } + + // Returns ALL discs with media (not just the first one) + async detectAllDiscs(settingsMap) { + if (settingsMap.drive_mode === 'explicit') { + // drive_devices is a JSON array of paths; fall back to legacy drive_device + let explicitPaths = []; + try { + const parsed = JSON.parse(settingsMap.drive_devices || '[]'); + if (Array.isArray(parsed)) { + explicitPaths = parsed + .map((e) => (typeof e === 'string' ? e.trim() : String(e?.path || '').trim())) + .filter(Boolean); + } + } catch (_error) { + // malformed JSON — ignore, fall through to legacy + } + if (explicitPaths.length === 0) { + const legacy = String(settingsMap.drive_device || '').trim(); + if (legacy) { + explicitPaths = [legacy]; + } + } + const results = await Promise.all(explicitPaths.map((p) => this.detectExplicit(p))); + return results.filter(Boolean); + } + + // Auto mode: scan all ROM drives via lsblk + const autoResults = await this.detectAllAuto(); + + // Fallback: if lsblk found no ROM drives but drive_device is configured, try it directly + if (autoResults.length === 0) { + const legacy = String(settingsMap.drive_device || '').trim(); + if (legacy) { + logger.debug('detect:auto:lsblk-empty-fallback-explicit', { legacy }); + const legacyResult = await this.detectExplicit(legacy); + if (legacyResult) { + return [legacyResult]; + } + } + } + + // Supplement: any drive already tracked in detectedDiscs that was not found by auto-scan + // gets a second chance via detectExplicit (which includes the sysfs-size fallback). + // This prevents polling from falsely emitting discRemoved for drives that were manually + // rescanned but cannot be auto-detected (e.g. VM/passthrough devices invisible to lsblk). + const foundPaths = new Set(autoResults.map((d) => String(d.path || ''))); + const supplementChecks = []; + for (const [trackedPath] of this.detectedDiscs) { + if (!trackedPath.startsWith('__virtual__') && !foundPaths.has(trackedPath)) { + supplementChecks.push(this.detectExplicit(trackedPath)); + } + } + if (supplementChecks.length > 0) { + const supplementResults = await Promise.all(supplementChecks); + for (const result of supplementResults) { + if (result) { + autoResults.push(result); + } + } + } + + return autoResults; + } + + // Rescan a single specific drive and emit the appropriate event + async rescanDriveAndEmit(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return { present: false, emitted: 'none', device: null }; + } + if (this.isDeviceLocked(normalized)) { + const existing = this.detectedDiscs.get(normalized) || null; + logger.info('rescan-drive:skip-locked', { + devicePath: normalized, + activeLocks: this.getActiveLocks() + }); + return { + present: Boolean(existing), + emitted: 'none', + device: existing, + locked: true + }; + } + try { + logger.info('rescan-drive:requested', { devicePath: normalized }); + const detected = await this.detectExplicit(normalized); + const previouslyTracked = this.detectedDiscs.has(normalized); + + if (detected) { + const previous = this.detectedDiscs.get(normalized); + const changed = previous ? buildSignature(previous) !== buildSignature(detected) : false; + this.detectedDiscs.set(normalized, detected); + this.lastDetected = detected; + this.lastPresent = true; + logger.info('rescan-drive:inserted', { devicePath: normalized, changed }); + this.emit('discInserted', detected); + return { present: true, emitted: 'discInserted', device: detected }; + } + + // No disc found + if (previouslyTracked) { + const old = this.detectedDiscs.get(normalized); + this.detectedDiscs.delete(normalized); + if (this.detectedDiscs.size === 0) { + this.lastDetected = null; + this.lastPresent = false; + } + logger.info('rescan-drive:removed', { devicePath: normalized }); + this.emit('discRemoved', old || { path: normalized }); + return { present: false, emitted: 'discRemoved', device: null }; + } + + logger.info('rescan-drive:empty', { devicePath: normalized }); + return { present: false, emitted: 'none', device: null }; + } catch (error) { + logger.error('rescan-drive:error', { devicePath: normalized, error: errorToMeta(error) }); + throw error; + } + } + + // Multi-drive: tracks per-device state and emits insert/remove events for each + applyMultiDetectionResults(detectedList, { forceInsertEvent = false } = {}) { + const detected = Array.isArray(detectedList) ? detectedList : []; + const newMap = new Map(); + for (const device of detected) { + if (device?.path) { + newMap.set(device.path, device); + } + } + + const results = []; + + // Check for new or changed devices + for (const [devicePath, device] of newMap) { + const previous = this.detectedDiscs.get(devicePath); + const changed = previous ? buildSignature(previous) !== buildSignature(device) : false; + const shouldEmitInserted = forceInsertEvent || !previous || changed; + if (shouldEmitInserted) { + logger.info('disc:inserted', { device, forceInsertEvent, changed }); + this.emit('discInserted', device); + results.push({ path: devicePath, emitted: 'discInserted', device }); + } else { + results.push({ path: devicePath, emitted: 'none', device }); + } + } + + // Preserve currently tracked locked devices that are intentionally skipped + // by detectAll* while a rip lock is active. + for (const [devicePath, device] of this.detectedDiscs) { + if (newMap.has(devicePath) || !this.isDeviceLocked(devicePath)) { + continue; + } + newMap.set(devicePath, device); + results.push({ path: devicePath, emitted: 'none', device, locked: true }); + } + + // Check for removed devices + for (const [devicePath, device] of this.detectedDiscs) { + if (!newMap.has(devicePath)) { + logger.info('disc:removed', { device }); + this.emit('discRemoved', device); + results.push({ path: devicePath, emitted: 'discRemoved', device: null }); + } + } + + this.detectedDiscs = newMap; + + // Update legacy single-disc tracking for backward compat + if (newMap.size > 0) { + this.lastDetected = Array.from(newMap.values())[0]; + this.lastPresent = true; + } else { + if (this.lastPresent) { + this.lastDetected = null; + this.lastPresent = false; + } + } + + return results; + } + + 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 details = await this.getBlockDeviceInfo(); + const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details); + const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {}; + const inferredIndex = Number( + makemkvIndexByPath.get(devicePath) + ?? makemkvIndexByPath.get(match.name || '') + ?? match.makemkvIndex + ); + + // Always call checkMediaPresent to get the filesystem type (needed for accurate + // mediaProfile detection). Use lsblk SIZE as fallback presence indicator for + // drives where blkid/udevadm fail (VM/passthrough). + const mediaState = await this.checkMediaPresent(devicePath); + const hasSizeMedia = (match.sizeBytes || 0) > 0; + if (!mediaState.hasMedia && !hasSizeMedia) { + logger.debug('detect:explicit:no-media', { devicePath }); + return null; + } + if (!mediaState.hasMedia && hasSizeMedia) { + logger.debug('detect:explicit:media-by-size-fallback', { devicePath, sizeBytes: match.sizeBytes }); + } + const mediaType = String(mediaState.type || '').trim().toLowerCase() || null; + const discLabel = await this.getDiscLabel(devicePath); + // Preserve explicit audio-CD detection from checkMediaPresent even if lsblk + // reports ambiguous optical fs markers like iso9660/cdfs. + const detectedFsType = String( + mediaType === 'audio_cd' + ? mediaType + : (match.fstype || mediaType || '') + ).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: Number.isFinite(inferredIndex) && inferredIndex >= 0 + ? Math.trunc(inferredIndex) + : this.guessDiscIndex(match.name || devicePath) + }; + logger.debug('detect:explicit:success', { detected }); + return detected; + } + + async detectAuto() { + const details = await this.getBlockDeviceInfo(); + const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details); + 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 mediaType = String(mediaState.type || '').trim().toLowerCase() || null; + const discLabel = await this.getDiscLabel(path); + const detectedFsType = String( + mediaType === 'audio_cd' + ? mediaType + : (item.fstype || mediaType || '') + ).trim() || null; + + const mediaProfile = await this.inferMediaProfile(path, { + discLabel, + label: item.label, + model: item.model, + fstype: detectedFsType, + mountpoint: item.mountpoint + }); + const detectedIndex = Number( + makemkvIndexByPath.get(path) + ?? makemkvIndexByPath.get(item.name || '') + ?? item.makemkvIndex + ); + + 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: Number.isFinite(detectedIndex) && detectedIndex >= 0 + ? Math.trunc(detectedIndex) + : this.guessDiscIndex(item.name) + }; + logger.debug('detect:auto:success', { detected }); + return detected; + } + + logger.debug('detect:auto:none'); + return null; + } + + async detectAllAuto() { + const details = await this.getBlockDeviceInfo(); + const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details); + const romCandidates = details.filter((entry) => entry.type === 'rom'); + const results = []; + + 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:all-auto:skip-locked', { + path, + activeLocks: this.getActiveLocks() + }); + continue; + } + + // Always call checkMediaPresent to get the filesystem type (needed for accurate + // mediaProfile detection via inferMediaProfile). Use lsblk SIZE as a fallback + // presence indicator for drives where blkid/udevadm fail (VM/passthrough). + const mediaState = await this.checkMediaPresent(path); + const hasSizeMedia = item.sizeBytes > 0; + if (!mediaState.hasMedia && !hasSizeMedia) { + logger.debug('detect:all-auto:no-media', { path, sizeBytes: item.sizeBytes }); + continue; + } + if (!mediaState.hasMedia && hasSizeMedia) { + logger.debug('detect:all-auto:media-by-size-fallback', { path, sizeBytes: item.sizeBytes }); + } + const mediaType = String(mediaState.type || '').trim().toLowerCase() || null; + const discLabel = await this.getDiscLabel(path); + const detectedFsType = String( + mediaType === 'audio_cd' + ? mediaType + : (item.fstype || mediaType || '') + ).trim() || null; + + const mediaProfile = await this.inferMediaProfile(path, { + discLabel, + label: item.label, + model: item.model, + fstype: detectedFsType, + mountpoint: item.mountpoint + }); + const detectedIndex = Number( + makemkvIndexByPath.get(path) + ?? makemkvIndexByPath.get(item.name || '') + ?? item.makemkvIndex + ); + + 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: Number.isFinite(detectedIndex) && detectedIndex >= 0 + ? Math.trunc(detectedIndex) + : this.guessDiscIndex(item.name) + }; + logger.debug('detect:all-auto:found', { detected }); + results.push(detected); + } + + logger.debug('detect:all-auto:done', { count: results.length }); + return results; + } + + async getBlockDeviceInfo() { + try { + const { stdout } = await execFileAsync('lsblk', [ + '-J', + '-b', + '-o', + 'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL,SIZE' + ]); + 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, + sizeBytes: Number(entry.size) || 0 + })); + const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(devices); + const withMakeMkvIndex = devices.map((entry) => { + if (entry.type !== 'rom') { + return { ...entry, makemkvIndex: null }; + } + const devicePath = normalizeOpticalDevicePath(entry); + const inferredIndex = Number( + makemkvIndexByPath.get(devicePath) + ?? makemkvIndexByPath.get(entry.name || '') + ); + return { + ...entry, + makemkvIndex: Number.isFinite(inferredIndex) && inferredIndex >= 0 + ? Math.trunc(inferredIndex) + : null + }; + }); + logger.debug('lsblk:ok', { deviceCount: devices.length }); + return withMakeMkvIndex; + } catch (error) { + logger.warn('lsblk:failed', { error: errorToMeta(error) }); + return []; + } + } + + async probeAudioCdWithCdparanoia(devicePath, command = 'cdparanoia') { + const cdparanoiaCmd = String(command || '').trim() || 'cdparanoia'; + try { + const { stdout, stderr } = await execFileAsync(cdparanoiaCmd, ['-Q', '-d', devicePath], { timeout: 10000 }); + const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`); + if (tracks.length > 0) { + logger.debug('cdparanoia:audio-cd', { devicePath, cmd: cdparanoiaCmd, trackCount: tracks.length }); + return true; + } + logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath, cmd: cdparanoiaCmd }); + return true; + } catch (error) { + const stderr = String(error?.stderr || ''); + const stdout = String(error?.stdout || ''); + const tracks = parseToc(`${stderr}\n${stdout}`); + if (tracks.length > 0) { + logger.debug('cdparanoia:audio-cd-from-error-streams', { + devicePath, + cmd: cdparanoiaCmd, + trackCount: tracks.length + }); + return true; + } + logger.debug('cdparanoia:no-audio-cd', { + devicePath, + cmd: cdparanoiaCmd, + error: errorToMeta(error) + }); + return false; + } + } + + async checkMediaPresent(devicePath) { + let blkidType = null; + let blkidError = null; + try { + const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]); + blkidType = String(stdout || '').trim().toLowerCase() || null; + } catch (_error) { + blkidError = String(_error?.message || _error || 'unknown'); + // blkid failed – could mean no disc, or an audio CD (no filesystem type) + } + logger.info('check-media:blkid', { devicePath, blkidType, blkidError }); + + if (blkidType) { + return { hasMedia: true, type: blkidType }; + } + + let hasOpticalMediaHintFromUdev = false; + + // 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 inferredByUdev = inferMediaProfileFromUdevProperties(props); + const audioTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO || '').trim(), 10); + const dataTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_DATA || '').trim(), 10); + logger.info('check-media:udevadm', { + devicePath, + inferredByUdev, + audioTrackCount: Number.isFinite(audioTrackCount) ? audioTrackCount : null, + dataTrackCount: Number.isFinite(dataTrackCount) ? dataTrackCount : null + }); + if (inferredByUdev === 'cd') { + logger.debug('udevadm:audio-cd', { devicePath }); + return { hasMedia: true, type: 'audio_cd' }; + } + if (inferredByUdev === 'bluray' || inferredByUdev === 'dvd') { + logger.debug('udevadm:optical-media', { devicePath, inferredByUdev }); + // Keep this as a presence hint, but still probe cdparanoia. Some drives + // expose mixed DVD/CD flags for audio CDs and would otherwise be + // downgraded to "other" before TOC probing. + hasOpticalMediaHintFromUdev = true; + } + } 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. + const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath); + if (hasAudioCdToc) { + return { hasMedia: true, type: 'audio_cd' }; + } + + if (hasOpticalMediaHintFromUdev) { + return { hasMedia: true, type: null }; + } + + // Final fallback: check block device size via sysfs. + // In VM/passthrough environments udev metadata may be absent even though + // the kernel reports a valid disc size (visible in lsblk). A non-zero + // 512-byte block count means media is physically present. + try { + const devName = String(devicePath || '').split('/').pop(); + if (devName) { + const sizeStr = fs.readFileSync(`/sys/block/${devName}/size`, 'utf8').trim(); + const sizeBlocks = parseInt(sizeStr, 10); + if (Number.isFinite(sizeBlocks) && sizeBlocks > 0) { + logger.info('check-media:sysfs-size', { devicePath, sizeBlocks }); + return { hasMedia: true, type: null }; + } + } + } catch (_sysError) { + // sysfs not available or device not found there + } + + 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. + // Also guard: when hintFstype is empty (no filesystem info at all), the drive model + // alone is not a reliable disc-type indicator — a BD-RE drive can contain a DVD. + // In that case skip this early return and let blkid -p determine the actual disc type. + if ( + hintFstype + && byFsTypeHint + && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray') + && !(isIsoLikeFsType(hintFstype) && byFsTypeHint === 'dvd') + ) { + 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 && !(isIsoLikeFsType(type) && byBlkidFsType === 'dvd')) { + 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 && !(isIsoLikeFsType(type) && byBlkidFsTypeWithoutModel === 'dvd')) { + return byBlkidFsTypeWithoutModel; + } + } catch (error) { + logger.debug('infer-media-profile:blkid-failed', { + devicePath, + error: errorToMeta(error) + }); + } + + if (udfHintFallback) { + return udfHintFallback; + } + + const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath); + if (hasAudioCdToc) { + return 'cd'; + } + + return 'other'; + } + + guessDiscIndex(name) { + if (!name) { + return 0; + } + + const match = String(name).match(/(\d+)$/); + return match ? Number(match[1]) : 0; + } +} + +module.exports = new DiskDetectionService(); diff --git a/backend/src/services/downloadService.js b/backend/src/services/downloadService.js new file mode 100644 index 0000000..1982654 --- /dev/null +++ b/backend/src/services/downloadService.js @@ -0,0 +1,572 @@ +const fs = require('fs'); +const path = require('path'); +const { randomUUID } = require('crypto'); +const { spawnSync } = require('child_process'); +const archiver = require('archiver'); +const settingsService = require('./settingsService'); +const historyService = require('./historyService'); +const wsService = require('./websocketService'); +const logger = require('./logger').child('DOWNLOADS'); + +function safeJsonParse(raw, fallback = null) { + if (!raw) { + return fallback; + } + try { + return JSON.parse(raw); + } catch (_error) { + return fallback; + } +} + +function normalizeDownloadId(value) { + const raw = String(value || '').trim(); + return raw || null; +} + +function normalizeStatus(value) { + const raw = String(value || '').trim().toLowerCase(); + if (['queued', 'processing', 'ready', 'failed'].includes(raw)) { + return raw; + } + return 'failed'; +} + +function normalizeTarget(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'raw') { + return 'raw'; + } + if (raw === 'output') { + return 'output'; + } + return 'output'; +} + +function normalizeDateString(value) { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function normalizeNumber(value, fallback = null) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function compareCreatedDesc(a, b) { + const left = String(a?.createdAt || ''); + const right = String(b?.createdAt || ''); + return right.localeCompare(left) || String(b?.id || '').localeCompare(String(a?.id || '')); +} + +function applyOwnerToPath(targetPath, ownerSpec) { + const spec = String(ownerSpec || '').trim(); + if (!targetPath || !spec) { + return; + } + try { + const result = spawnSync('chown', [spec, targetPath], { timeout: 15000 }); + if (result.status !== 0) { + logger.warn('download:chown:failed', { + targetPath, + spec, + stderr: String(result.stderr || '').trim() || null + }); + } + } catch (error) { + logger.warn('download:chown:error', { + targetPath, + spec, + error: error?.message || String(error) + }); + } +} + +class DownloadService { + constructor() { + this.items = new Map(); + this.activeTasks = new Map(); + this.initPromise = null; + } + + async init() { + if (!this.initPromise) { + this.initPromise = this._init(); + } + return this.initPromise; + } + + async _init() { + const settings = await settingsService.getEffectiveSettingsMap(null); + const downloadDir = String(settings?.download_dir || '').trim(); + const owner = String(settings?.download_dir_owner || '').trim() || null; + await fs.promises.mkdir(downloadDir, { recursive: true }); + applyOwnerToPath(downloadDir, owner); + + let entries = []; + try { + entries = await fs.promises.readdir(downloadDir, { withFileTypes: true }); + } catch (error) { + logger.warn('download:init:readdir-failed', { + downloadDir, + error: error?.message || String(error) + }); + entries = []; + } + + const nowIso = new Date().toISOString(); + const pendingResumeIds = []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + const metaPath = path.join(downloadDir, entry.name); + const parsed = safeJsonParse(await fs.promises.readFile(metaPath, 'utf-8').catch(() => null), null); + if (!parsed || typeof parsed !== 'object') { + continue; + } + const item = this._normalizeLoadedItem(parsed, downloadDir); + if (!item) { + continue; + } + + let changed = false; + if (item.status === 'queued' || item.status === 'processing') { + const archiveExists = await this._pathExists(item.archivePath); + if (archiveExists) { + const archiveStat = await fs.promises.stat(item.archivePath).catch(() => null); + item.status = 'ready'; + item.errorMessage = null; + item.finishedAt = item.finishedAt || nowIso; + item.sizeBytes = Number.isFinite(Number(archiveStat?.size)) + ? archiveStat.size + : item.sizeBytes; + changed = true; + logger.warn('download:init:recovered-ready-archive', { + id: item.id, + archiveName: item.archiveName + }); + } else { + item.status = 'queued'; + item.startedAt = null; + item.finishedAt = null; + item.errorMessage = null; + item.sizeBytes = null; + changed = true; + pendingResumeIds.push(item.id); + await this._safeUnlink(item.partialPath); + logger.warn('download:init:requeue-interrupted-job', { + id: item.id, + archiveName: item.archiveName + }); + } + } else if (item.status === 'ready') { + const exists = await this._pathExists(item.archivePath); + if (!exists) { + item.status = 'failed'; + item.errorMessage = 'ZIP-Datei wurde nicht gefunden.'; + item.finishedAt = nowIso; + item.sizeBytes = null; + changed = true; + } + } + + this.items.set(item.id, item); + if (changed) { + await this._persistItem(item); + } + } + + if (pendingResumeIds.length > 0) { + logger.warn('download:init:resume-pending-jobs', { + count: pendingResumeIds.length + }); + setImmediate(() => { + for (const id of pendingResumeIds) { + void this._startArchiveJob(id); + } + }); + } + } + + _normalizeLoadedItem(rawItem, fallbackDir) { + const id = normalizeDownloadId(rawItem?.id); + if (!id) { + return null; + } + const downloadDir = String(rawItem?.downloadDir || fallbackDir || '').trim(); + if (!downloadDir) { + return null; + } + return { + id, + kind: String(rawItem?.kind || 'history').trim() || 'history', + jobId: normalizeNumber(rawItem?.jobId, null), + target: normalizeTarget(rawItem?.target), + label: String(rawItem?.label || (rawItem?.target === 'raw' ? 'RAW' : 'Encode')).trim() || 'Download', + displayTitle: String(rawItem?.displayTitle || '').trim() || null, + sourcePath: String(rawItem?.sourcePath || '').trim() || null, + sourceType: String(rawItem?.sourceType || '').trim() === 'file' ? 'file' : 'directory', + sourceMtimeMs: normalizeNumber(rawItem?.sourceMtimeMs, null), + sourceModifiedAt: normalizeDateString(rawItem?.sourceModifiedAt), + entryName: String(rawItem?.entryName || '').trim() || null, + archiveName: String(rawItem?.archiveName || `${id}.zip`).trim() || `${id}.zip`, + downloadDir, + archivePath: String(rawItem?.archivePath || path.join(downloadDir, `${id}.zip`)).trim(), + partialPath: String(rawItem?.partialPath || path.join(downloadDir, `${id}.partial.zip`)).trim(), + metaPath: String(rawItem?.metaPath || path.join(downloadDir, `${id}.json`)).trim(), + ownerSpec: String(rawItem?.ownerSpec || '').trim() || null, + status: normalizeStatus(rawItem?.status), + createdAt: normalizeDateString(rawItem?.createdAt) || new Date().toISOString(), + startedAt: normalizeDateString(rawItem?.startedAt), + finishedAt: normalizeDateString(rawItem?.finishedAt), + errorMessage: String(rawItem?.errorMessage || '').trim() || null, + sizeBytes: normalizeNumber(rawItem?.sizeBytes, null) + }; + } + + _serializeItem(item) { + return { + id: item.id, + kind: item.kind, + jobId: item.jobId, + target: item.target, + label: item.label, + displayTitle: item.displayTitle, + sourcePath: item.sourcePath, + sourceType: item.sourceType, + archiveName: item.archiveName, + downloadDir: item.downloadDir, + status: item.status, + createdAt: item.createdAt, + startedAt: item.startedAt, + finishedAt: item.finishedAt, + errorMessage: item.errorMessage, + sizeBytes: item.sizeBytes, + downloadUrl: item.status === 'ready' ? `/api/downloads/${encodeURIComponent(item.id)}/file` : null + }; + } + + getSummary() { + const items = Array.from(this.items.values()); + const queuedCount = items.filter((item) => item.status === 'queued').length; + const processingCount = items.filter((item) => item.status === 'processing').length; + const readyCount = items.filter((item) => item.status === 'ready').length; + const failedCount = items.filter((item) => item.status === 'failed').length; + + return { + totalCount: items.length, + queuedCount, + processingCount, + activeCount: queuedCount + processingCount, + readyCount, + failedCount + }; + } + + _broadcastUpdate(reason, item = null) { + wsService.broadcast('DOWNLOADS_UPDATED', { + reason: String(reason || 'updated').trim() || 'updated', + summary: this.getSummary(), + item: item ? this._serializeItem(item) : null + }); + } + + async listItems() { + await this.init(); + return Array.from(this.items.values()) + .sort(compareCreatedDesc) + .map((item) => this._serializeItem(item)); + } + + async getItem(id) { + await this.init(); + const normalizedId = normalizeDownloadId(id); + if (!normalizedId || !this.items.has(normalizedId)) { + const error = new Error('Download nicht gefunden.'); + error.statusCode = 404; + throw error; + } + return this.items.get(normalizedId); + } + + async enqueueHistoryJob(jobId, target, options = {}) { + await this.init(); + const descriptor = await historyService.getJobArchiveDescriptor(jobId, target, options); + const settings = await settingsService.getEffectiveSettingsMap(null); + const downloadDir = String(settings?.download_dir || '').trim(); + const ownerSpec = String(settings?.download_dir_owner || '').trim() || null; + await fs.promises.mkdir(downloadDir, { recursive: true }); + applyOwnerToPath(downloadDir, ownerSpec); + + const reusable = await this._findReusableHistoryItem(descriptor, downloadDir); + if (reusable) { + return { + item: this._serializeItem(reusable), + reused: true, + created: false + }; + } + + const id = randomUUID(); + const nowIso = new Date().toISOString(); + const item = { + id, + kind: 'history', + jobId: descriptor.jobId, + target: descriptor.target, + label: descriptor.target === 'raw' ? 'RAW' : 'Encode', + displayTitle: descriptor.displayTitle, + sourcePath: descriptor.sourcePath, + sourceType: descriptor.sourceType, + sourceMtimeMs: descriptor.sourceMtimeMs, + sourceModifiedAt: descriptor.sourceModifiedAt, + entryName: descriptor.entryName, + archiveName: descriptor.archiveName, + downloadDir, + archivePath: path.join(downloadDir, `${id}.zip`), + partialPath: path.join(downloadDir, `${id}.partial.zip`), + metaPath: path.join(downloadDir, `${id}.json`), + ownerSpec, + status: 'queued', + createdAt: nowIso, + startedAt: null, + finishedAt: null, + errorMessage: null, + sizeBytes: null + }; + + this.items.set(id, item); + await this._persistItem(item); + this._broadcastUpdate('queued', item); + + setImmediate(() => { + void this._startArchiveJob(id); + }); + + return { + item: this._serializeItem(item), + reused: false, + created: true + }; + } + + async _findReusableHistoryItem(descriptor, downloadDir) { + for (const item of this.items.values()) { + if (item.kind !== 'history') { + continue; + } + if (item.jobId !== descriptor.jobId || item.target !== descriptor.target) { + continue; + } + if (item.sourcePath !== descriptor.sourcePath || item.sourceMtimeMs !== descriptor.sourceMtimeMs) { + continue; + } + if (item.downloadDir !== downloadDir) { + continue; + } + if (!['queued', 'processing', 'ready'].includes(item.status)) { + continue; + } + if (item.status === 'ready' && !(await this._pathExists(item.archivePath))) { + item.status = 'failed'; + item.errorMessage = 'ZIP-Datei wurde nicht gefunden.'; + item.finishedAt = new Date().toISOString(); + item.sizeBytes = null; + await this._persistItem(item); + this._broadcastUpdate('failed', item); + continue; + } + return item; + } + return null; + } + + async _startArchiveJob(id) { + const item = this.items.get(id); + if (!item) { + return; + } + if (this.activeTasks.has(id)) { + return this.activeTasks.get(id); + } + + const promise = this._runArchiveJob(item) + .catch((error) => { + logger.warn('download:job:failed', { + id, + archiveName: item.archiveName, + error: error?.message || String(error) + }); + }) + .finally(() => { + this.activeTasks.delete(id); + }); + + this.activeTasks.set(id, promise); + return promise; + } + + async _runArchiveJob(item) { + item.status = 'processing'; + item.startedAt = new Date().toISOString(); + item.finishedAt = null; + item.errorMessage = null; + item.sizeBytes = null; + await this._safeUnlink(item.partialPath); + await this._persistItem(item); + this._broadcastUpdate('processing', item); + + await fs.promises.mkdir(item.downloadDir, { recursive: true }); + applyOwnerToPath(item.downloadDir, item.ownerSpec); + + await new Promise((resolve, reject) => { + let settled = false; + const output = fs.createWriteStream(item.partialPath); + const archive = archiver('zip', { zlib: { level: 9 } }); + + const finishError = (error) => { + if (settled) { + return; + } + settled = true; + output.destroy(); + reject(error); + }; + + output.on('close', () => { + if (settled) { + return; + } + settled = true; + resolve(); + }); + output.on('error', finishError); + archive.on('warning', finishError); + archive.on('error', finishError); + + archive.pipe(output); + if (item.sourceType === 'directory') { + archive.directory(item.sourcePath, item.entryName); + } else { + archive.file(item.sourcePath, { name: item.entryName }); + } + + try { + const finalizeResult = archive.finalize(); + if (finalizeResult && typeof finalizeResult.catch === 'function') { + finalizeResult.catch(finishError); + } + } catch (error) { + finishError(error); + } + }).catch(async (error) => { + await this._safeUnlink(item.partialPath); + item.status = 'failed'; + item.finishedAt = new Date().toISOString(); + item.errorMessage = error?.message || 'ZIP-Erstellung fehlgeschlagen.'; + item.sizeBytes = null; + await this._persistItem(item); + this._broadcastUpdate('failed', item); + throw error; + }); + + await fs.promises.rename(item.partialPath, item.archivePath); + applyOwnerToPath(item.archivePath, item.ownerSpec); + + const stat = await fs.promises.stat(item.archivePath); + item.status = 'ready'; + item.finishedAt = new Date().toISOString(); + item.errorMessage = null; + item.sizeBytes = stat.size; + await this._persistItem(item); + this._broadcastUpdate('ready', item); + } + + async getDownloadDescriptor(id) { + const item = await this.getItem(id); + if (item.status !== 'ready') { + const error = new Error('ZIP-Datei ist noch nicht fertig.'); + error.statusCode = 409; + throw error; + } + const exists = await this._pathExists(item.archivePath); + if (!exists) { + item.status = 'failed'; + item.finishedAt = new Date().toISOString(); + item.errorMessage = 'ZIP-Datei wurde nicht gefunden.'; + item.sizeBytes = null; + await this._persistItem(item); + this._broadcastUpdate('failed', item); + const error = new Error('ZIP-Datei wurde nicht gefunden.'); + error.statusCode = 404; + throw error; + } + return { + path: item.archivePath, + archiveName: item.archiveName + }; + } + + async deleteItem(id) { + const item = await this.getItem(id); + if (item.status === 'queued' || item.status === 'processing' || this.activeTasks.has(item.id)) { + const error = new Error('Laufende ZIP-Jobs können nicht gelöscht werden.'); + error.statusCode = 409; + throw error; + } + + await this._safeUnlink(item.archivePath); + await this._safeUnlink(item.partialPath); + await this._safeUnlink(item.metaPath); + this.items.delete(item.id); + this._broadcastUpdate('deleted', item); + return { + deleted: true, + id: item.id + }; + } + + async _persistItem(item) { + const next = { + ...item, + metaPath: item.metaPath, + archivePath: item.archivePath, + partialPath: item.partialPath + }; + const tmpMetaPath = `${item.metaPath}.tmp`; + await fs.promises.writeFile(tmpMetaPath, JSON.stringify(next, null, 2), 'utf-8'); + await fs.promises.rename(tmpMetaPath, item.metaPath); + applyOwnerToPath(item.metaPath, item.ownerSpec); + } + + async _safeUnlink(targetPath) { + if (!targetPath) { + return; + } + try { + await fs.promises.rm(targetPath, { force: true }); + } catch (_error) { + // ignore cleanup errors + } + } + + async _pathExists(targetPath) { + if (!targetPath) { + return false; + } + try { + await fs.promises.access(targetPath, fs.constants.F_OK); + return true; + } catch (_error) { + return false; + } + } +} + +module.exports = new DownloadService(); diff --git a/backend/src/services/dvdSeriesScanService.js b/backend/src/services/dvdSeriesScanService.js new file mode 100644 index 0000000..22cf0cf --- /dev/null +++ b/backend/src/services/dvdSeriesScanService.js @@ -0,0 +1,528 @@ +'use strict'; + +const TITLE_KIND = Object.freeze({ + EPISODE_CANDIDATE: 'episode_candidate', + PLAY_ALL: 'play_all', + EXTRA: 'extra', + DUPLICATE: 'duplicate', + SHORT: 'short', + UNKNOWN: 'unknown' +}); + +const DEFAULTS = Object.freeze({ + minEpisodeMinutes: 18, + maxEpisodeMinutes: 75, + minChapterCount: 3, + shortTitleMinutes: 5 +}); + +function nowIso() { + return new Date().toISOString(); +} + +function parseDurationToSeconds(rawValue) { + const text = String(rawValue || '').trim(); + const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/); + if (!match) { + return 0; + } + return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]); +} + +function roundToStep(value, step) { + const num = Number(value); + if (!Number.isFinite(num) || step <= 0) { + return 0; + } + return Math.round(num / step) * step; +} + +function median(values = []) { + const nums = (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((left, right) => left - right); + if (nums.length === 0) { + return 0; + } + const middle = Math.floor(nums.length / 2); + if (nums.length % 2 === 1) { + return nums[middle]; + } + return (nums[middle - 1] + nums[middle]) / 2; +} + +function normalizeLanguageCode(rawCode, fallbackLabel = null) { + const raw = String(rawCode || '').trim().toLowerCase(); + if (raw && raw.length === 3) { + return raw; + } + const label = String(fallbackLabel || '').trim().toLowerCase(); + if (label.startsWith('de')) return 'deu'; + if (label.startsWith('en')) return 'eng'; + if (label.startsWith('fr')) return 'fra'; + if (label.startsWith('es')) return 'spa'; + if (label.startsWith('nl')) return 'nld'; + return raw || 'und'; +} + +function normalizeSeriesLookupTitle(rawValue) { + return String(rawValue || '') + .replace(/[_./]+/g, ' ') + .replace(/\bs\d{1,2}\s*d\d{1,2}\b/gi, ' ') + .replace(/\bs(?:taffel|eason)?\s*\d{1,2}\s*(?:disc|dvd|cd|d)\s*\d{1,2}\b/gi, ' ') + .replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ') + .replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ') + .replace(/\b\d{1,2}x\b/gi, ' ') + .replace(/\b(?:complete|collection|boxset)\b/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function deriveSeriesLookupHint(inputs = []) { + const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs]) + .map((entry) => { + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + return { + value: String(entry.value || '').trim(), + source: String(entry.source || '').trim() || 'unknown' + }; + } + return { + value: String(entry || '').trim(), + source: 'unknown' + }; + }) + .filter((entry) => entry.value); + + for (const entry of normalizedInputs) { + const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim(); + if (!compactValue) { + continue; + } + + const compactSeasonDiscMatch = compactValue.match( + /(?:^|\s)s(?:taffel|eason)?\s*0?(\d{1,2})\s*(?:d|disc|dvd|cd)\s*0?(\d{1,2})(?=\s|$)/i + ); + const seasonMatch = compactSeasonDiscMatch + || compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i) + || compactValue.match(/(?:^|\s)s\s*0?(\d{1,2})(?=\s|$)/i) + || compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i); + if (!seasonMatch) { + continue; + } + + const seasonNumber = Number(seasonMatch[1] || 0); + if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) { + continue; + } + + const discMatch = compactSeasonDiscMatch + || compactValue.match(/(?:^|\s)(?:disc|dvd|cd|d)\s*0?(\d{1,2})(?=\s|$)/i); + const compactDiscToken = compactSeasonDiscMatch ? compactSeasonDiscMatch[2] : null; + const discNumber = discMatch + ? Number(compactDiscToken || discMatch[1] || 0) || null + : null; + + const baseTitle = normalizeSeriesLookupTitle(compactValue); + if (!baseTitle) { + continue; + } + + return { + query: baseTitle, + seriesTitle: baseTitle, + seasonNumber, + discNumber, + source: entry.source, + sourceLabel: entry.value, + confidence: discNumber ? 'high' : 'medium' + }; + } + + return null; +} + +function normalizeTitleRecord(title = {}) { + const durationSeconds = Number(title.durationSeconds || 0); + const chapterCount = Number(title.chapterCount || 0); + const roundedDurationBucket = roundToStep(durationSeconds, 30); + const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : []) + .map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel)) + .filter(Boolean) + .sort(); + const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : []) + .map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel)) + .filter(Boolean) + .sort(); + + return { + index: Number(title.index || 0), + durationSeconds, + durationLabel: String(title.durationLabel || '').trim() || null, + chapterCount, + chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [], + aspectRatio: String(title.aspectRatio || '').trim() || null, + audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [], + subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [], + flags: Array.isArray(title.flags) ? title.flags : [], + roundedDurationBucket, + signature: JSON.stringify({ + duration: roundedDurationBucket, + chapterCount, + audioLanguages, + subtitleLanguages + }) + }; +} + +function buildBestEpisodeCluster(titles = [], options = {}) { + const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60)); + const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60)); + const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount)); + const candidates = titles.filter((title) => + title.durationSeconds >= minEpisodeSeconds + && title.durationSeconds <= maxEpisodeSeconds + && title.chapterCount >= minChapterCount + ); + + if (candidates.length === 0) { + return { + titles: [], + medianDurationSeconds: 0, + medianChapterCount: 0 + }; + } + + let bestCluster = []; + for (const anchor of candidates) { + const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12)); + const cluster = candidates.filter((candidate) => { + const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds); + const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount); + return durationGap <= durationTolerance && chapterGap <= 2; + }); + + if (cluster.length > bestCluster.length) { + bestCluster = cluster; + continue; + } + if (cluster.length === bestCluster.length) { + const clusterMedian = median(cluster.map((item) => item.durationSeconds)); + const bestMedian = median(bestCluster.map((item) => item.durationSeconds)); + if (clusterMedian > bestMedian) { + bestCluster = cluster; + } + } + } + + return { + titles: bestCluster, + medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)), + medianChapterCount: median(bestCluster.map((item) => item.chapterCount)) + }; +} + +function classifyTitles(titles = [], cluster = {}, options = {}) { + const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60)); + const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index))); + const duplicateFirstBySignature = new Map(); + + for (const title of titles) { + if (!duplicateFirstBySignature.has(title.signature)) { + duplicateFirstBySignature.set(title.signature, title.index); + } + } + + const playAllCandidate = (() => { + if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) { + return null; + } + const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1); + return titles + .filter((title) => + !clusterTitleIds.has(Number(title.index)) + && title.durationSeconds >= minPlayAllSeconds * 0.75 + && title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6)) + ) + .sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null; + })(); + + return titles.map((title) => { + const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index; + const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds; + const isEpisodeCandidate = clusterTitleIds.has(Number(title.index)); + const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index); + const kind = isPlayAll + ? TITLE_KIND.PLAY_ALL + : isEpisodeCandidate + ? TITLE_KIND.EPISODE_CANDIDATE + : !isFirstWithSignature + ? TITLE_KIND.DUPLICATE + : isShort + ? TITLE_KIND.SHORT + : (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN); + + let confidence = 0.2; + if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8; + if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95; + if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85; + if (kind === TITLE_KIND.SHORT) confidence = 0.9; + if (kind === TITLE_KIND.EXTRA) confidence = 0.55; + + return { + ...title, + kind, + confidence, + duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE + ? duplicateFirstBySignature.get(title.signature) + : null + }; + }); +} + +function buildDiscSignature(parsedScan = {}) { + return JSON.stringify({ + discTitle: String(parsedScan.discTitle || '').trim() || null, + discSerial: String(parsedScan.discSerial || '').trim() || null, + titleCount: Number(parsedScan.titleCount || 0), + durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : []) + .map((title) => ({ + index: Number(title.index || 0), + durationBucket: roundToStep(title.durationSeconds || 0, 30), + chapterCount: Number(title.chapterCount || 0) + })) + }); +} + +function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) { + const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length; + const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length; + const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length; + const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length; + + const reasons = []; + let confidence = 'low'; + let seriesLike = false; + + if (episodeCount >= 3) { + seriesLike = true; + confidence = playAllCount > 0 ? 'high' : 'medium'; + reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`); + } else if (episodeCount === 2) { + seriesLike = true; + confidence = 'medium'; + reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.'); + } + + if (playAllCount > 0) { + reasons.push('Ein langer Play-All-Titel wurde erkannt.'); + } + if (duplicateCount > 0) { + reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`); + } + if (cluster.medianDurationSeconds > 0) { + reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`); + } + if (extrasCount > 0) { + reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`); + } + + return { + seriesLike, + confidence, + reasons + }; +} + +function parseHandBrakeScanText(rawOutput) { + const lines = String(rawOutput || '').split(/\r?\n/); + const parsed = { + source: 'handbrake_scan_text', + generatedAt: nowIso(), + discTitle: null, + discSerial: null, + titleCount: 0, + rawLineCount: lines.length, + titles: [] + }; + + let currentTitle = null; + const ensureCurrentTitle = (index) => { + const normalizedIndex = Number(index); + if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) { + return null; + } + if (currentTitle && Number(currentTitle.index) === normalizedIndex) { + return currentTitle; + } + const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex); + if (existing) { + currentTitle = existing; + return currentTitle; + } + currentTitle = { + index: normalizedIndex, + durationLabel: null, + durationSeconds: 0, + chapterCount: 0, + chapterDurationsMs: [], + audioTracks: [], + subtitleTracks: [], + aspectRatio: null, + flags: [] + }; + parsed.titles.push(currentTitle); + return currentTitle; + }; + + for (const line of lines) { + let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i); + if (match) { + parsed.discTitle = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i); + if (match) { + parsed.discSerial = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i); + if (match) { + parsed.titleCount = Number(match[1] || 0); + continue; + } + match = line.match(/scan:\s+(?:BD|Blu-?ray)\s+has\s+(\d+)\s+title/i); + if (match) { + parsed.titleCount = Number(match[1] || 0); + continue; + } + + match = line.match(/scan:\s+scanning title\s+(\d+)/i); + if (match) { + ensureCurrentTitle(match[1]); + continue; + } + match = line.match(/^\s*\+\s+title\s+(\d+):/i); + if (match) { + ensureCurrentTitle(match[1]); + continue; + } + + match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i); + if (match && currentTitle) { + currentTitle.durationLabel = match[1]; + currentTitle.durationSeconds = parseDurationToSeconds(match[1]); + continue; + } + match = line.match(/^\s*\+\s+duration:\s+(\d{1,2}:\d{2}:\d{2})/i); + if (match && currentTitle) { + currentTitle.durationLabel = match[1]; + currentTitle.durationSeconds = parseDurationToSeconds(match[1]); + continue; + } + + match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i); + if (match) { + const title = ensureCurrentTitle(match[1]); + if (title) { + title.chapterCount = Number(match[2] || 0); + } + continue; + } + match = line.match(/^\s*\+\s+chapters:\s+(\d+)/i); + if (match && currentTitle) { + currentTitle.chapterCount = Number(match[1] || 0); + continue; + } + + match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i); + if (match && currentTitle) { + currentTitle.chapterDurationsMs.push(Number(match[1] || 0)); + continue; + } + + match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i); + if (match && currentTitle) { + const track = { + languageLabel: String(match[1] || '').trim() || null, + languageCode: normalizeLanguageCode(match[2], match[1]) + }; + if (/\[VOBSUB\]/i.test(line)) { + currentTitle.subtitleTracks.push(track); + } else { + currentTitle.audioTracks.push(track); + } + continue; + } + + match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i); + if (match && currentTitle) { + currentTitle.aspectRatio = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/scan:\s+ignoring title \(too short\)/i); + if (match && currentTitle) { + currentTitle.flags.push('ignored_too_short'); + } + } + + parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0)); + if (!parsed.titleCount) { + parsed.titleCount = parsed.titles.length; + } + return parsed; +} + +function analyzeParsedScan(parsedScan = {}, options = {}) { + const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord); + const cluster = buildBestEpisodeCluster(titles, options); + const classifiedTitles = classifyTitles(titles, cluster, options); + const summary = summarizeSeriesLikelihood(classifiedTitles, cluster); + + return { + source: parsedScan.source || 'handbrake_scan_text', + generatedAt: nowIso(), + discTitle: parsedScan.discTitle || null, + discSerial: parsedScan.discSerial || null, + titleCount: Number(parsedScan.titleCount || titles.length || 0), + discSignature: buildDiscSignature({ + discTitle: parsedScan.discTitle, + discSerial: parsedScan.discSerial, + titleCount: parsedScan.titleCount, + titles + }), + summary: { + ...summary, + titleCount: classifiedTitles.length, + episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length, + playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length, + duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length, + extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length, + typicalEpisodeMinutes: cluster.medianDurationSeconds + ? Number((cluster.medianDurationSeconds / 60).toFixed(2)) + : 0 + }, + titles: classifiedTitles + }; +} + +function analyzeHandBrakeScan(rawOutput, options = {}) { + const parsed = parseHandBrakeScanText(rawOutput); + const analysis = analyzeParsedScan(parsed, options); + return { + parsed, + analysis + }; +} + +module.exports = { + TITLE_KIND, + parseHandBrakeScanText, + analyzeParsedScan, + analyzeHandBrakeScan, + deriveSeriesLookupHint +}; diff --git a/backend/src/services/hardwareMonitorService.js b/backend/src/services/hardwareMonitorService.js new file mode 100644 index 0000000..0d0d15b --- /dev/null +++ b/backend/src/services/hardwareMonitorService.js @@ -0,0 +1,1293 @@ +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 { getDb } = require('../db/database'); + +const execFileAsync = promisify(execFile); + +const DEFAULT_INTERVAL_MS = 5000; +const MIN_INTERVAL_MS = 1000; +const MAX_INTERVAL_MS = 60000; +const HISTORY_RETENTION_PREVIOUS_DAYS = 3; +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', + 'external_storage_paths' +]); + +function nowIso() { + return new Date().toISOString(); +} + +function toLocalDayKey(inputDate = new Date()) { + const date = inputDate instanceof Date ? inputDate : new Date(inputDate); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function addLocalDays(inputDate, deltaDays) { + const date = inputDate instanceof Date ? new Date(inputDate.getTime()) : new Date(inputDate); + date.setDate(date.getDate() + Number(deltaDays || 0)); + return date; +} + +function isAbortError(error) { + if (!error || typeof error !== 'object') { + return false; + } + return error.name === 'AbortError' || error.code === 'ABORT_ERR'; +} + +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 parseExternalStoragePaths(rawValue) { + const normalized = String(rawValue || '').trim(); + if (!normalized) { + return []; + } + + let parsed = []; + try { + parsed = JSON.parse(normalized); + } catch (_error) { + return []; + } + + const list = Array.isArray(parsed) ? parsed : []; + const unique = []; + const seen = new Set(); + for (const item of list) { + const pathCandidate = typeof item === 'string' + ? item + : (item && typeof item === 'object' ? item.path : ''); + const labelCandidate = item && typeof item === 'object' + ? (item.name ?? item.label ?? '') + : ''; + const pathValue = normalizePathSetting(pathCandidate); + const labelValue = normalizePathSetting(labelCandidate); + if (!pathValue || seen.has(pathValue)) { + continue; + } + seen.add(pathValue); + unique.push({ + path: pathValue, + label: labelValue || null + }); + } + return unique; +} + +function isRootPath(inputPath) { + const normalized = String(inputPath || '').trim(); + if (!normalized) { + return false; + } + const resolved = path.resolve(normalized); + return resolved === path.parse(resolved).root; +} + +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.activePollAbortController = null; + this.reloadSettingsSeq = 0; + this.lastCpuTimes = null; + this.sensorsCommandAvailable = null; + this.nvidiaSmiAvailable = null; + this.lastSnapshot = { + enabled: false, + intervalMs: DEFAULT_INTERVAL_MS, + updatedAt: null, + sample: null, + error: null + }; + this.lastHistoryCleanupDayKey = 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 requestSeq = this.reloadSettingsSeq + 1; + this.reloadSettingsSeq = requestSeq; + 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(); + } + if (requestSeq !== this.reloadSettingsSeq) { + 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(); + let storage = []; + try { + storage = await this.collectStorageMetrics(); + } catch (error) { + logger.debug('storage:collect:disabled-mode:failed', { error: errorToMeta(error) }); + } + this.lastSnapshot = { + enabled: false, + intervalMs: this.intervalMs, + updatedAt: nowIso(), + sample: { + storage + }, + 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 externalStoragePaths = parseExternalStoragePaths(sourceMap.external_storage_paths); + const monitoredPaths = []; + + const addPath = (key, label, monitoredPath, options = {}) => { + monitoredPaths.push({ + key, + label, + path: normalizePathSetting(monitoredPath), + hideWhenUnavailable: Boolean(options?.hideWhenUnavailable), + requireDedicatedMount: Boolean(options?.requireDedicatedMount) + }); + }; + + 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); + externalStoragePaths.forEach((externalEntry, index) => { + const externalPath = normalizePathSetting(externalEntry?.path); + const externalLabel = normalizePathSetting(externalEntry?.label) || `Externer Speicher ${index + 1}`; + addPath( + `external_storage_path_${index}`, + externalLabel, + externalPath, + { + hideWhenUnavailable: true, + requireDedicatedMount: true + } + ); + }); + + 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.activePollAbortController) { + try { + this.activePollAbortController.abort(); + } catch (_error) { + // ignore abort errors + } + this.activePollAbortController = 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; + const pollAbortController = new AbortController(); + this.activePollAbortController = pollAbortController; + try { + const sample = await this.collectSample(pollAbortController.signal); + try { + await this.persistHistorySample(sample); + } catch (historyError) { + logger.warn('history:write:failed', { error: errorToMeta(historyError) }); + } + this.lastSnapshot = { + enabled: true, + intervalMs: this.intervalMs, + updatedAt: nowIso(), + sample, + error: null + }; + this.broadcastUpdate(); + } catch (error) { + if (isAbortError(error)) { + logger.debug('poll:aborted'); + return; + } + 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 { + if (this.activePollAbortController === pollAbortController) { + this.activePollAbortController = null; + } + this.pollInFlight = false; + if (this.running && this.enabled) { + this.scheduleNext(this.intervalMs); + } + } + } + + buildHistoryRecord(sample = {}) { + const cpu = sample?.cpu && typeof sample.cpu === 'object' ? sample.cpu : {}; + const memory = sample?.memory && typeof sample.memory === 'object' ? sample.memory : {}; + const gpu = sample?.gpu && typeof sample.gpu === 'object' ? sample.gpu : {}; + const gpuDevices = Array.isArray(gpu.devices) ? gpu.devices : []; + const primaryGpu = gpuDevices[0] || null; + const capturedAt = new Date(); + + return { + capturedAtIso: capturedAt.toISOString(), + dayKey: toLocalDayKey(capturedAt), + cpuUsagePercent: roundNumber(cpu?.overallUsagePercent, 1), + cpuTemperatureC: roundNumber(cpu?.overallTemperatureC, 1), + ramUsagePercent: roundNumber(memory?.usagePercent, 1), + ramUsedBytes: Number.isFinite(Number(memory?.usedBytes)) ? Math.max(0, Math.round(Number(memory.usedBytes))) : null, + ramTotalBytes: Number.isFinite(Number(memory?.totalBytes)) ? Math.max(0, Math.round(Number(memory.totalBytes))) : null, + gpuUsagePercent: roundNumber(primaryGpu?.utilizationPercent, 1), + gpuTemperatureC: roundNumber(primaryGpu?.temperatureC, 1), + vramUsedBytes: Number.isFinite(Number(primaryGpu?.memoryUsedBytes)) ? Math.max(0, Math.round(Number(primaryGpu.memoryUsedBytes))) : null, + vramTotalBytes: Number.isFinite(Number(primaryGpu?.memoryTotalBytes)) ? Math.max(0, Math.round(Number(primaryGpu.memoryTotalBytes))) : null + }; + } + + async cleanupHistoryForDay(currentDayKey, referenceDate = new Date()) { + const dayKey = String(currentDayKey || '').trim(); + if (!dayKey || this.lastHistoryCleanupDayKey === dayKey) { + return; + } + + const cutoffDayKey = toLocalDayKey(addLocalDays(referenceDate, -HISTORY_RETENTION_PREVIOUS_DAYS)); + const db = await getDb(); + const result = await db.run( + ` + DELETE FROM hardware_metrics_history + WHERE day_key < ? + `, + [cutoffDayKey] + ); + this.lastHistoryCleanupDayKey = dayKey; + logger.info('history:cleanup', { + dayKey, + cutoffDayKey, + deletedRows: Number(result?.changes || 0) + }); + } + + async persistHistorySample(sample = {}) { + if (!this.enabled) { + return; + } + const record = this.buildHistoryRecord(sample); + if (!record.dayKey) { + return; + } + + await this.cleanupHistoryForDay(record.dayKey, new Date(record.capturedAtIso)); + + const db = await getDb(); + await db.run( + ` + INSERT INTO hardware_metrics_history ( + captured_at, + day_key, + cpu_usage_percent, + cpu_temperature_c, + ram_usage_percent, + ram_used_bytes, + ram_total_bytes, + gpu_usage_percent, + gpu_temperature_c, + vram_used_bytes, + vram_total_bytes + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + record.capturedAtIso, + record.dayKey, + record.cpuUsagePercent, + record.cpuTemperatureC, + record.ramUsagePercent, + record.ramUsedBytes, + record.ramTotalBytes, + record.gpuUsagePercent, + record.gpuTemperatureC, + record.vramUsedBytes, + record.vramTotalBytes + ] + ); + } + + broadcastUpdate() { + wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot()); + } + + async collectSample(signal) { + const memory = this.collectMemoryMetrics(); + const [cpu, gpu, storage] = await Promise.all([ + this.collectCpuMetrics(signal), + this.collectGpuMetrics(signal), + this.collectStorageMetrics(signal) + ]); + + 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(signal) { + const cpus = os.cpus() || []; + const currentTimes = this.getCpuTimes(); + const usage = this.calculateCpuUsage(currentTimes, this.lastCpuTimes || []); + this.lastCpuTimes = currentTimes; + + const tempMetrics = await this.collectCpuTemperatures(signal); + 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(signal) { + const sensors = await this.collectTempsViaSensors(signal); + 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(signal) { + 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, + signal + }); + 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 (isAbortError(error)) { + throw 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(signal) { + 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, + signal + } + ); + + 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) { + const fallback = this.getLastKnownGpuMetrics(); + if (fallback) { + return fallback; + } + 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) { + if (isAbortError(error)) { + throw error; + } + const commandMissing = isCommandMissingError(error); + if (commandMissing) { + this.nvidiaSmiAvailable = false; + } + logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) }); + const fallback = this.getLastKnownGpuMetrics(); + if (fallback) { + return fallback; + } + return { + source: 'nvidia-smi', + available: false, + devices: [], + message: commandMissing + ? 'nvidia-smi ist nicht verfuegbar.' + : 'GPU-Daten derzeit nicht verfuegbar.' + }; + } + } + + getLastKnownGpuMetrics() { + const previousGpu = this.lastSnapshot?.sample?.gpu; + if (!previousGpu || typeof previousGpu !== 'object') { + return null; + } + if (!Array.isArray(previousGpu.devices) || previousGpu.devices.length === 0) { + return null; + } + return { + ...previousGpu, + message: null + }; + } + + async collectStorageMetrics(signal) { + const list = []; + for (const entry of this.monitoredPaths) { + const metric = await this.collectStorageForPath(entry, signal); + if (metric && !metric.hidden) { + list.push(metric); + } + } + 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, signal) { + const key = String(entry?.key || ''); + const label = String(entry?.label || key || 'Pfad'); + const rawPath = String(entry?.path || '').trim(); + const hideWhenUnavailable = Boolean(entry?.hideWhenUnavailable); + const requireDedicatedMount = Boolean(entry?.requireDedicatedMount); + + const hiddenResult = (base = {}) => ({ + key, + label, + hidden: true, + ...base + }); + + if (!rawPath) { + if (hideWhenUnavailable) { + return hiddenResult({ + path: null, + queryPath: null, + exists: false + }); + } + 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) { + if (hideWhenUnavailable) { + return hiddenResult({ + path: resolvedPath, + queryPath: null, + exists: false + }); + } + 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, + signal + }); + const parsed = parseDfStats(stdout); + if (!parsed) { + if (hideWhenUnavailable) { + return hiddenResult({ + path: resolvedPath, + queryPath, + exists + }); + } + 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.' + }; + } + const mountPoint = parsed?.mountPoint ? path.resolve(parsed.mountPoint) : parsed.mountPoint; + const hasDedicatedMount = Boolean(mountPoint) && !isRootPath(mountPoint); + // External storage entries must only be visible when a dedicated filesystem + // mount is present. Existing directories alone are not enough. + const shouldShowExternal = hasDedicatedMount; + if (hideWhenUnavailable && requireDedicatedMount && !shouldShowExternal) { + return hiddenResult({ + path: resolvedPath, + queryPath, + exists + }); + } + + return { + key, + label, + path: resolvedPath, + queryPath, + exists, + ...parsed, + mountPoint: mountPoint || null, + note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`, + error: null + }; + } catch (error) { + if (isAbortError(error)) { + throw error; + } + if (hideWhenUnavailable) { + return hiddenResult({ + path: resolvedPath, + queryPath, + exists + }); + } + 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(); diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js new file mode 100644 index 0000000..d4644a2 --- /dev/null +++ b/backend/src/services/historyService.js @@ -0,0 +1,9181 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('HISTORY'); +const fs = require('fs'); +const path = require('path'); +const { defaultConverterRawDir } = require('../config'); +const settingsService = require('./settingsService'); +const tmdbService = require('./tmdbService'); +const cdRipService = require('./cdRipService'); +const { getJobLogDir } = require('./logPathService'); +const thumbnailService = require('./thumbnailService'); +const { getServerTimestamp } = require('../utils/serverTime'); + +function parseJsonSafe(raw, fallback = null) { + if (!raw) { + return fallback; + } + + try { + return JSON.parse(raw); + } catch (error) { + return fallback; + } +} + +const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024; +const processLogStreams = new Map(); +const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'converter', 'other']; +const RAW_INCOMPLETE_PREFIX = 'Incomplete_'; +const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_'; +const PROCESS_LOG_FILE_PATTERN = /^job-(\d+)\.process\.log$/i; +const CD_OUTPUT_AUDIO_EXTENSIONS = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg']); +const CD_RAW_TRACK_FILE_PATTERN = /^track(\d{1,3})\.cdda\.wav$/i; +const INCOMPLETE_OUTPUT_PATH_PATTERN = /(^|[\\/])incomplete_job-\d+([\\/]|$)/i; +const INCOMPLETE_MERGE_OUTPUT_PATH_PATTERN = /(^|[\\/])incomplete_merge_[^\\/]+_job_\d+([\\/]|$)/i; + +function inspectDirectory(dirPath) { + if (!dirPath) { + return { + path: null, + exists: false, + isDirectory: false, + isEmpty: null, + entryCount: null + }; + } + + try { + const stat = fs.statSync(dirPath); + if (!stat.isDirectory()) { + return { + path: dirPath, + exists: true, + isDirectory: false, + isEmpty: null, + entryCount: null + }; + } + + // Fast path: only determine whether directory is empty, avoid loading all entries. + let firstEntry = null; + let openError = null; + try { + const dir = fs.opendirSync(dirPath); + try { + firstEntry = dir.readSync(); + } finally { + dir.closeSync(); + } + } catch (error) { + openError = error; + } + if (openError) { + const entries = fs.readdirSync(dirPath); + return { + path: dirPath, + exists: true, + isDirectory: true, + isEmpty: entries.length === 0, + entryCount: entries.length + }; + } + return { + path: dirPath, + exists: true, + isDirectory: true, + isEmpty: !firstEntry, + entryCount: firstEntry ? null : 0 + }; + } catch (error) { + return { + path: dirPath, + exists: false, + isDirectory: false, + isEmpty: null, + entryCount: null + }; + } +} + +function inspectOutputFile(filePath) { + if (!filePath) { + return { + path: null, + exists: false, + isFile: false, + sizeBytes: null + }; + } + + try { + const stat = fs.statSync(filePath); + return { + path: filePath, + exists: true, + isFile: stat.isFile(), + sizeBytes: stat.size + }; + } catch (error) { + return { + path: filePath, + exists: false, + isFile: false, + sizeBytes: null + }; + } +} + +function isIncompleteRawStoragePath(rawPath) { + const normalized = String(rawPath || '').trim(); + if (!normalized) { + return false; + } + const baseName = path.basename(normalized); + return /^incomplete_/i.test(baseName); +} + +function isIncompleteOutputStoragePath(outputPath) { + const normalized = String(outputPath || '').trim(); + if (!normalized) { + return false; + } + return INCOMPLETE_OUTPUT_PATH_PATTERN.test(normalized) + || INCOMPLETE_MERGE_OUTPUT_PATH_PATTERN.test(normalized); +} + +function parseBooleanValue(value, fallback = false) { + if (value === null || value === undefined) { + return fallback; + } + if (typeof value === 'boolean') { + return value; + } + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return fallback; + } + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + return fallback; +} + +function resolveNfoPathFromOutputPath(outputPath) { + const normalizedOutputPath = String(outputPath || '').trim(); + if (!normalizedOutputPath) { + return null; + } + const parsed = path.parse(normalizedOutputPath); + const extension = String(parsed.ext || '').trim(); + const baseName = String(parsed.name || '').trim(); + if (!extension || !baseName) { + return null; + } + return path.join(parsed.dir, `${baseName}.nfo`); +} + +function inspectNfoFileStatus(nfoPath, options = {}) { + const includeFsChecks = options?.includeFsChecks !== false; + if (!nfoPath) { + return { + path: null, + exists: false, + isFile: false, + sizeBytes: null + }; + } + if (!includeFsChecks) { + return { + path: nfoPath, + exists: null, + isFile: null, + sizeBytes: null + }; + } + try { + const stat = fs.statSync(nfoPath); + return { + path: nfoPath, + exists: true, + isFile: stat.isFile(), + sizeBytes: stat.size + }; + } catch (_error) { + return { + path: nfoPath, + exists: false, + isFile: false, + sizeBytes: null + }; + } +} + +function escapeXml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function resolveNfoMetadataFromJob(job = {}) { + const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {}); + const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo); + const encodePlan = parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {}); + const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object' + ? encodePlan.metadata + : {}; + + const title = String( + job?.title + || selectedMetadata?.title + || selectedMetadata?.seriesTitle + || planMetadata?.title + || job?.detected_title + || '' + ).trim(); + const rawYear = Number( + job?.year + ?? selectedMetadata?.year + ?? planMetadata?.year + ?? null + ); + const year = Number.isFinite(rawYear) && rawYear > 0 + ? Math.trunc(rawYear) + : null; + const imdbId = String( + job?.imdb_id + || selectedMetadata?.imdbId + || planMetadata?.imdbId + || '' + ).trim(); + + return { + title, + year, + imdbId + }; +} + +function buildNfoXml(metadata = {}) { + const title = escapeXml(metadata?.title || ''); + const year = metadata?.year !== null && metadata?.year !== undefined + ? escapeXml(String(metadata.year)) + : ''; + const imdbId = escapeXml(metadata?.imdbId || ''); + return [ + '', + ` ${title}`, + ` ${year}`, + ` ${imdbId}`, + '', + '' + ].join('\n'); +} + +function resolveConverterMediaTypeForNfo(job = {}, encodePlan = null) { + const plan = encodePlan && typeof encodePlan === 'object' + ? encodePlan + : parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {}); + const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {}); + const miInfo = parseInfoFromValue(job?.mediainfoInfo ?? job?.mediainfo_info_json, {}); + const candidates = [ + plan?.converterMediaType, + job?.converterMediaType, + mkInfo?.converterMediaType, + miInfo?.converterMediaType + ]; + for (const candidate of candidates) { + const raw = String(candidate || '').trim().toLowerCase(); + if (raw === 'audio' || raw === 'video' || raw === 'iso') { + return raw; + } + } + return null; +} + +function resolveNfoSupportForJob(job = {}, settings = {}, options = {}) { + const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {}); + const miInfo = parseInfoFromValue(job?.mediainfoInfo ?? job?.mediainfo_info_json, {}); + const plan = parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {}); + const hbInfo = parseInfoFromValue(job?.handbrakeInfo ?? job?.handbrake_info_json, {}); + const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo); + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const mediaType = normalizeMediaTypeValue(options?.mediaType) + || normalizeMediaTypeValue(job?.mediaType) + || normalizeMediaTypeValue(job?.media_type) + || inferMediaType(job, mkInfo, miInfo, plan, hbInfo); + const normalizedJobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const hasSeriesJobKind = normalizedJobKind === 'dvd_series_child' || normalizedJobKind === 'dvd_series_container'; + const hasSeriesMetadata = hasSeriesMetadataSignals(selectedMetadata, analyzeContext); + const hasSeriesRawPathHint = isLikelySeriesRawPath(job?.raw_path, settings || {}); + const seriesBatchMode = String(hbInfo?.mode || '').trim().toLowerCase() === 'series_batch' + || Boolean(plan?.seriesBatchChild) + || Boolean(plan?.seriesBatchVirtualEpisode); + const isSeriesMedia = (mediaType === 'dvd' || mediaType === 'bluray') && ( + hasSeriesJobKind + || hasSeriesMetadata + || hasSeriesRawPathHint + || seriesBatchMode + ); + + const outputPath = String(options?.outputPath || '').trim(); + const outputExt = String(path.extname(outputPath || '') || '').trim().toLowerCase(); + const audioOutputExtensions = new Set([ + '.flac', '.wav', '.mp3', '.opus', '.ogg', '.m4a', '.m4b', '.aac', '.alac', '.aif', '.aiff', '.wma' + ]); + const converterMediaType = resolveConverterMediaTypeForNfo(job, plan); + + let supported = false; + if (mediaType === 'bluray' || mediaType === 'dvd') { + supported = !isSeriesMedia; + } else if (mediaType === 'converter') { + if (converterMediaType === 'audio') { + supported = false; + } else if (converterMediaType === 'video' || converterMediaType === 'iso') { + supported = true; + } else { + supported = !audioOutputExtensions.has(outputExt); + } + } + + return { + supported, + mediaType: mediaType || null, + converterMediaType, + isSeriesMedia + }; +} + +function buildNfoStatusForJob({ + job = {}, + outputPath = null, + outputStatus = null, + includeFsChecks = true, + settings = {}, + mediaType = null +} = {}) { + const normalizedOutputPath = String(outputPath || '').trim() || null; + const nfoPath = resolveNfoPathFromOutputPath(normalizedOutputPath); + const nfoStatus = inspectNfoFileStatus(nfoPath, { includeFsChecks }); + const nfoSettingEnabled = parseBooleanValue( + settings?.generate_nfo_after_encode, + false + ); + const supportInfo = resolveNfoSupportForJob(job, settings, { + mediaType, + outputPath: normalizedOutputPath + }); + const outputExists = Boolean(outputStatus?.exists); + const outputIsFile = Boolean(outputStatus?.isFile); + const canGenerateManual = Boolean(supportInfo?.supported) + && !nfoSettingEnabled + && Boolean(nfoPath) + && outputExists + && outputIsFile + && !Boolean(nfoStatus?.exists); + + let reason = null; + if (!supportInfo?.supported) { + reason = 'unsupported_media'; + } else if (nfoSettingEnabled) { + reason = 'setting_enabled'; + } else if (!nfoPath) { + reason = 'output_not_file'; + } else if (!outputExists || !outputIsFile) { + reason = 'output_missing'; + } else if (Boolean(nfoStatus?.exists)) { + reason = 'nfo_exists'; + } else { + reason = 'eligible'; + } + + return { + settingEnabled: nfoSettingEnabled, + supported: Boolean(supportInfo?.supported), + mediaType: supportInfo?.mediaType || null, + converterMediaType: supportInfo?.converterMediaType || null, + isSeriesMedia: Boolean(supportInfo?.isSeriesMedia), + outputPath: normalizedOutputPath, + nfoPath, + outputExists: includeFsChecks ? outputExists : null, + outputIsFile: includeFsChecks ? outputIsFile : null, + nfoExists: includeFsChecks ? Boolean(nfoStatus?.exists) : null, + canGenerateManual, + reason + }; +} + +function parseInfoFromValue(value, fallback = null) { + if (!value) { + return fallback; + } + if (typeof value === 'object') { + return value; + } + return parseJsonSafe(value, fallback); +} + +function isOrphanRawImportMakemkvInfo(makemkvInfo = null) { + const info = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; + const source = String( + info?.source + || info?.importRecovery?.source + || '' + ).trim().toLowerCase(); + return source === 'orphan_raw_import'; +} + +function hasBlurayStructure(rawPath) { + const basePath = String(rawPath || '').trim(); + if (!basePath) { + return false; + } + + const bdmvPath = path.join(basePath, 'BDMV'); + const streamPath = path.join(bdmvPath, 'STREAM'); + + try { + if (fs.existsSync(streamPath)) { + const streamStat = fs.statSync(streamPath); + if (streamStat.isDirectory()) { + return true; + } + } + } catch (_error) { + // ignore fs errors and continue with fallback checks + } + + try { + if (fs.existsSync(bdmvPath)) { + const bdmvStat = fs.statSync(bdmvPath); + if (bdmvStat.isDirectory()) { + return true; + } + } + } catch (_error) { + // ignore fs errors + } + + return false; +} + +function hasCdStructure(rawPath) { + const basePath = String(rawPath || '').trim(); + if (!basePath) { + return false; + } + + try { + if (!fs.existsSync(basePath)) { + return false; + } + const stat = fs.statSync(basePath); + if (!stat.isDirectory()) { + return false; + } + const entries = fs.readdirSync(basePath); + const audioExtensions = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg', '.aiff', '.aif']); + return entries.some((entry) => audioExtensions.has(path.extname(entry).toLowerCase())); + } catch (_error) { + return false; + } +} + +function hasAudiobookStructure(rawPath) { + const basePath = String(rawPath || '').trim(); + if (!basePath) { + return false; + } + + try { + if (!fs.existsSync(basePath)) { + return false; + } + const stat = fs.statSync(basePath); + if (stat.isFile()) { + return path.extname(basePath).toLowerCase() === '.aax'; + } + if (!stat.isDirectory()) { + return false; + } + const entries = fs.readdirSync(basePath); + return entries.some((entry) => path.extname(entry).toLowerCase() === '.aax'); + } catch (_error) { + return false; + } +} + +function hasNestedMediaStructure(rawPath, detector, maxDepth = 2) { + const startPath = String(rawPath || '').trim(); + if (!startPath || typeof detector !== 'function') { + return false; + } + + const normalizedMaxDepth = Number.isFinite(Number(maxDepth)) + ? Math.max(0, Math.trunc(Number(maxDepth))) + : 0; + const queue = [{ currentPath: startPath, depth: 0 }]; + const visited = new Set(); + + while (queue.length > 0) { + const next = queue.shift(); + const currentPath = String(next?.currentPath || '').trim(); + const depth = Number(next?.depth || 0); + if (!currentPath) { + continue; + } + const normalizedPath = normalizeComparablePath(currentPath); + if (!normalizedPath || visited.has(normalizedPath)) { + continue; + } + visited.add(normalizedPath); + + if (detector(currentPath)) { + return true; + } + if (depth >= normalizedMaxDepth) { + continue; + } + + try { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry?.isDirectory?.() || isHiddenDirectoryName(entry.name)) { + continue; + } + queue.push({ + currentPath: path.join(currentPath, entry.name), + depth: depth + 1 + }); + } + } catch (_error) { + // ignore fs errors while traversing nested structures + } + } + + return false; +} + +function detectOrphanMediaType(rawPath, options = {}) { + if (hasBlurayStructure(rawPath)) { + return 'bluray'; + } + if (hasDvdStructure(rawPath)) { + return 'dvd'; + } + // Some RAW folders contain one or two nested disc directories (e.g. ".../Rip_Complete_XYZ/.../VIDEO_TS"). + // Inspect nested folders as fallback so these imports are not classified as "other". + if (hasNestedMediaStructure(rawPath, hasBlurayStructure, 2)) { + return 'bluray'; + } + if (hasNestedMediaStructure(rawPath, hasDvdStructure, 2)) { + return 'dvd'; + } + if (hasCdStructure(rawPath)) { + return 'cd'; + } + if (hasAudiobookStructure(rawPath)) { + return 'audiobook'; + } + if (options?.seriesRawPathHint) { + return 'dvd'; + } + return 'other'; +} + +function hasDvdStructure(rawPath) { + const basePath = String(rawPath || '').trim(); + if (!basePath) { + return false; + } + + const videoTsPath = path.join(basePath, 'VIDEO_TS'); + try { + if (fs.existsSync(videoTsPath)) { + const stat = fs.statSync(videoTsPath); + if (stat.isDirectory()) { + return true; + } + } + } catch (_error) { + // ignore fs errors + } + + try { + if (fs.existsSync(basePath)) { + const stat = fs.statSync(basePath); + if (stat.isDirectory()) { + const entries = fs.readdirSync(basePath); + if (entries.some((entry) => /^vts_\d{2}_\d\.(ifo|vob|bup)$/i.test(entry) || /^video_ts\.(ifo|vob|bup)$/i.test(entry))) { + return true; + } + } else if (stat.isFile()) { + return /(^|\/)video_ts\/.+\.(ifo|vob|bup)$/i.test(basePath) || /\.(ifo|vob|bup)$/i.test(basePath); + } + } + } catch (_error) { + // ignore fs errors and fallback to path checks + } + + if (/(^|\/)video_ts(\/|$)/i.test(basePath)) { + return true; + } + + return false; +} + +function normalizeMediaTypeValue(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if ( + raw === 'bluray' + || raw === 'blu-ray' + || raw === 'blu_ray' + || raw === 'bd' + || raw === 'bdmv' + || raw === 'bdrom' + || raw === 'bd-rom' + || raw === 'bd-r' + || raw === 'bd-re' + ) { + return 'bluray'; + } + if ( + raw === 'dvd' + || raw === 'dvdvideo' + || raw === 'dvd-video' + || raw === 'dvdrom' + || raw === 'dvd-rom' + || raw === 'video_ts' + || raw === 'iso9660' + ) { + return 'dvd'; + } + if (raw === 'cd' || raw === 'audio_cd') { + return 'cd'; + } + if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') { + return 'audiobook'; + } + return null; +} + +function normalizeJobKindValue(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if ( + raw === 'audiobook' + || raw === 'cd' + || raw === 'dvd' + || raw === 'bluray' + || raw === 'dvd_series_container' + || raw === 'dvd_series_child' + || raw === 'multipart_movie_container' + || raw === 'multipart_movie_child' + || raw === 'multipart_movie_merge' + ) { + return raw; + } + if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') { + return raw; + } + return null; +} + +function inferMediaTypeFromJobKind(value) { + const normalized = normalizeJobKindValue(value); + if (normalized === 'converter_audio' || normalized === 'converter_video' || normalized === 'converter_iso') { + return 'converter'; + } + if (normalized === 'audiobook' || normalized === 'cd' || normalized === 'dvd' || normalized === 'bluray') { + return normalized; + } + const legacy = String(value || '').trim().toLowerCase(); + if (legacy === 'converter') { + return 'converter'; + } + return null; +} + +function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeInfo = null) { + const mkInfo = parseInfoFromValue(makemkvInfo, null); + const miInfo = parseInfoFromValue(mediainfoInfo, null); + const plan = parseInfoFromValue(encodePlan, null); + const hbInfo = parseInfoFromValue(handbrakeInfo, null); + const rawPath = String(job?.raw_path || '').trim(); + const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim(); + const profileHint = normalizeMediaTypeValue( + plan?.mediaProfile + || mkInfo?.analyzeContext?.mediaProfile + || mkInfo?.mediaProfile + || miInfo?.mediaProfile + || job?.media_type + || job?.mediaType + ); + + if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd' || profileHint === 'audiobook') { + return profileHint; + } + + const profileFromJobKind = inferMediaTypeFromJobKind( + job?.job_kind + || plan?.jobKind + || mkInfo?.jobKind + || mkInfo?.analyzeContext?.jobKind + || miInfo?.jobKind + || hbInfo?.jobKind + ); + if (profileFromJobKind) { + return profileFromJobKind; + } + + // Converter jobs: detected via media_type field (plan.mediaProfile = 'converter') + const rawMediaType = normalizeMediaTypeValue(job?.media_type); + const rawMediaTypeLegacy = String(job?.media_type || '').trim().toLowerCase(); + const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase(); + const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase(); + const hasConverterPathHint = (value) => { + const normalized = String(value || '') + .trim() + .replace(/\\/g, '/') + .toLowerCase(); + if (!normalized) { + return false; + } + return /(^|\/)converter(\/|$)/.test(normalized); + }; + if ( + rawPlanProfile === 'converter' + || rawMediaType === 'converter' + || rawMediaTypeLegacy === 'converter' + || converterMediaTypeHint === 'video' + || converterMediaTypeHint === 'audio' + || converterMediaTypeHint === 'iso' + || hasConverterPathHint(rawPath) + || hasConverterPathHint(encodeInputPath) + || hasConverterPathHint(plan?.inputPath) + ) { + return 'converter'; + } + + const statusCandidates = [ + job?.status, + job?.last_state, + mkInfo?.lastState + ]; + if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) { + return 'cd'; + } + + const planFormat = String(plan?.format || '').trim().toLowerCase(); + const hasCdTracksInPlan = Array.isArray(plan?.selectedTracks) && plan.selectedTracks.length > 0; + if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) { + return 'cd'; + } + if (String(hbInfo?.mode || '').trim().toLowerCase() === 'cd_rip') { + return 'cd'; + } + if (Array.isArray(mkInfo?.tracks) && mkInfo.tracks.length > 0) { + return 'cd'; + } + if (hasAudiobookStructure(rawPath) || hasAudiobookStructure(encodeInputPath)) { + return 'audiobook'; + } + if (['audiobook_encode', 'audiobook_encode_split'].includes(String(hbInfo?.mode || '').trim().toLowerCase())) { + return 'audiobook'; + } + if (String(plan?.mode || '').trim().toLowerCase() === 'audiobook') { + return 'audiobook'; + } + + if (hasBlurayStructure(rawPath)) { + return 'bluray'; + } + if (hasDvdStructure(rawPath)) { + return 'dvd'; + } + + const mkSource = String(mkInfo?.source || '').trim().toLowerCase(); + const mkRipMode = String(mkInfo?.ripMode || mkInfo?.rip_mode || '').trim().toLowerCase(); + if (Boolean(mkInfo?.analyzeContext?.playlistAnalysis)) { + return 'bluray'; + } + if (mkRipMode === 'backup' || mkSource.includes('backup') || mkSource.includes('raw_backup')) { + if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) { + return 'dvd'; + } + if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) { + return 'bluray'; + } + } + + const planMode = String(plan?.mode || '').trim().toLowerCase(); + if (planMode === 'pre_rip' || Boolean(plan?.preRip)) { + return 'bluray'; + } + + const mediainfoSource = String(miInfo?.source || '').trim().toLowerCase(); + if (Number(miInfo?.handbrakeTitleId) > 0) { + return 'bluray'; + } + if (mediainfoSource.includes('raw_backup')) { + if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) { + return 'dvd'; + } + if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) { + return 'bluray'; + } + } + + if ( + /(^|\/)bdmv(\/|$)/i.test(rawPath) + || /(^|\/)bdmv(\/|$)/i.test(encodeInputPath) + || /\.m2ts(\.|$)/i.test(encodeInputPath) + ) { + return 'bluray'; + } + if ( + /(^|\/)video_ts(\/|$)/i.test(rawPath) + || /(^|\/)video_ts(\/|$)/i.test(encodeInputPath) + || /\.(ifo|vob|bup)(\.|$)/i.test(encodeInputPath) + ) { + return 'dvd'; + } + // Fallback for jobs without complete metadata/structure hints (e.g. multipart history rows). + // Prefer explicit profile folder segments when present. + const normalizedRawPathForHint = rawPath.replace(/\\/g, '/').toLowerCase(); + const normalizedEncodeInputPathForHint = encodeInputPath.replace(/\\/g, '/').toLowerCase(); + if ( + /(^|\/)raw\/bluray(\/|$)/.test(normalizedRawPathForHint) + || /(^|\/)raw\/bluray(\/|$)/.test(normalizedEncodeInputPathForHint) + ) { + return 'bluray'; + } + if ( + /(^|\/)raw\/dvd(\/|$)/.test(normalizedRawPathForHint) + || /(^|\/)raw\/dvd(\/|$)/.test(normalizedEncodeInputPathForHint) + ) { + return 'dvd'; + } + + return profileHint || 'other'; +} + +function toProcessLogPath(jobId) { + const normalizedId = Number(jobId); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + return null; + } + return path.join(getJobLogDir(), `job-${Math.trunc(normalizedId)}.process.log`); +} + +function buildArchivedProcessLogPath(sourceJobId, options = {}) { + const normalizedId = Number(sourceJobId); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + return null; + } + const targetJobId = Number(options?.replacementJobId); + const safeReason = String(options?.reason || 'retired') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'retired'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const archiveDir = path.join(getJobLogDir(), 'archived'); + const targetSuffix = Number.isFinite(targetJobId) && targetJobId > 0 + ? `.to-${Math.trunc(targetJobId)}` + : ''; + return { + archiveDir, + archivePath: path.join( + archiveDir, + `job-${Math.trunc(normalizedId)}.process.${safeReason}${targetSuffix}.${timestamp}.log` + ) + }; +} + +function hasProcessLogFile(jobId) { + const filePath = toProcessLogPath(jobId); + return Boolean(filePath && fs.existsSync(filePath)); +} + +function toProcessLogStreamKey(jobId) { + const normalizedId = Number(jobId); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + return null; + } + return String(Math.trunc(normalizedId)); +} + +function resolveEffectiveRawPath(storedPath, rawDir, extraDirs = []) { + const stored = String(storedPath || '').trim(); + if (!stored) return stored; + const baseFolderName = path.basename(stored); + if (!baseFolderName) return stored; + const folderNames = [baseFolderName]; + const stripped = stripRawFolderStatePrefix(baseFolderName); + if (stripped) { + folderNames.push(applyRawFolderPrefix(stripped, RAW_RIP_COMPLETE_PREFIX)); + folderNames.push(applyRawFolderPrefix(stripped, RAW_INCOMPLETE_PREFIX)); + } + + const candidates = []; + const seen = new Set(); + const pushCandidate = (candidatePath) => { + const normalized = String(candidatePath || '').trim(); + if (!normalized) { + return; + } + const comparable = normalizeComparablePath(normalized); + if (!comparable || seen.has(comparable)) { + return; + } + seen.add(comparable); + candidates.push(normalized); + }; + + pushCandidate(stored); + const storedParent = path.dirname(stored); + for (const name of folderNames) { + if (!name) continue; + if (storedParent && storedParent !== '.') { + pushCandidate(path.join(storedParent, name)); + } + if (rawDir) { + pushCandidate(path.join(String(rawDir).trim(), name)); + } + for (const extraDir of Array.isArray(extraDirs) ? extraDirs : []) { + pushCandidate(path.join(String(extraDir || '').trim(), name)); + } + } + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + return candidate; + } + } catch (_error) { + // ignore fs errors and continue with fallbacks + } + } + + return rawDir ? path.join(String(rawDir).trim(), baseFolderName) : stored; +} + +function resolveEffectiveOutputPath(storedPath, movieDir) { + const stored = String(storedPath || '').trim(); + if (!stored || !movieDir) return stored; + // output_path structure: {movie_dir}/{folderName}/{fileName} + const fileName = path.basename(stored); + const folderName = path.basename(path.dirname(stored)); + if (!fileName || !folderName || folderName === '.') return stored; + return path.join(String(movieDir).trim(), folderName, fileName); +} + +function getConfiguredMediaPathList(settings = {}, baseKey) { + const source = settings && typeof settings === 'object' ? settings : {}; + const candidates = [source[baseKey], ...PROFILE_PATH_SUFFIXES.map((suffix) => source[`${baseKey}_${suffix}`])]; + const unique = []; + const seen = new Set(); + + for (const candidate of candidates) { + const rawPath = String(candidate || '').trim(); + if (!rawPath) { + continue; + } + const normalized = normalizeComparablePath(rawPath); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + unique.push(normalized); + } + + return unique; +} + +function getSeriesRawPathCandidates(settings = {}) { + const source = settings && typeof settings === 'object' ? settings : {}; + const unique = []; + const seen = new Set(); + const pushPath = (candidate) => { + const rawPath = String(candidate || '').trim(); + if (!rawPath) { + return; + } + const normalized = normalizeComparablePath(rawPath); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + unique.push(normalized); + }; + + for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) { + pushPath(candidate); + } + pushPath(source.raw_dir_bluray_series); + pushPath(source.raw_dir_dvd_series); + + const blurayEffective = settingsService.resolveEffectiveToolSettings(source, 'bluray') || {}; + pushPath(blurayEffective.series_raw_dir); + const dvdEffective = settingsService.resolveEffectiveToolSettings(source, 'dvd') || {}; + pushPath(dvdEffective.series_raw_dir); + + return unique; +} + +function resolveRawRootForPath(settings = {}, currentRawDir = null, rawPath = null) { + const normalizedRawPath = normalizeComparablePath(rawPath); + if (!normalizedRawPath) { + return String(currentRawDir || '').trim() || null; + } + + const unique = []; + const seen = new Set(); + const pushPath = (candidate) => { + const normalized = normalizeComparablePath(candidate); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + unique.push(normalized); + }; + + pushPath(currentRawDir); + for (const candidate of getConfiguredMediaPathList(settings || {}, 'raw_dir')) { + pushPath(candidate); + } + for (const candidate of getSeriesRawPathCandidates(settings || {})) { + pushPath(candidate); + } + + const matchingRoots = unique + .filter((candidateRoot) => isPathInside(candidateRoot, normalizedRawPath)) + .sort((left, right) => right.length - left.length); + if (matchingRoots.length > 0) { + return matchingRoots[0]; + } + return String(currentRawDir || '').trim() || null; +} + +function isLikelySeriesRawPath(rawPath, settings = {}) { + const normalizedRawPath = normalizeComparablePath(rawPath); + if (!normalizedRawPath) { + return false; + } + + const seriesRoots = getSeriesRawPathCandidates(settings); + if (seriesRoots.some((candidate) => isPathInside(candidate, normalizedRawPath))) { + return true; + } + + // Fallback for common default layouts when series raw dir is not explicitly configured. + return /(^|\/)raw\/(?:dvd|bluray)\/series(\/|$)/i.test(normalizedRawPath); +} + +function getOrphanRawScanPathList(settings = {}) { + const source = settings && typeof settings === 'object' ? settings : {}; + const unique = []; + const seen = new Set(); + const pushPath = (candidate) => { + const rawPath = String(candidate || '').trim(); + if (!rawPath) { + return; + } + const normalized = normalizeComparablePath(rawPath); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + unique.push(normalized); + }; + + // Explicitly configured RAW roots (legacy + profiled keys) + for (const candidate of getConfiguredMediaPathList(source, 'raw_dir')) { + pushPath(candidate); + } + for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) { + pushPath(candidate); + } + pushPath(source.raw_dir_bluray_series); + pushPath(source.raw_dir_dvd_series); + pushPath(source.converter_raw_dir); + + // Effective settings roots per profile (covers fallback behavior in settings service) + for (const profile of ['bluray', 'dvd', 'cd', 'audiobook']) { + const effective = settingsService.resolveEffectiveToolSettings(source, profile) || {}; + pushPath(effective.raw_dir); + if (profile === 'dvd' || profile === 'bluray') { + pushPath(effective.series_raw_dir); + } + } + + // Hard defaults (even when no explicit setting exists yet) + pushPath(settingsService.DEFAULT_RAW_DIR); + pushPath(settingsService.DEFAULT_AUDIOBOOK_RAW_DIR); + pushPath(defaultConverterRawDir); + + return unique; +} + +function isDirectoryLikeOutput(mediaType, encodePlan = null, handbrakeInfo = null) { + if (mediaType === 'cd') { + return true; + } + if (mediaType !== 'audiobook') { + return false; + } + const hbMode = String(handbrakeInfo?.mode || '').trim().toLowerCase(); + if (hbMode === 'audiobook_encode_split') { + return true; + } + const format = String(encodePlan?.format || '').trim().toLowerCase(); + return Boolean(format && format !== 'm4b'); +} + +function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = {}) { + const mkInfo = parsed?.makemkvInfo || parseJsonSafe(job?.makemkv_info_json, null); + const miInfo = parsed?.mediainfoInfo || parseJsonSafe(job?.mediainfo_info_json, null); + const plan = parsed?.encodePlan || parseJsonSafe(job?.encode_plan_json, null); + const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null); + const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo); + const directoryLikeOutput = isDirectoryLikeOutput(mediaType, plan, handbrakeInfo); + const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; + const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase(); + const normalizedJobKind = String(job?.job_kind || '').trim().toLowerCase(); + const hasSeriesJobKind = normalizedJobKind === 'dvd_series_child' || normalizedJobKind === 'dvd_series_container'; + const hasParentJob = normalizeJobIdValue(job?.parent_job_id) !== null; + const isMultipartJobKind = ( + normalizedJobKind === 'multipart_movie_container' + || normalizedJobKind === 'multipart_movie_child' + || normalizedJobKind === 'multipart_movie_merge' + ); + // Parent-ID alone is not a reliable series signal: multipart movie jobs also use parent_job_id. + const hasSeriesParent = hasParentJob && !isMultipartJobKind; + const hasSeriesRawPathHint = isLikelySeriesRawPath(job?.raw_path, settings || {}); + const hasSeriesMetadataHint = hasSeriesMetadataSignals( + selectedMetadata, + mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : {} + ); + const seriesSignals = ( + Number(selectedMetadata?.tmdbId || 0) > 0 + || Number(selectedMetadata?.seasonNumber || 0) > 0 + || (Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0) + || Number(selectedMetadata?.episodeCount || 0) > 0 + || ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind) + ); + const isSeriesDisc = (mediaType === 'dvd' || mediaType === 'bluray') && ( + seriesSignals + || hasSeriesMetadataHint + || hasSeriesJobKind + || hasSeriesParent + || hasSeriesRawPathHint + ); + + // Converter jobs use their own storage dirs — do not remap output path + if (mediaType === 'converter') { + const s = settings || {}; + const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase(); + const converterMovieDir = converterMediaType === 'audio' + ? (String(s.converter_audio_dir || '').trim() || String(s.converter_movie_dir || '').trim()) + : String(s.converter_movie_dir || '').trim(); + const converterRawDir = String(s.converter_raw_dir || '').trim(); + return { + mediaType: 'converter', + directoryLikeOutput: false, + rawDir: converterRawDir, + movieDir: converterMovieDir, + effectiveRawPath: job?.raw_path || null, + effectiveOutputPath: job?.output_path || null, + makemkvInfo: mkInfo, + mediainfoInfo: miInfo, + handbrakeInfo, + encodePlan: plan + }; + } + + const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType); + const seriesRawDir = String(effectiveSettings?.series_raw_dir || '').trim(); + const seriesMovieDir = String(effectiveSettings?.series_dir || '').trim(); + const defaultRawDir = String(effectiveSettings?.raw_dir || '').trim(); + let rawDir = isSeriesDisc ? (seriesRawDir || defaultRawDir) : defaultRawDir; + if (isSeriesDisc) { + const normalizedStoredRawPath = normalizeComparablePath(job?.raw_path); + if (normalizedStoredRawPath) { + const seriesRawCandidates = getSeriesRawPathCandidates(settings || {}); + const matchedSeriesRawDir = seriesRawCandidates.find((candidate) => isPathInside(candidate, normalizedStoredRawPath)); + if (matchedSeriesRawDir) { + rawDir = matchedSeriesRawDir; + } + } + } + const configuredMovieDir = isSeriesDisc + ? (seriesMovieDir || String(effectiveSettings?.movie_dir || '').trim()) + : String(effectiveSettings?.movie_dir || '').trim(); + const movieDir = configuredMovieDir || rawDir; + const rawLookupDirsBase = isSeriesDisc + ? getSeriesRawPathCandidates(settings || {}) + : getConfiguredMediaPathList(settings || {}, 'raw_dir'); + const rawLookupDirs = rawLookupDirsBase + .filter((candidate) => normalizeComparablePath(candidate) !== normalizeComparablePath(rawDir)); + const effectiveRawPath = job?.raw_path + ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) + : (job?.raw_path || null); + rawDir = resolveRawRootForPath(settings || {}, rawDir, effectiveRawPath); + const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDisc) + ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) + : (job?.output_path || null); + + return { + mediaType, + directoryLikeOutput, + rawDir, + movieDir, + effectiveRawPath, + effectiveOutputPath, + makemkvInfo: mkInfo, + mediainfoInfo: miInfo, + handbrakeInfo, + encodePlan: plan + }; +} + +function buildUnknownDirectoryStatus(dirPath = null) { + return { + path: dirPath || null, + exists: null, + isDirectory: null, + isEmpty: null, + entryCount: null + }; +} + +function buildUnknownFileStatus(filePath = null) { + return { + path: filePath || null, + exists: null, + isFile: null, + sizeBytes: null + }; +} + +function enrichJobRow(job, settings = null, options = {}) { + const includeFsChecks = options?.includeFsChecks !== false; + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const handbrakeInfo = resolvedPaths.handbrakeInfo; + const directoryLikeOutput = Boolean(resolvedPaths.directoryLikeOutput); + const outputStatus = includeFsChecks + ? (directoryLikeOutput + ? inspectDirectory(resolvedPaths.effectiveOutputPath) + : inspectOutputFile(resolvedPaths.effectiveOutputPath)) + : (directoryLikeOutput + ? buildUnknownDirectoryStatus(resolvedPaths.effectiveOutputPath) + : buildUnknownFileStatus(resolvedPaths.effectiveOutputPath)); + const rawStatus = includeFsChecks + ? inspectDirectory(resolvedPaths.effectiveRawPath) + : buildUnknownDirectoryStatus(resolvedPaths.effectiveRawPath); + const movieDirPath = resolvedPaths.effectiveOutputPath ? path.dirname(resolvedPaths.effectiveOutputPath) : null; + const movieDirStatus = includeFsChecks + ? inspectDirectory(movieDirPath) + : buildUnknownDirectoryStatus(movieDirPath); + const makemkvInfo = resolvedPaths.makemkvInfo; + const mediainfoInfo = resolvedPaths.mediainfoInfo; + const encodePlan = resolvedPaths.encodePlan; + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}); + const resolvedPosterUrl = String( + job?.poster_url + || selectedMetadata?.poster + || selectedMetadata?.coverUrl + || selectedMetadata?.posterUrl + || encodePlan?.metadata?.poster + || encodePlan?.metadata?.coverUrl + || '' + ).trim() || null; + const mediaType = resolvedPaths.mediaType; + const normalizedJobStatus = String(job?.status || '').trim().toUpperCase(); + const ripSuccessful = Number(job?.rip_successful || 0) === 1 + || String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + const backupSuccess = ripSuccessful; + const finishedWithOutput = normalizedJobStatus === 'FINISHED' + && (includeFsChecks ? Boolean(outputStatus?.exists) : true); + const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object' + ? handbrakeInfo.seriesBatch + : null; + const seriesBatchMode = String(handbrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch'; + const seriesBatchTotal = Number(seriesBatchSummary?.totalCount || 0); + const seriesBatchFinished = Number(seriesBatchSummary?.finishedCount || 0); + const seriesBatchErrors = Number(seriesBatchSummary?.errorCount || 0); + const seriesBatchCancelled = Number(seriesBatchSummary?.cancelledCount || 0); + const seriesBatchEncodeSuccess = seriesBatchMode + && seriesBatchTotal > 0 + && seriesBatchFinished >= seriesBatchTotal + && seriesBatchErrors <= 0 + && seriesBatchCancelled <= 0; + const encodeSuccess = directoryLikeOutput + ? finishedWithOutput + : ( + seriesBatchEncodeSuccess + || (mediaType === 'converter' + ? finishedWithOutput + : String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS') + ); + const nfoStatus = buildNfoStatusForJob({ + job, + outputPath: resolvedPaths.effectiveOutputPath, + outputStatus, + includeFsChecks, + settings, + mediaType + }); + + return { + ...job, + poster_url: resolvedPosterUrl, + raw_path: resolvedPaths.effectiveRawPath, + output_path: resolvedPaths.effectiveOutputPath, + makemkvInfo, + handbrakeInfo, + mediainfoInfo, + encodePlan, + mediaType, + ripSuccessful, + backupSuccess, + encodeSuccess, + rawStatus, + outputStatus, + movieDirStatus, + nfoStatus + }; +} + +function resolveSafe(inputPath) { + return path.resolve(String(inputPath || '')); +} + +function isPathInside(basePath, candidatePath) { + if (!basePath || !candidatePath) { + return false; + } + + const base = resolveSafe(basePath); + const candidate = resolveSafe(candidatePath); + return candidate === base || candidate.startsWith(`${base}${path.sep}`); +} + +function normalizeComparablePath(inputPath) { + return resolveSafe(String(inputPath || '')).replace(/[\\/]+$/, ''); +} + +function escapeRegExp(input) { + return String(input || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function parseNumberedFolderName(folderName) { + const raw = String(folderName || '').trim(); + if (!raw) { + return { baseName: '', suffixNumber: 0, numbered: false }; + } + const match = raw.match(/^(.*)_([0-9]+)$/); + if (!match) { + return { baseName: raw, suffixNumber: 0, numbered: false }; + } + const baseName = String(match[1] || '').trim() || raw; + const suffixNumber = Number(match[2] || 0); + return { + baseName, + suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0, + numbered: true + }; +} + +function inferSiblingOutputFolders(outputPath) { + const normalizedOutputPath = normalizeComparablePath(outputPath); + if (!normalizedOutputPath) { + return []; + } + + let outputStat = null; + try { + outputStat = fs.statSync(normalizedOutputPath); + } catch (_error) { + return []; + } + if (!outputStat?.isDirectory?.()) { + return []; + } + + const parentDir = path.dirname(normalizedOutputPath); + const folderName = path.basename(normalizedOutputPath); + const parsedName = parseNumberedFolderName(folderName); + const baseName = parsedName.baseName || folderName; + if (!baseName) { + return [normalizedOutputPath]; + } + + const siblingRegex = new RegExp(`^${escapeRegExp(baseName)}(?:_(\\d+))?$`); + let entries = []; + try { + entries = fs.readdirSync(parentDir, { withFileTypes: true }); + } catch (_error) { + return [normalizedOutputPath]; + } + + const candidates = []; + for (const entry of entries) { + if (!entry?.isDirectory?.()) { + continue; + } + const name = String(entry.name || '').trim(); + if (!name) { + continue; + } + const match = name.match(siblingRegex); + if (!match) { + continue; + } + const suffixNumber = match[1] ? Number(match[1]) : 0; + const fullPath = normalizeComparablePath(path.join(parentDir, name)); + candidates.push({ + path: fullPath, + suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0 + }); + } + + if (candidates.length === 0) { + return [normalizedOutputPath]; + } + + candidates.sort((left, right) => { + if (left.suffixNumber !== right.suffixNumber) { + return left.suffixNumber - right.suffixNumber; + } + return String(left.path || '').localeCompare(String(right.path || ''), 'de-DE'); + }); + return candidates.map((item) => item.path); +} + +function resolveExistingOutputPathVariant(outputPath) { + const normalizedOutputPath = normalizeComparablePath(outputPath); + if (!normalizedOutputPath) { + return null; + } + try { + if (fs.existsSync(normalizedOutputPath)) { + return normalizedOutputPath; + } + } catch (_error) { + // Ignore and continue with fallback probing. + } + + const parsed = path.parse(normalizedOutputPath); + const parentDir = normalizeComparablePath(parsed.dir); + if (!parentDir) { + return null; + } + + const candidatePaths = []; + const candidateSet = new Set(); + const pushCandidate = (candidatePath) => { + const normalized = normalizeComparablePath(candidatePath); + if (!normalized || candidateSet.has(normalized)) { + return; + } + candidateSet.add(normalized); + candidatePaths.push(normalized); + }; + + // If parent directory is numbered (e.g. "Staffel 3_2"), prefer base folder first. + const parentName = path.basename(parentDir); + const parsedParentName = parseNumberedFolderName(parentName); + const parentRoot = path.dirname(parentDir); + const siblingBaseName = parsedParentName.baseName || parentName; + if (parsedParentName.numbered && parsedParentName.baseName) { + pushCandidate(path.join(parentRoot, parsedParentName.baseName, parsed.base)); + } + + // If filename is numbered (e.g. "Episode_2.mkv"), prefer base filename first. + const parsedFileName = parseNumberedFolderName(parsed.name); + if (parsedFileName.numbered && parsedFileName.baseName) { + pushCandidate(path.join(parentDir, `${parsedFileName.baseName}${parsed.ext}`)); + } + + // Probe sibling directories with same base name (base, _2, _3, ...). + if (parentRoot && siblingBaseName) { + try { + const siblingRegex = new RegExp(`^${escapeRegExp(siblingBaseName)}(?:_(\\d+))?$`); + const siblingDirs = fs.readdirSync(parentRoot, { withFileTypes: true }) + .filter((entry) => entry?.isDirectory?.()) + .map((entry) => { + const name = String(entry?.name || '').trim(); + const match = name.match(siblingRegex); + if (!match) { + return null; + } + const suffixNumber = match[1] ? Number(match[1]) : 0; + return { + dirPath: path.join(parentRoot, name), + suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0 + }; + }) + .filter(Boolean) + .sort((left, right) => { + if (left.suffixNumber !== right.suffixNumber) { + return left.suffixNumber - right.suffixNumber; + } + return String(left.dirPath || '').localeCompare(String(right.dirPath || ''), 'de-DE'); + }); + for (const sibling of siblingDirs) { + pushCandidate(path.join(sibling.dirPath, parsed.base)); + } + } catch (_error) { + // Best effort. + } + } + + for (const candidatePath of candidatePaths) { + try { + if (fs.existsSync(candidatePath)) { + return candidatePath; + } + } catch (_error) { + // Ignore and continue. + } + } + + return null; +} + +function compareOutputFolderPaths(leftPath, rightPath) { + const leftNormalized = normalizeComparablePath(leftPath); + const rightNormalized = normalizeComparablePath(rightPath); + const leftParent = normalizeComparablePath(path.dirname(leftNormalized)); + const rightParent = normalizeComparablePath(path.dirname(rightNormalized)); + const parentCompare = leftParent.localeCompare(rightParent, 'de-DE'); + if (parentCompare !== 0) { + return parentCompare; + } + + const leftName = String(path.basename(leftNormalized) || '').trim(); + const rightName = String(path.basename(rightNormalized) || '').trim(); + const leftParsed = parseNumberedFolderName(leftName); + const rightParsed = parseNumberedFolderName(rightName); + const baseCompare = String(leftParsed.baseName || '').localeCompare(String(rightParsed.baseName || ''), 'de-DE'); + if (baseCompare !== 0) { + return baseCompare; + } + + const leftSuffix = Number(leftParsed?.suffixNumber || 0); + const rightSuffix = Number(rightParsed?.suffixNumber || 0); + if (leftSuffix !== rightSuffix) { + return leftSuffix - rightSuffix; + } + + return leftNormalized.localeCompare(rightNormalized, 'de-DE'); +} + +function stripRawFolderStatePrefix(folderName) { + const rawName = String(folderName || '').trim(); + if (!rawName) { + return ''; + } + return rawName + .replace(new RegExp(`^${RAW_INCOMPLETE_PREFIX}`, 'i'), '') + .replace(new RegExp(`^${RAW_RIP_COMPLETE_PREFIX}`, 'i'), '') + .trim(); +} + +function stripRawFolderJobSuffix(folderName) { + const rawName = String(folderName || '').trim(); + if (!rawName) { + return ''; + } + return rawName.replace(/(?:\s*-\s*RAW\s*-\s*job-\d+\s*)+$/i, '').trim(); +} + +function applyRawFolderPrefix(folderName, prefix = '') { + const normalized = stripRawFolderStatePrefix(folderName); + if (!normalized) { + return normalized; + } + const safePrefix = String(prefix || '').trim(); + return safePrefix ? `${safePrefix}${normalized}` : normalized; +} + +function parseRawFolderMetadata(folderName) { + const rawName = String(folderName || '').trim(); + const normalizedRawName = stripRawFolderStatePrefix(rawName); + const folderJobIdMatches = Array.from(normalizedRawName.matchAll(/-\s*RAW\s*-\s*job-(\d+)/ig)); + const folderJobId = folderJobIdMatches.length > 0 + ? Number(folderJobIdMatches[folderJobIdMatches.length - 1][1]) + : null; + let working = stripRawFolderJobSuffix(normalizedRawName); + + const imdbMatch = working.match(/\[(tt\d{6,12})\]/i); + const imdbId = imdbMatch ? String(imdbMatch[1] || '').toLowerCase() : null; + if (imdbMatch) { + working = working.replace(imdbMatch[0], '').trim(); + } + + const yearMatch = working.match(/\((19|20)\d{2}\)/); + const year = yearMatch ? Number(String(yearMatch[0]).replace(/[()]/g, '')) : null; + if (yearMatch) { + working = working.replace(yearMatch[0], '').trim(); + } + + const title = working.replace(/\s{2,}/g, ' ').trim() || null; + + return { + title, + year: Number.isFinite(year) ? year : null, + imdbId, + folderJobId: Number.isFinite(folderJobId) ? Math.trunc(folderJobId) : null + }; +} + +function normalizePositiveNumberOrNull(value) { + const raw = String(value ?? '').trim(); + if (!raw) { + return null; + } + const parsed = Number(raw.replace(',', '.')); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function normalizePositiveIntegerOrNull(value) { + const parsed = normalizePositiveNumberOrNull(value); + if (parsed === null) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeDvdMetadataWorkflowKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (['film', 'movie', 'feature'].includes(raw)) { + return 'film'; + } + if (['series', 'tv', 'season', 'episode'].includes(raw)) { + return 'series'; + } + return null; +} + +function resolveSeriesAssignmentEpisodeSpan(assignment = null) { + const source = assignment && typeof assignment === 'object' ? assignment : {}; + const start = normalizePositiveIntegerOrNull( + source?.episodeNumberStart + ?? source?.episodeNoStart + ?? source?.episodeNumber + ?? source?.number + ?? null + ); + const explicitEnd = normalizePositiveIntegerOrNull( + source?.episodeNumberEnd + ?? source?.episodeNoEnd + ?? null + ); + const explicitSpan = normalizePositiveIntegerOrNull( + source?.episodeSpan + ?? source?.episodeCount + ?? null + ); + if (start && explicitEnd && explicitEnd >= start) { + return Math.max(1, Math.trunc(explicitEnd - start + 1)); + } + if (explicitSpan && explicitSpan > 0) { + return Math.max(1, Math.trunc(explicitSpan)); + } + if (start) { + return 1; + } + return 0; +} + +function countSelectedEpisodeSlotsFromPlan(plan = null) { + const sourcePlan = plan && typeof plan === 'object' ? plan : {}; + const titles = Array.isArray(sourcePlan?.titles) ? sourcePlan.titles : []; + const selectedFromFlags = titles + .filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) + .map((title) => normalizePositiveIntegerOrNull(title?.id)) + .filter(Boolean); + const selectedFromPlan = Array.isArray(sourcePlan?.selectedTitleIds) + ? sourcePlan.selectedTitleIds + .map((value) => normalizePositiveIntegerOrNull(value)) + .filter(Boolean) + : []; + const selectedFromInput = normalizePositiveIntegerOrNull(sourcePlan?.encodeInputTitleId); + const selectedTitleIds = Array.from(new Set([ + ...selectedFromFlags, + ...selectedFromPlan, + ...(selectedFromInput ? [selectedFromInput] : []) + ])); + if (selectedTitleIds.length === 0) { + return 0; + } + const assignments = sourcePlan?.episodeAssignments && typeof sourcePlan.episodeAssignments === 'object' + ? sourcePlan.episodeAssignments + : {}; + let total = 0; + for (const titleId of selectedTitleIds) { + const assignment = assignments[String(titleId)] || assignments[titleId] || null; + const span = resolveSeriesAssignmentEpisodeSpan(assignment); + total += span > 0 ? span : 1; + } + return Math.max(0, total); +} + +function isSeriesBatchEpisodeSubJobPlan(encodePlan = null) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + if (Boolean(plan?.seriesBatchChild) || Boolean(plan?.seriesBatchVirtualEpisode)) { + return true; + } + const parentJobId = normalizePositiveIntegerOrNull(plan?.seriesBatchParentJobId); + const titleId = normalizePositiveIntegerOrNull(plan?.seriesBatchTitleId); + const childIndex = normalizePositiveIntegerOrNull(plan?.seriesBatchChildIndex); + const childCount = normalizePositiveIntegerOrNull(plan?.seriesBatchChildCount); + if (parentJobId && (titleId || childIndex || childCount)) { + return true; + } + return false; +} + +function isSeriesBatchEpisodeSubJobRow(row = null) { + const source = row && typeof row === 'object' ? row : {}; + const plan = source?.encodePlan && typeof source.encodePlan === 'object' + ? source.encodePlan + : parseJsonSafe(source?.encode_plan_json, null); + return isSeriesBatchEpisodeSubJobPlan(plan); +} + +function extractSeasonNumberFromText(value) { + const text = String(value || '').trim(); + if (!text) { + return null; + } + + const directPattern = text.match(/(?:staffel|season|serie|series|s)\s*[-_.:]?\s*(\d{1,3}(?:[.,]\d+)?)/i); + if (directPattern?.[1]) { + return normalizePositiveNumberOrNull(directPattern[1]); + } + + const tokenPattern = text.match(/(?:^|[\s._-])s(\d{1,3}(?:[.,]\d+)?)(?:$|[\s._-])/i); + if (tokenPattern?.[1]) { + return normalizePositiveNumberOrNull(tokenPattern[1]); + } + + return null; +} + +function extractDiscNumberFromText(value) { + const text = String(value || '').trim(); + if (!text) { + return null; + } + + const directPattern = text.match(/(?:disc|disk|dvd|cd)\s*[-_.:]?\s*(\d{1,2})/i); + if (directPattern?.[1]) { + return normalizePositiveIntegerOrNull(directPattern[1]); + } + + const tokenPattern = text.match(/(?:^|[\s._-])d(?:isc)?[-_.:]?\s*(\d{1,2})(?:$|[\s._-])/i); + if (tokenPattern?.[1]) { + return normalizePositiveIntegerOrNull(tokenPattern[1]); + } + + return null; +} + +function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = {}) { + const source = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; + const analyzeContext = source.analyzeContext && typeof source.analyzeContext === 'object' + ? source.analyzeContext + : {}; + const analyzeSelected = analyzeContext.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const topLevelSelected = source.selectedMetadata && typeof source.selectedMetadata === 'object' + ? source.selectedMetadata + : {}; + const merged = { + ...analyzeSelected, + ...topLevelSelected + }; + + if (!merged.metadataProvider && analyzeContext.metadataProvider) { + merged.metadataProvider = analyzeContext.metadataProvider; + } + if (!merged.metadataKind && analyzeContext.metadataKind) { + merged.metadataKind = analyzeContext.metadataKind; + } + + return merged; +} + +function hasSeriesMetadataSignals(selectedMetadata = {}, analyzeContext = {}) { + const metadata = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}; + const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {}; + const workflowKind = normalizeDvdMetadataWorkflowKind( + metadata.workflowKind + || analyze.workflowKind + || null + ); + if (workflowKind === 'film') { + return false; + } + if (workflowKind === 'series') { + return true; + } + const metadataKind = String(metadata.metadataKind || analyze.metadataKind || '').trim().toLowerCase(); + const seasonNumber = normalizePositiveNumberOrNull( + metadata.seasonNumber ?? analyze?.seriesLookupHint?.seasonNumber ?? null + ); + const seasonName = String(metadata.seasonName || '').trim(); + const episodeCount = Number(metadata.episodeCount || 0) || 0; + const hasEpisodeList = Array.isArray(metadata.episodes) && metadata.episodes.length > 0; + const tmdbId = normalizePositiveIntegerOrNull( + metadata.tmdbId ?? analyze.tmdbId ?? metadata.providerId ?? analyze.providerId ?? null + ); + const provider = String( + metadata.metadataProvider + || analyze.metadataProvider + || '' + ).trim().toLowerCase(); + const providerIsTmdb = provider === 'tmdb' || provider === 'themoviedb'; + const seriesKind = ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind); + + if (seriesKind || seasonNumber !== null || Boolean(seasonName) || episodeCount > 0 || hasEpisodeList || tmdbId !== null) { + return true; + } + return providerIsTmdb && Boolean(String(analyze?.seriesLookupHint?.query || '').trim()); +} + +function buildSeriesImportHints({ + folderName = '', + rawPath = '', + metadata = {}, + sourceSelectedMetadata = {}, + sourceAnalyzeContext = {} +} = {}) { + const safeMetadata = metadata && typeof metadata === 'object' ? metadata : {}; + const selected = sourceSelectedMetadata && typeof sourceSelectedMetadata === 'object' + ? sourceSelectedMetadata + : {}; + const analyze = sourceAnalyzeContext && typeof sourceAnalyzeContext === 'object' + ? sourceAnalyzeContext + : {}; + const titleSource = String( + selected.title + || selected.seriesTitle + || safeMetadata.title + || '' + ).trim(); + const combinedText = [folderName, path.basename(String(rawPath || '').trim())] + .filter(Boolean) + .join(' '); + const seasonNumber = normalizePositiveNumberOrNull( + selected.seasonNumber + ?? analyze?.seriesLookupHint?.seasonNumber + ?? extractSeasonNumberFromText(combinedText) + ); + const discNumber = normalizePositiveIntegerOrNull( + selected.discNumber + ?? analyze?.seriesLookupHint?.discNumber + ?? extractDiscNumberFromText(combinedText) + ); + + const seriesLookupHint = { + query: titleSource || null, + seasonNumber, + discNumber + }; + const metadataHasSeriesSignals = hasSeriesMetadataSignals(selected, analyze); + if (!metadataHasSeriesSignals && !seriesLookupHint.query && seriesLookupHint.seasonNumber === null && seriesLookupHint.discNumber === null) { + return null; + } + + return { + selectedMetadata: { + ...selected, + metadataProvider: String(selected.metadataProvider || analyze.metadataProvider || 'tmdb').trim().toLowerCase() || 'tmdb', + metadataKind: String(selected.metadataKind || analyze.metadataKind || 'series').trim().toLowerCase() || 'series', + title: titleSource || null, + seasonNumber, + discNumber + }, + analyzeContextPatch: { + metadataProvider: String( + analyze.metadataProvider + || selected.metadataProvider + || 'tmdb' + ).trim().toLowerCase() || 'tmdb', + metadataKind: String( + analyze.metadataKind + || selected.metadataKind + || 'series' + ).trim().toLowerCase() || 'series', + seriesLookupHint: { + ...(analyze.seriesLookupHint && typeof analyze.seriesLookupHint === 'object' + ? analyze.seriesLookupHint + : {}), + ...seriesLookupHint + }, + seriesAnalysis: { + ...(analyze.seriesAnalysis && typeof analyze.seriesAnalysis === 'object' + ? analyze.seriesAnalysis + : {}), + summary: { + ...(analyze?.seriesAnalysis?.summary && typeof analyze.seriesAnalysis.summary === 'object' + ? analyze.seriesAnalysis.summary + : {}), + seriesLike: true, + confidence: analyze?.seriesAnalysis?.summary?.confidence || 'medium' + } + } + } + }; +} + +function buildRawPathForJobId(rawPath, jobId) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return rawPath; + } + + const absRawPath = normalizeComparablePath(rawPath); + const folderName = path.basename(absRawPath); + const statePrefix = /^Rip_Complete_/i.test(folderName) + ? RAW_RIP_COMPLETE_PREFIX + : /^Incomplete_/i.test(folderName) + ? RAW_INCOMPLETE_PREFIX + : ''; + const stripped = stripRawFolderJobSuffix(stripRawFolderStatePrefix(folderName)); + if (!stripped) { + return absRawPath; + } + const withJobId = `${statePrefix}${stripped} - RAW - job-${Math.trunc(normalizedJobId)}`; + return path.join(path.dirname(absRawPath), withJobId); +} + +function normalizeCdFormat(value) { + const raw = String(value || '').trim().toLowerCase(); + return cdRipService.SUPPORTED_FORMATS.has(raw) ? raw : null; +} + +function normalizeAudioPathToken(value) { + let raw = String(value || '').trim(); + if (!raw) { + return null; + } + + // Unwrap common wrappers like "(...)" or quotes around the full token. + let changed = true; + while (changed && raw.length > 0) { + changed = false; + if ( + (raw.startsWith('"') && raw.endsWith('"')) + || (raw.startsWith("'") && raw.endsWith("'")) + || (raw.startsWith('`') && raw.endsWith('`')) + || (raw.startsWith('(') && raw.endsWith(')')) + ) { + raw = raw.slice(1, -1).trim(); + changed = true; + } + } + + // Strip leading quotes and trailing punctuation (but keep balanced ')'). + raw = raw + .replace(/^["'`]+/, '') + .replace(/[,"'`;]+$/g, '') + .trim(); + + // Remove only dangling unmatched ')' at the end (e.g. ".../path)") + // while preserving valid directory names like "... (2007)". + let openCount = (raw.match(/\(/g) || []).length; + let closeCount = (raw.match(/\)/g) || []).length; + while (closeCount > openCount && raw.endsWith(')')) { + raw = raw.slice(0, -1).trimEnd(); + closeCount -= 1; + } + + if (!raw.startsWith('/')) { + return null; + } + return raw; +} + +function resolveExistingAudioPathCandidate(candidate) { + const normalized = normalizeAudioPathToken(candidate) || String(candidate || '').trim() || null; + if (!normalized || !normalized.startsWith('/')) { + return null; + } + + try { + if (fs.existsSync(normalized)) { + return normalized; + } + } catch (_error) { + // ignore fs errors while probing possible output paths + } + + const openCount = (normalized.match(/\(/g) || []).length; + const closeCount = (normalized.match(/\)/g) || []).length; + if (openCount <= closeCount) { + return null; + } + + let repaired = normalized; + for (let missing = closeCount; missing < openCount; missing += 1) { + repaired = `${repaired})`; + try { + if (fs.existsSync(repaired)) { + return repaired; + } + } catch (_error) { + // ignore fs errors while probing repaired variants + } + } + + return null; +} + +function extractAbsolutePathTokens(text) { + const source = String(text || ''); + const tokens = []; + const seen = new Set(); + const pushToken = (value) => { + const normalized = normalizeAudioPathToken(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + tokens.push(normalized); + }; + + let match; + const quotedPattern = /'([^']+)'|"([^"]+)"/g; + while ((match = quotedPattern.exec(source)) !== null) { + pushToken(match[1] || match[2] || ''); + } + + const sanitized = source.replace(/'[^']*'|"[^"]*"/g, ' '); + const barePattern = /(^|\s)(\/[^\s]+)/g; + while ((match = barePattern.exec(sanitized)) !== null) { + pushToken(match[2] || ''); + } + + return tokens; +} + +function parseProcessLogTimestamp(line) { + const match = String(line || '').match(/^\[([^\]]+)\]/); + if (!match) { + return null; + } + const parsed = Date.parse(match[1]); + return Number.isFinite(parsed) ? parsed : null; +} + +function readProcessLogFileLines(filePath) { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + return String(raw || '') + .split(/\r\n|\n|\r/) + .filter((line) => line.length > 0); + } catch (_error) { + return []; + } +} + +function listJobProcessLogFiles() { + const logDir = getJobLogDir(); + try { + if (!fs.existsSync(logDir) || !fs.statSync(logDir).isDirectory()) { + return []; + } + return fs.readdirSync(logDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && PROCESS_LOG_FILE_PATTERN.test(entry.name)) + .map((entry) => { + const match = entry.name.match(PROCESS_LOG_FILE_PATTERN); + const jobId = match ? Number(match[1]) : null; + return { + fileName: entry.name, + filePath: path.join(logDir, entry.name), + jobId: Number.isFinite(jobId) ? Math.trunc(jobId) : null + }; + }); + } catch (_error) { + return []; + } +} + +function pickPreferredExistingPath(candidates = []) { + const normalizedCandidates = []; + const seen = new Set(); + + for (const candidate of Array.isArray(candidates) ? candidates : []) { + const normalized = normalizeAudioPathToken(candidate) || String(candidate || '').trim() || null; + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + normalizedCandidates.push(normalized); + } + + for (let index = normalizedCandidates.length - 1; index >= 0; index -= 1) { + const candidate = normalizedCandidates[index]; + const existingCandidate = resolveExistingAudioPathCandidate(candidate); + if (existingCandidate) { + return existingCandidate; + } + } + + return normalizedCandidates[normalizedCandidates.length - 1] || null; +} + +function collectAudioFilesRecursively(rootPath, options = {}) { + const maxDepth = Number.isFinite(Number(options.maxDepth)) ? Math.max(0, Math.trunc(Number(options.maxDepth))) : 6; + const maxFiles = Number.isFinite(Number(options.maxFiles)) ? Math.max(1, Math.trunc(Number(options.maxFiles))) : 2000; + const result = []; + const normalizedRoot = String(rootPath || '').trim(); + if (!normalizedRoot || !fs.existsSync(normalizedRoot)) { + return result; + } + + const pushFile = (filePath) => { + if (result.length >= maxFiles) { + return; + } + const ext = path.extname(filePath).toLowerCase(); + if (CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) { + result.push(filePath); + } + }; + + try { + const stat = fs.statSync(normalizedRoot); + if (stat.isFile()) { + pushFile(normalizedRoot); + return result; + } + } catch (_error) { + return result; + } + + const queue = [{ dirPath: normalizedRoot, depth: 0 }]; + while (queue.length > 0 && result.length < maxFiles) { + const current = queue.shift(); + if (!current) { + continue; + } + let entries = []; + try { + entries = fs.readdirSync(current.dirPath, { withFileTypes: true }); + } catch (_error) { + continue; + } + + entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + for (const entry of entries) { + const absPath = path.join(current.dirPath, entry.name); + if (entry.isFile()) { + pushFile(absPath); + } else if (entry.isDirectory() && current.depth < maxDepth) { + queue.push({ dirPath: absPath, depth: current.depth + 1 }); + } + if (result.length >= maxFiles) { + break; + } + } + } + + return result; +} + +function parseCdOutputTrackFromPath(filePath) { + const ext = path.extname(String(filePath || '')).toLowerCase(); + if (!CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) { + return null; + } + + const baseName = path.basename(filePath, ext).replace(/\s+/g, ' ').trim(); + if (!baseName) { + return null; + } + + const numberMatch = baseName.match(/^(\d{1,3})(.*)$/); + const parsedPosition = numberMatch ? Number(numberMatch[1]) : null; + const position = Number.isFinite(parsedPosition) && parsedPosition > 0 ? Math.trunc(parsedPosition) : null; + let remainder = numberMatch ? String(numberMatch[2] || '').replace(/^[-._\s]+/, '').trim() : baseName; + let artist = null; + let title = remainder || baseName; + + const separatorIndex = remainder.indexOf(' - '); + if (separatorIndex > 0) { + artist = remainder.slice(0, separatorIndex).trim() || null; + title = remainder.slice(separatorIndex + 3).trim() || remainder; + } + + return { + position, + artist, + title: title || baseName, + format: normalizeCdFormat(ext.replace(/^\./, '')), + filePath + }; +} + +function assignMissingTrackPositions(entries = []) { + const used = new Set( + (Array.isArray(entries) ? entries : []) + .map((entry) => Number(entry?.position)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + + let nextPosition = 1; + return (Array.isArray(entries) ? entries : []).map((entry) => { + const current = Number(entry?.position); + if (Number.isFinite(current) && current > 0) { + return { + ...entry, + position: Math.trunc(current) + }; + } + + while (used.has(nextPosition)) { + nextPosition += 1; + } + const assigned = nextPosition; + used.add(assigned); + nextPosition += 1; + return { + ...entry, + position: assigned + }; + }); +} + +function findMostCommonString(values = []) { + const counts = new Map(); + for (const value of Array.isArray(values) ? values : []) { + const normalized = String(value || '').trim(); + if (!normalized) { + continue; + } + counts.set(normalized, (counts.get(normalized) || 0) + 1); + } + + let bestValue = null; + let bestCount = 0; + for (const [value, count] of counts.entries()) { + if (count > bestCount) { + bestValue = value; + bestCount = count; + } + } + return bestValue; +} + +function normalizeComparableLabel(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[^\x00-\x7F]+/g, '') + .replace(/[^A-Za-z0-9]+/g, ' ') + .trim() + .toLowerCase(); +} + +function parseCdFolderMetadataFromOutputDir(outputDir) { + const normalized = String(outputDir || '').trim(); + if (!normalized) { + return { + artist: null, + album: null, + year: null + }; + } + + let working = path.basename(normalized).replace(/\s+/g, ' ').trim(); + if (!working || working === '.' || working === path.sep) { + return { + artist: null, + album: null, + year: null + }; + } + + const yearMatch = working.match(/\((19|20)\d{2}\)\s*$/); + const year = yearMatch ? Number(String(yearMatch[0]).replace(/[()]/g, '')) : null; + if (yearMatch) { + working = working.slice(0, working.length - yearMatch[0].length).trim(); + } + + const separatorIndex = working.indexOf(' - '); + const artist = separatorIndex > 0 ? working.slice(0, separatorIndex).trim() || null : null; + const album = separatorIndex > 0 + ? working.slice(separatorIndex + 3).trim() || null + : (working || null); + + return { + artist, + album, + year: Number.isFinite(year) ? year : null + }; +} + +function directoryContainsAudioFiles(dirPath) { + try { + if (!dirPath || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { + return false; + } + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const absPath = path.join(dirPath, entry.name); + if (entry.isFile()) { + if (CD_OUTPUT_AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + return true; + } + } else if (entry.isDirectory()) { + const nestedEntries = fs.readdirSync(absPath, { withFileTypes: true }); + if (nestedEntries.some((nested) => nested.isFile() && CD_OUTPUT_AUDIO_EXTENSIONS.has(path.extname(nested.name).toLowerCase()))) { + return true; + } + } + } + } catch (_error) { + return false; + } + return false; +} + +function findCdOutputDirByMetadata(options = {}) { + const settings = options.settings && typeof options.settings === 'object' ? options.settings : {}; + const baseMetadata = options.baseMetadata && typeof options.baseMetadata === 'object' + ? options.baseMetadata + : {}; + const outputTemplate = String(options.outputTemplate || settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim() + || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings, 'cd'); + const outputBaseDir = String(effectiveSettings?.movie_dir || effectiveSettings?.raw_dir || '').trim() || null; + if (!outputBaseDir) { + return null; + } + + const metadataVariants = []; + const pushMetadataVariant = (candidate) => { + if (!candidate || typeof candidate !== 'object') { + return; + } + const title = String(candidate.title || candidate.album || '').trim() || null; + const artist = String(candidate.artist || '').trim() || null; + const yearRaw = Number(candidate.year); + const year = Number.isFinite(yearRaw) && yearRaw > 0 ? Math.trunc(yearRaw) : null; + if (!title && !artist && !year) { + return; + } + const key = JSON.stringify({ title, artist, year }); + if (metadataVariants.some((entry) => entry.key === key)) { + return; + } + metadataVariants.push({ + key, + meta: { + title, + album: title, + artist, + year + } + }); + }; + + pushMetadataVariant(baseMetadata); + pushMetadataVariant({ + title: baseMetadata.album || baseMetadata.title || null, + artist: null, + year: baseMetadata.year || null + }); + + for (const variant of metadataVariants) { + try { + if (!variant.meta.title) { + continue; + } + const candidatePath = cdRipService.buildOutputDir(variant.meta, outputBaseDir, outputTemplate); + if (candidatePath && fs.existsSync(candidatePath) && directoryContainsAudioFiles(candidatePath)) { + return candidatePath; + } + } catch (_error) { + // ignore template/render errors and continue with directory scan fallback + } + } + + try { + if (!fs.existsSync(outputBaseDir) || !fs.statSync(outputBaseDir).isDirectory()) { + return null; + } + const targetAlbum = normalizeComparableLabel(baseMetadata.album || baseMetadata.title || ''); + const targetArtist = normalizeComparableLabel(baseMetadata.artist || ''); + const targetYearRaw = Number(baseMetadata.year); + const targetYear = Number.isFinite(targetYearRaw) && targetYearRaw > 0 ? Math.trunc(targetYearRaw) : null; + let best = null; + let bestScore = -1; + const entries = fs.readdirSync(outputBaseDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const candidatePath = path.join(outputBaseDir, entry.name); + if (!directoryContainsAudioFiles(candidatePath)) { + continue; + } + const parsed = parseCdFolderMetadataFromOutputDir(candidatePath); + const candidateAlbum = normalizeComparableLabel(parsed.album || ''); + const candidateArtist = normalizeComparableLabel(parsed.artist || ''); + const candidateYearRaw = Number(parsed.year); + const candidateYear = Number.isFinite(candidateYearRaw) && candidateYearRaw > 0 ? Math.trunc(candidateYearRaw) : null; + + let score = 0; + if (targetAlbum && candidateAlbum === targetAlbum) { + score += 10; + } else if (targetAlbum && candidateAlbum.includes(targetAlbum)) { + score += 6; + } else if (targetAlbum && targetAlbum.includes(candidateAlbum) && candidateAlbum) { + score += 4; + } + if (targetYear && candidateYear === targetYear) { + score += 5; + } + if (targetArtist && candidateArtist === targetArtist) { + score += 3; + } + if (score > bestScore) { + best = candidatePath; + bestScore = score; + } + } + return bestScore > 0 ? best : null; + } catch (_error) { + return null; + } +} + +function extractCommandToken(commandLine) { + const raw = String(commandLine || '').trim(); + if (!raw) { + return null; + } + const match = raw.match(/^'([^']+)'|"([^"]+)"|(\S+)/); + return String(match?.[1] || match?.[2] || match?.[3] || '').trim() || null; +} + +function findRelatedJobLogSources(options = {}) { + const relatedJobIds = Array.isArray(options.relatedJobIds) + ? options.relatedJobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + const excludeJobIds = new Set( + (Array.isArray(options.excludeJobIds) ? options.excludeJobIds : []) + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + ); + const rawPathCandidates = Array.isArray(options.rawPathCandidates) + ? options.rawPathCandidates + .map((value) => String(value || '').trim()) + .filter(Boolean) + : []; + const pathNeedles = Array.from(new Set( + rawPathCandidates + .map((value) => normalizeComparablePath(value)) + .filter(Boolean) + )); + + const collected = new Map(); + const addSource = (filePath, matchedBy) => { + const normalizedFilePath = String(filePath || '').trim(); + if (!normalizedFilePath || collected.has(normalizedFilePath)) { + return; + } + const lines = readProcessLogFileLines(normalizedFilePath); + if (lines.length === 0) { + return; + } + const fileName = path.basename(normalizedFilePath); + const match = fileName.match(PROCESS_LOG_FILE_PATTERN); + const parsedJobId = match ? Number(match[1]) : null; + const jobId = Number.isFinite(parsedJobId) ? Math.trunc(parsedJobId) : null; + if (jobId && excludeJobIds.has(jobId)) { + return; + } + const firstTimestampMs = lines.reduce((found, line) => { + if (found !== null) { + return found; + } + return parseProcessLogTimestamp(line); + }, null); + collected.set(normalizedFilePath, { + filePath: normalizedFilePath, + fileName, + jobId, + matchedBy: matchedBy ? [matchedBy] : [], + lines, + firstTimestampMs + }); + }; + + for (const relatedJobId of relatedJobIds) { + if (excludeJobIds.has(relatedJobId)) { + continue; + } + const directPath = toProcessLogPath(relatedJobId); + if (directPath && fs.existsSync(directPath)) { + addSource(directPath, `jobId:${relatedJobId}`); + } + } + + if (pathNeedles.length > 0) { + for (const entry of listJobProcessLogFiles()) { + if (!entry?.filePath || collected.has(entry.filePath)) { + continue; + } + if (entry.jobId && excludeJobIds.has(entry.jobId)) { + continue; + } + + let rawText = ''; + try { + rawText = fs.readFileSync(entry.filePath, 'utf-8'); + } catch (_error) { + continue; + } + + const matchedNeedle = pathNeedles.find((needle) => rawText.includes(needle)); + if (matchedNeedle) { + addSource(entry.filePath, `path:${matchedNeedle}`); + } + } + } + + return Array.from(collected.values()) + .sort((a, b) => { + if (a.firstTimestampMs !== null && b.firstTimestampMs !== null && a.firstTimestampMs !== b.firstTimestampMs) { + return a.firstTimestampMs - b.firstTimestampMs; + } + if (a.jobId && b.jobId && a.jobId !== b.jobId) { + return a.jobId - b.jobId; + } + return a.fileName.localeCompare(b.fileName, undefined, { numeric: true, sensitivity: 'base' }); + }); +} + +function recoverCdJobArtifactsForImport(options = {}) { + const currentRawPath = String(options.currentRawPath || '').trim() || null; + const settings = options.settings && typeof options.settings === 'object' ? options.settings : {}; + const baseMetadata = options.baseMetadata && typeof options.baseMetadata === 'object' + ? options.baseMetadata + : {}; + const rawPathCandidates = Array.isArray(options.rawPathCandidates) + ? options.rawPathCandidates + : []; + const relatedJobIds = Array.isArray(options.relatedJobIds) + ? options.relatedJobIds + : []; + const excludeJobIds = Array.isArray(options.excludeJobIds) + ? options.excludeJobIds + : []; + const logSources = findRelatedJobLogSources({ + rawPathCandidates: [currentRawPath, ...rawPathCandidates], + relatedJobIds, + excludeJobIds + }); + const logLines = logSources.flatMap((source) => source.lines || []); + + let formatFromLogs = null; + let selectedTracksFromLogs = []; + let selectedTracksLogSaysAll = false; + let cdparanoiaCmd = null; + const explicitOutputDirs = []; + const outputFilePathsFromLogs = []; + const seenOutputFilePath = new Set(); + + for (const line of logLines) { + const message = String(line || '').replace(/^\[[^\]]+\]\s+\[[^\]]+\]\s*/, ''); + if (!message) { + continue; + } + + const startMatch = message.match(/CD-Rip gestartet:\s*Format=([a-z0-9]+)\s*,\s*Tracks=(.+)$/i); + if (startMatch) { + formatFromLogs = normalizeCdFormat(startMatch[1]) || formatFromLogs; + const selectedTrackText = String(startMatch[2] || '').trim(); + if (/^alle$/i.test(selectedTrackText)) { + selectedTracksLogSaysAll = true; + selectedTracksFromLogs = []; + } else { + selectedTracksFromLogs = selectedTrackText + .split(',') + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + } + } + + const completionMatch = message.match(/CD-Rip abgeschlossen\.\s*Ausgabe:\s*(.+)$/i); + if (completionMatch) { + const outputDir = normalizeAudioPathToken(completionMatch[1]) || String(completionMatch[1] || '').trim(); + if (outputDir) { + explicitOutputDirs.push(outputDir); + } + } + + if (!cdparanoiaCmd) { + const ripMatch = message.match(/Promptkette \[Rip \d+\/\d+]:\s*(.+)$/i); + if (ripMatch) { + cdparanoiaCmd = extractCommandToken(ripMatch[1]); + } + } + + for (const token of extractAbsolutePathTokens(message)) { + const ext = path.extname(token).toLowerCase(); + if (!CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) { + continue; + } + if (CD_RAW_TRACK_FILE_PATTERN.test(path.basename(token))) { + continue; + } + if (!seenOutputFilePath.has(token)) { + seenOutputFilePath.add(token); + outputFilePathsFromLogs.push(token); + } + } + } + + const outputPath = pickPreferredExistingPath([ + ...(options.fallbackOutputPath ? [options.fallbackOutputPath] : []), + ...explicitOutputDirs, + ...outputFilePathsFromLogs.map((filePath) => path.dirname(filePath)) + ]); + + const outputAudioFiles = outputPath ? collectAudioFilesRecursively(outputPath) : []; + const parsedOutputTracks = assignMissingTrackPositions( + (outputAudioFiles.length > 0 ? outputAudioFiles : outputFilePathsFromLogs) + .map((filePath) => parseCdOutputTrackFromPath(filePath)) + .filter(Boolean) + ); + + const rawTracks = []; + if (currentRawPath && fs.existsSync(currentRawPath)) { + try { + const rawEntries = fs.readdirSync(currentRawPath, { withFileTypes: true }); + for (const entry of rawEntries) { + if (!entry.isFile()) { + continue; + } + const match = entry.name.match(CD_RAW_TRACK_FILE_PATTERN); + if (!match) { + continue; + } + const parsedPosition = Number(match[1]); + if (!Number.isFinite(parsedPosition) || parsedPosition <= 0) { + continue; + } + rawTracks.push({ + position: Math.trunc(parsedPosition), + title: `Track ${Math.trunc(parsedPosition)}`, + artist: null + }); + } + } catch (_error) { + // ignore raw dir read errors during best-effort import recovery + } + } + + const folderMetadata = parseRawFolderMetadata(path.basename(currentRawPath || rawPathCandidates[0] || '')); + const outputFolderMetadata = parseCdFolderMetadataFromOutputDir(outputPath); + const selectedTrackSet = new Set( + selectedTracksFromLogs + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + const trackPositionSet = new Set(); + for (const track of rawTracks) { + trackPositionSet.add(track.position); + } + for (const track of parsedOutputTracks) { + if (Number.isFinite(Number(track?.position)) && Number(track.position) > 0) { + trackPositionSet.add(Math.trunc(Number(track.position))); + } + } + for (const position of selectedTrackSet) { + trackPositionSet.add(position); + } + + const trackPositions = Array.from(trackPositionSet).sort((a, b) => a - b); + const outputTrackByPosition = new Map( + parsedOutputTracks + .filter((track) => Number.isFinite(Number(track?.position)) && Number(track.position) > 0) + .map((track) => [Math.trunc(Number(track.position)), track]) + ); + const rawTrackByPosition = new Map( + rawTracks + .filter((track) => Number.isFinite(Number(track?.position)) && Number(track.position) > 0) + .map((track) => [Math.trunc(Number(track.position)), track]) + ); + + const tracks = trackPositions.map((position) => { + const outputTrack = outputTrackByPosition.get(position) || null; + const rawTrack = rawTrackByPosition.get(position) || null; + return { + position, + title: String(outputTrack?.title || rawTrack?.title || `Track ${position}`).trim() || `Track ${position}`, + artist: String(outputTrack?.artist || rawTrack?.artist || '').trim() || null, + selected: selectedTrackSet.size > 0 ? selectedTrackSet.has(position) : true + }; + }); + + const selectedTracks = selectedTracksLogSaysAll || selectedTrackSet.size === 0 + ? trackPositions + : trackPositions.filter((position) => selectedTrackSet.has(position)); + const artistHints = [ + ...parsedOutputTracks.map((track) => track.artist), + ...tracks.map((track) => track.artist), + baseMetadata.artist, + outputFolderMetadata.artist + ].filter(Boolean); + const formatHints = [ + formatFromLogs, + ...parsedOutputTracks.map((track) => track.format) + ].filter(Boolean); + const inferredArtist = findMostCommonString(artistHints); + const inferredFormat = findMostCommonString(formatHints); + const normalizedYear = Number(baseMetadata.year); + const year = Number.isFinite(normalizedYear) && normalizedYear > 0 + ? Math.trunc(normalizedYear) + : (outputFolderMetadata.year || folderMetadata.year || null); + const title = String( + baseMetadata.title + || baseMetadata.album + || outputFolderMetadata.album + || folderMetadata.title + || '' + ).trim() || null; + const artist = String(baseMetadata.artist || inferredArtist || '').trim() || null; + const selectedMetadata = { + title, + album: title, + artist, + year + }; + const outputTemplate = String(settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim() + || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + const outputPathFromMetadata = findCdOutputDirByMetadata({ + settings, + baseMetadata: selectedMetadata, + outputTemplate + }); + const resolvedOutputPath = outputPath || outputPathFromMetadata || null; + + return { + logSources, + outputPath: resolvedOutputPath, + outputPathExists: Boolean(resolvedOutputPath && fs.existsSync(resolvedOutputPath)), + format: normalizeCdFormat(inferredFormat), + tracks, + selectedTracks, + selectedMetadata, + cdparanoiaCmd: String(cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia', + outputTemplate, + importedLogFiles: logSources.map((source) => ({ + fileName: source.fileName, + jobId: source.jobId, + matchedBy: Array.isArray(source.matchedBy) ? source.matchedBy : [], + lineCount: Array.isArray(source.lines) ? source.lines.length : 0 + })) + }; +} + +function deleteFilesRecursively(rootPath, keepRoot = true) { + const result = { + filesDeleted: 0, + dirsRemoved: 0 + }; + + const visit = (current, isRoot = false) => { + if (!fs.existsSync(current)) { + return; + } + + const stat = fs.lstatSync(current); + if (stat.isDirectory()) { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const abs = path.join(current, entry.name); + if (entry.isDirectory()) { + visit(abs, false); + } else { + fs.unlinkSync(abs); + result.filesDeleted += 1; + } + } + + const remaining = fs.readdirSync(current); + if (remaining.length === 0 && (!isRoot || !keepRoot)) { + fs.rmdirSync(current); + result.dirsRemoved += 1; + } + return; + } + + fs.unlinkSync(current); + result.filesDeleted += 1; + }; + + visit(rootPath, true); + return result; +} + +function collectFilesRecursively(rootPath) { + const normalizedRoot = normalizeComparablePath(rootPath); + if (!normalizedRoot) { + return []; + } + const result = []; + const visit = (currentPath) => { + let stat = null; + try { + stat = fs.lstatSync(currentPath); + } catch (_error) { + return; + } + if (stat.isDirectory()) { + let entries = []; + try { + entries = fs.readdirSync(currentPath, { withFileTypes: true }); + } catch (_error) { + return; + } + for (const entry of entries) { + visit(path.join(currentPath, entry.name)); + } + return; + } + result.push(normalizeComparablePath(currentPath)); + }; + visit(normalizedRoot); + return Array.from(new Set(result.filter(Boolean))).sort((left, right) => left.localeCompare(right, 'de')); +} + +function removeEmptyParentDirectories(startPath, options = {}) { + const start = normalizeComparablePath(startPath); + if (!start) { + return []; + } + const protectedRoots = new Set( + (Array.isArray(options?.protectedRoots) ? options.protectedRoots : []) + .map((entry) => normalizeComparablePath(entry)) + .filter(Boolean) + ); + const allowedRoots = Array.from(protectedRoots); + const removed = []; + const removedSet = new Set(); + + let current = start; + while (current && !isFilesystemRootPath(current)) { + if (protectedRoots.has(current)) { + break; + } + if (allowedRoots.length > 0 && !allowedRoots.some((rootPath) => isPathInside(rootPath, current))) { + break; + } + let stat = null; + try { + stat = fs.lstatSync(current); + } catch (_error) { + break; + } + if (!stat.isDirectory()) { + break; + } + let entries = []; + try { + entries = fs.readdirSync(current); + } catch (_error) { + break; + } + if (entries.length > 0) { + break; + } + try { + fs.rmdirSync(current); + if (!removedSet.has(current)) { + removedSet.add(current); + removed.push(current); + } + } catch (_error) { + break; + } + const parentDir = normalizeComparablePath(path.dirname(current)); + if (!parentDir || parentDir === current) { + break; + } + current = parentDir; + } + + return removed; +} + +function normalizeJobIdValue(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function isSeriesContainerRow(row) { + return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; +} + +function isMultipartContainerRow(row) { + return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container'; +} + +function isDiskContainerRow(row) { + return isSeriesContainerRow(row) || isMultipartContainerRow(row); +} + +function isSeriesChildRow(row) { + return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_child'; +} + +function isMultipartChildRow(row) { + return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child'; +} + +function isMultipartMergeRow(row) { + const jobKind = String(row?.job_kind || '').trim().toLowerCase(); + if (jobKind === 'multipart_movie_merge') { + return true; + } + const encodePlan = parseInfoFromValue(row?.encodePlan ?? row?.encode_plan_json, null); + const handbrakeInfo = parseInfoFromValue(row?.handbrakeInfo ?? row?.handbrake_info_json, null); + const planJobKind = String(encodePlan?.jobKind || '').trim().toLowerCase(); + if (planJobKind === 'multipart_movie_merge') { + return true; + } + const mode = String(encodePlan?.mode || handbrakeInfo?.mode || '').trim().toLowerCase(); + return mode === 'multipart_merge'; +} + +function normalizeArchiveTarget(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'raw') { + return 'raw'; + } + if (raw === 'output' || raw === 'movie' || raw === 'encode') { + return 'output'; + } + return null; +} + +function sanitizeArchiveNamePart(value, fallback = 'job') { + const normalized = String(value || '') + .normalize('NFKD') + .replace(/[^\x00-\x7F]+/g, ''); + const safe = normalized + .replace(/[^A-Za-z0-9._-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^[_-]+|[_-]+$/g, '') + .slice(0, 80); + return safe || fallback; +} + +function buildJobArchiveName(job, target) { + const jobId = normalizeJobIdValue(job?.id) || 'unknown'; + const titlePart = sanitizeArchiveNamePart(job?.title || job?.detected_title || '', 'job'); + const targetPart = target === 'raw' ? 'raw' : 'encode'; + return `job-${jobId}-${titlePart}-${targetPart}.zip`; +} + +function parseSourceJobIdFromPlan(encodePlanRaw) { + const plan = parseInfoFromValue(encodePlanRaw, null); + const sourceJobId = normalizeJobIdValue(plan?.sourceJobId); + return sourceJobId || null; +} + +function extractDiscNumberFromDeleteContext(row) { + const values = [ + row?.raw_path, + row?.title, + row?.detected_title + ]; + for (const value of values) { + const raw = String(value || '').trim(); + if (!raw) continue; + const match = raw.match(/(?:^|\W)D(?:ISC)?\s*[-_ ]?(\d+)(?:\W|$)/i) + || raw.match(/-\s*D(\d+)\s*-\s*RAW/i); + const discNumber = normalizeJobIdValue(match?.[1]); + if (discNumber) { + return discNumber; + } + } + return null; +} + +function resolveDeletePreviewRole(row) { + if (!row || typeof row !== 'object') { + return { roleKey: 'job', roleLabel: 'Job' }; + } + if (isSeriesContainerRow(row) || isMultipartContainerRow(row)) { + return { roleKey: 'container', roleLabel: 'Container' }; + } + if (isMultipartMergeRow(row)) { + return { roleKey: 'merge', roleLabel: 'Merge' }; + } + if (isSeriesChildRow(row) || isMultipartChildRow(row)) { + const discNumber = extractDiscNumberFromDeleteContext(row); + return { + roleKey: 'disc', + roleLabel: discNumber ? `Disk ${discNumber}` : 'Disk/Child' + }; + } + if (normalizeJobIdValue(row?.parent_job_id)) { + return { roleKey: 'child', roleLabel: 'Child-Job' }; + } + return { roleKey: 'job', roleLabel: 'Einzeljob' }; +} + +function parseRetryLinkedJobIdsFromLogLines(lines = []) { + const jobIds = new Set(); + const list = Array.isArray(lines) ? lines : []; + for (const line of list) { + const text = String(line || ''); + if (!text) { + continue; + } + if (!/retry/i.test(text)) { + continue; + } + const regex = /job\s*#(\d+)/ig; + let match = regex.exec(text); + while (match) { + const id = normalizeJobIdValue(match?.[1]); + if (id) { + jobIds.add(id); + } + match = regex.exec(text); + } + } + return Array.from(jobIds); +} + +function normalizeLineageReason(value) { + const normalized = String(value || '').trim(); + return normalized || null; +} + +function inspectDeletionPath(targetPath) { + const normalized = normalizeComparablePath(targetPath); + if (!normalized) { + return { + path: null, + exists: false, + isDirectory: false, + isFile: false + }; + } + try { + const stat = fs.lstatSync(normalized); + return { + path: normalized, + exists: true, + isDirectory: stat.isDirectory(), + isFile: stat.isFile() + }; + } catch (_error) { + return { + path: normalized, + exists: false, + isDirectory: false, + isFile: false + }; + } +} + +function buildJobDisplayTitle(job = null) { + if (!job || typeof job !== 'object') { + return '-'; + } + return String(job.title || job.detected_title || `Job #${job.id || '-'}`).trim() || '-'; +} + +function isHiddenDirectoryName(value) { + return String(value || '').trim().startsWith('.'); +} + +function isFilesystemRootPath(inputPath) { + const raw = String(inputPath || '').trim(); + if (!raw) { + return false; + } + const resolved = normalizeComparablePath(raw); + const parsedRoot = path.parse(resolved).root; + return Boolean(parsedRoot && resolved === normalizeComparablePath(parsedRoot)); +} + +class HistoryService { + async createJob({ + discDevice = null, + status = 'ANALYZING', + detectedTitle = null, + mediaType = null, + jobKind = null + }) { + const db = await getDb(); + const startTime = new Date().toISOString(); + const normalizedMediaType = (() => { + const normalized = normalizeMediaTypeValue(mediaType); + if (normalized) { + return normalized; + } + const legacy = String(mediaType || '').trim().toLowerCase(); + return legacy === 'converter' ? 'converter' : null; + })(); + const normalizedJobKind = normalizeJobKindValue(jobKind); + + const result = await db.run( + ` + INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, media_type, job_kind, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + [discDevice, status, startTime, detectedTitle, status, normalizedMediaType, normalizedJobKind] + ); + logger.info('job:created', { + jobId: result.lastID, + discDevice, + status, + detectedTitle, + mediaType: normalizedMediaType, + jobKind: normalizedJobKind + }); + + return this.getJobById(result.lastID); + } + + async updateJob(jobId, patch) { + const db = await getDb(); + const fields = []; + const values = []; + + for (const [key, value] of Object.entries(patch)) { + fields.push(`${key} = ?`); + values.push(value); + } + + fields.push('updated_at = CURRENT_TIMESTAMP'); + values.push(jobId); + + await db.run(`UPDATE jobs SET ${fields.join(', ')} WHERE id = ?`, values); + logger.debug('job:updated', { jobId, patchKeys: Object.keys(patch) }); + return this.getJobById(jobId, { skipRepair: true }); + } + + async updateJobStatus(jobId, status, extra = {}) { + return this.updateJob(jobId, { + status, + last_state: status, + ...extra + }); + } + + async updateRawPathByOldPath(oldRawPath, newRawPath) { + const db = await getDb(); + const result = await db.run( + 'UPDATE jobs SET raw_path = ?, updated_at = CURRENT_TIMESTAMP WHERE raw_path = ?', + [newRawPath, oldRawPath] + ); + logger.info('job:raw-path-bulk-updated', { oldRawPath, newRawPath, changes: result.changes }); + return result.changes; + } + + async listJobLineageArtifactsByJobIds(jobIds = []) { + const normalizedIds = Array.isArray(jobIds) + ? jobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + if (normalizedIds.length === 0) { + return new Map(); + } + + const db = await getDb(); + const placeholders = normalizedIds.map(() => '?').join(', '); + const rows = await db.all( + ` + SELECT id, job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at + FROM job_lineage_artifacts + WHERE job_id IN (${placeholders}) + ORDER BY id ASC + `, + normalizedIds + ); + + const byJobId = new Map(); + for (const row of rows) { + const ownerJobId = normalizeJobIdValue(row?.job_id); + if (!ownerJobId) { + continue; + } + if (!byJobId.has(ownerJobId)) { + byJobId.set(ownerJobId, []); + } + byJobId.get(ownerJobId).push({ + id: normalizeJobIdValue(row?.id), + jobId: ownerJobId, + sourceJobId: normalizeJobIdValue(row?.source_job_id), + mediaType: normalizeMediaTypeValue(row?.media_type), + rawPath: String(row?.raw_path || '').trim() || null, + outputPath: String(row?.output_path || '').trim() || null, + reason: normalizeLineageReason(row?.reason), + note: String(row?.note || '').trim() || null, + createdAt: String(row?.created_at || '').trim() || null + }); + } + + return byJobId; + } + + async transferJobLineageArtifacts(sourceJobId, replacementJobId, options = {}) { + const fromJobId = normalizeJobIdValue(sourceJobId); + const toJobId = normalizeJobIdValue(replacementJobId); + if (!fromJobId || !toJobId || fromJobId === toJobId) { + const error = new Error('Ungültige Job-IDs für Lineage-Transfer.'); + error.statusCode = 400; + throw error; + } + + const reason = normalizeLineageReason(options?.reason) || 'job_replaced'; + const note = String(options?.note || '').trim() || null; + const sourceJob = await this.getJobById(fromJobId); + if (!sourceJob) { + const error = new Error(`Quell-Job ${fromJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const settings = await settingsService.getSettingsMap(); + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, sourceJob); + const rawPath = String(resolvedPaths?.effectiveRawPath || sourceJob?.raw_path || '').trim() || null; + const outputPath = String(resolvedPaths?.effectiveOutputPath || sourceJob?.output_path || '').trim() || null; + const mediaType = normalizeMediaTypeValue(resolvedPaths?.mediaType) || 'other'; + + const db = await getDb(); + await db.exec('BEGIN'); + try { + await db.run( + ` + INSERT INTO job_lineage_artifacts ( + job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at + ) + SELECT ?, source_job_id, media_type, raw_path, output_path, reason, note, created_at + FROM job_lineage_artifacts + WHERE job_id = ? + `, + [toJobId, fromJobId] + ); + + if (rawPath || outputPath) { + await db.run( + ` + INSERT INTO job_lineage_artifacts ( + job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, + [toJobId, fromJobId, mediaType, rawPath, outputPath, reason, note] + ); + } + + // Preserve known output folders when replacing a job. + await db.run( + ` + INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label, created_at) + SELECT ?, output_path, label, created_at + FROM job_output_folders + WHERE job_id = ? + `, + [toJobId, fromJobId] + ); + if (outputPath) { + await db.run( + 'INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label) VALUES (?, ?, ?)', + [toJobId, outputPath, 'Lineage-Output'] + ); + } + + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + logger.info('job:lineage:transferred', { + sourceJobId: fromJobId, + replacementJobId: toJobId, + mediaType, + reason, + hasRawPath: Boolean(rawPath), + hasOutputPath: Boolean(outputPath) + }); + } + + async retireJobInFavorOf(sourceJobId, replacementJobId, options = {}) { + const fromJobId = normalizeJobIdValue(sourceJobId); + const toJobId = normalizeJobIdValue(replacementJobId); + if (!fromJobId || !toJobId || fromJobId === toJobId) { + const error = new Error('Ungültige Job-IDs für Job-Ersatz.'); + error.statusCode = 400; + throw error; + } + + const reason = normalizeLineageReason(options?.reason) || 'job_replaced'; + const note = String(options?.note || '').trim() || null; + + await this.transferJobLineageArtifacts(fromJobId, toJobId, { reason, note }); + + const db = await getDb(); + const pipelineRow = await db.get('SELECT active_job_id FROM pipeline_state WHERE id = 1'); + const activeJobId = normalizeJobIdValue(pipelineRow?.active_job_id); + const sourceIsActive = activeJobId === fromJobId; + + await db.exec('BEGIN'); + try { + if (sourceIsActive) { + await db.run( + ` + UPDATE pipeline_state + SET active_job_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `, + [toJobId] + ); + } else { + await db.run( + ` + UPDATE pipeline_state + SET active_job_id = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 AND active_job_id = ? + `, + [fromJobId] + ); + } + + await db.run('DELETE FROM jobs WHERE id = ?', [fromJobId]); + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + await this.closeProcessLog(fromJobId); + const archivedProcessLogPath = this._archiveProcessLogFile(fromJobId, { + replacementJobId: toJobId, + reason + }); + if (archivedProcessLogPath) { + this.appendProcessLog( + toJobId, + 'SYSTEM', + `Vorheriger Prozess-Log von Job #${fromJobId} archiviert: ${archivedProcessLogPath}` + ); + } + + logger.warn('job:retired', { + sourceJobId: fromJobId, + replacementJobId: toJobId, + reason, + sourceWasActive: sourceIsActive + }); + + return { + retired: true, + sourceJobId: fromJobId, + replacementJobId: toJobId, + reason, + archivedProcessLogPath: archivedProcessLogPath || null + }; + } + + async appendLog(jobId, source, message) { + this.appendProcessLog(jobId, source, message); + } + + async cacheAndPromoteExternalPoster(jobId, posterUrl, options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + const normalizedPosterUrl = String(posterUrl || '').trim(); + const logFailures = options?.logFailures !== false; + const sourceLabel = String(options?.source || 'Poster').trim() || 'Poster'; + const logPrefix = `${sourceLabel} Link`; + + if (!normalizedJobId) { + return { ok: false, reason: 'invalid_job_id', localUrl: null }; + } + if (!normalizedPosterUrl || thumbnailService.isLocalUrl(normalizedPosterUrl)) { + return { ok: false, reason: 'no_external_url', localUrl: null }; + } + + const cacheResult = await thumbnailService.cacheJobThumbnailDetailed(normalizedJobId, normalizedPosterUrl); + if (!cacheResult?.ok) { + if (logFailures) { + await this.appendLog( + normalizedJobId, + 'SYSTEM', + `${logPrefix} konnte nicht heruntergeladen werden: ${normalizedPosterUrl}${cacheResult?.error ? ` (${cacheResult.error})` : ''}` + ); + } + return { + ok: false, + reason: 'cache_failed', + localUrl: null, + error: cacheResult?.error || null + }; + } + + const promotedUrl = thumbnailService.promoteJobThumbnail(normalizedJobId); + if (!promotedUrl) { + if (logFailures) { + await this.appendLog( + normalizedJobId, + 'SYSTEM', + `${logPrefix} konnte nicht finalisiert werden: ${normalizedPosterUrl}` + ); + } + return { ok: false, reason: 'promote_failed', localUrl: null }; + } + + try { + await this.updateJob(normalizedJobId, { poster_url: promotedUrl }); + return { ok: true, reason: 'ok', localUrl: promotedUrl, sourceUrl: normalizedPosterUrl }; + } catch (error) { + logger.warn('thumbnail:update-job-failed', { + jobId: normalizedJobId, + posterUrl: normalizedPosterUrl, + promotedUrl, + error: error?.message || String(error) + }); + if (logFailures) { + await this.appendLog( + normalizedJobId, + 'SYSTEM', + `${logPrefix} wurde heruntergeladen, konnte aber nicht am Job gespeichert werden: ${normalizedPosterUrl}` + ); + } + return { + ok: false, + reason: 'update_failed', + localUrl: null, + error: error?.message || String(error) + }; + } + } + + queuePosterCache(jobId, posterUrl, options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + return; + } + this.cacheAndPromoteExternalPoster(normalizedJobId, posterUrl, options).catch((error) => { + logger.warn('thumbnail:queue-cache-failed', { + jobId: normalizedJobId, + posterUrl: String(posterUrl || '').trim() || null, + error: error?.message || String(error) + }); + }); + } + + appendProcessLog(jobId, source, message) { + const filePath = toProcessLogPath(jobId); + const streamKey = toProcessLogStreamKey(jobId); + if (!filePath || !streamKey) { + return; + } + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + let stream = processLogStreams.get(streamKey); + if (!stream) { + stream = fs.createWriteStream(filePath, { + flags: 'a', + encoding: 'utf-8' + }); + stream.on('error', (error) => { + logger.warn('job:process-log:stream-error', { + jobId, + source, + error: error?.message || String(error) + }); + }); + processLogStreams.set(streamKey, stream); + } + const line = `[${getServerTimestamp()}] [${source}] ${String(message || '')}\n`; + stream.write(line); + } catch (error) { + logger.warn('job:process-log:append-failed', { + jobId, + source, + error: error?.message || String(error) + }); + } + } + + async closeProcessLog(jobId) { + const streamKey = toProcessLogStreamKey(jobId); + if (!streamKey) { + return; + } + const stream = processLogStreams.get(streamKey); + if (!stream) { + return; + } + processLogStreams.delete(streamKey); + await new Promise((resolve) => { + stream.end(resolve); + }); + } + + async resetProcessLog(jobId) { + await this.closeProcessLog(jobId); + const filePath = toProcessLogPath(jobId); + if (!filePath || !fs.existsSync(filePath)) { + return; + } + try { + fs.unlinkSync(filePath); + } catch (error) { + logger.warn('job:process-log:reset-failed', { + jobId, + path: filePath, + error: error?.message || String(error) + }); + } + } + + async readProcessLogLines(jobId, options = {}) { + const includeAll = Boolean(options.includeAll); + const parsedTail = Number(options.tailLines); + const tailLines = Number.isFinite(parsedTail) && parsedTail > 0 + ? Math.trunc(parsedTail) + : 800; + const filePath = toProcessLogPath(jobId); + if (!filePath || !fs.existsSync(filePath)) { + return { + exists: false, + lines: [], + returned: 0, + total: 0, + truncated: false + }; + } + + if (includeAll) { + const raw = await fs.promises.readFile(filePath, 'utf-8'); + const lines = String(raw || '') + .split(/\r\n|\n|\r/) + .filter((line) => line.length > 0); + return { + exists: true, + lines, + returned: lines.length, + total: lines.length, + truncated: false + }; + } + + const stat = await fs.promises.stat(filePath); + if (!stat.isFile() || stat.size <= 0) { + return { + exists: true, + lines: [], + returned: 0, + total: 0, + truncated: false + }; + } + + const readBytes = Math.min(stat.size, PROCESS_LOG_TAIL_MAX_BYTES); + const start = Math.max(0, stat.size - readBytes); + const handle = await fs.promises.open(filePath, 'r'); + let buffer = Buffer.alloc(0); + try { + buffer = Buffer.alloc(readBytes); + const { bytesRead } = await handle.read(buffer, 0, readBytes, start); + buffer = buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } + + let text = buffer.toString('utf-8'); + if (start > 0) { + const parts = text.split(/\r\n|\n|\r/); + parts.shift(); + text = parts.join('\n'); + } + + let lines = text.split(/\r\n|\n|\r/).filter((line) => line.length > 0); + let truncated = start > 0; + if (lines.length > tailLines) { + lines = lines.slice(-tailLines); + truncated = true; + } + + return { + exists: true, + lines, + returned: lines.length, + total: lines.length, + truncated + }; + } + + buildRecoveredCdEncodePlan(recovery = {}) { + const tracks = Array.isArray(recovery?.tracks) ? recovery.tracks : []; + const selectedTracks = Array.isArray(recovery?.selectedTracks) ? recovery.selectedTracks : []; + const format = normalizeCdFormat(recovery?.format) || null; + const outputTemplate = String(recovery?.outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim() + || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + + if (tracks.length === 0 && selectedTracks.length === 0 && !format) { + return null; + } + + return { + format, + formatOptions: {}, + selectedTracks: selectedTracks.length > 0 + ? selectedTracks + : tracks + .map((track) => Number(track?.position)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)), + tracks, + outputTemplate, + recoveredFrom: { + source: 'orphan_raw_import', + importedLogFiles: Array.isArray(recovery?.importedLogFiles) ? recovery.importedLogFiles : [], + outputPath: recovery?.outputPath || null, + outputPathExists: Boolean(recovery?.outputPathExists) + } + }; + } + + buildOrphanRawImportMakemkvInfo(options = {}) { + const mediaProfile = normalizeMediaTypeValue(options.mediaProfile) || 'other'; + const importedAt = String(options.importedAt || new Date().toISOString()).trim() || new Date().toISOString(); + const rawPath = String(options.rawPath || '').trim() || null; + const stripRecoveryMetadata = Boolean(options.stripRecoveryMetadata); + const existingInfo = options.existingInfo && typeof options.existingInfo === 'object' + ? options.existingInfo + : {}; + const recovery = options.recovery && typeof options.recovery === 'object' + ? options.recovery + : null; + const analyzeContextPatch = options.analyzeContextPatch && typeof options.analyzeContextPatch === 'object' + ? options.analyzeContextPatch + : {}; + const providedSelectedMetadata = options.selectedMetadata && typeof options.selectedMetadata === 'object' + ? options.selectedMetadata + : {}; + const compactMetadataObject = (input) => Object.fromEntries( + Object.entries(input || {}).filter(([, value]) => { + if (value === null || value === undefined) { + return false; + } + if (typeof value === 'string') { + return value.trim().length > 0; + } + return true; + }) + ); + const existingSelectedMetadata = compactMetadataObject(extractSelectedMetadataFromMakemkvInfo(existingInfo)); + const normalizedProvidedSelectedMetadata = compactMetadataObject(providedSelectedMetadata); + const mergedSelectedMetadata = { + ...existingSelectedMetadata, + ...normalizedProvidedSelectedMetadata + }; + const existingAnalyzeContext = existingInfo.analyzeContext && typeof existingInfo.analyzeContext === 'object' + ? existingInfo.analyzeContext + : {}; + const previousImportContext = existingInfo.importContext && typeof existingInfo.importContext === 'object' + ? existingInfo.importContext + : {}; + const nextImportContext = { + ...previousImportContext, + requestedRawPath: String(options.requestedRawPath || previousImportContext.requestedRawPath || rawPath || '').trim() || null, + sourceFolderJobId: normalizeJobIdValue( + options.sourceFolderJobId + ?? previousImportContext.sourceFolderJobId + ?? null + ) + }; + + if (recovery?.outputPath) { + nextImportContext.recoveredOutputPath = recovery.outputPath; + } + if (Array.isArray(recovery?.importedLogFiles) && recovery.importedLogFiles.length > 0) { + nextImportContext.importedLogFiles = recovery.importedLogFiles; + } + + const nextInfo = { + ...existingInfo, + status: 'SUCCESS', + source: 'orphan_raw_import', + importedAt, + rawPath, + mediaProfile, + analyzeContext: { + ...existingAnalyzeContext, + ...analyzeContextPatch, + mediaProfile + }, + importContext: nextImportContext + }; + + if (Object.keys(mergedSelectedMetadata).length > 0) { + nextInfo.selectedMetadata = mergedSelectedMetadata; + nextInfo.analyzeContext = { + ...nextInfo.analyzeContext, + selectedMetadata: { + ...(existingAnalyzeContext.selectedMetadata && typeof existingAnalyzeContext.selectedMetadata === 'object' + ? existingAnalyzeContext.selectedMetadata + : {}), + ...mergedSelectedMetadata + } + }; + } + + if (mediaProfile === 'cd' && recovery) { + if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) { + nextInfo.tracks = recovery.tracks; + } + if (!stripRecoveryMetadata && recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') { + const recoverySelectedMetadata = compactMetadataObject(recovery.selectedMetadata); + nextInfo.selectedMetadata = { + ...(nextInfo.selectedMetadata && typeof nextInfo.selectedMetadata === 'object' + ? nextInfo.selectedMetadata + : {}), + ...recoverySelectedMetadata + }; + nextInfo.analyzeContext = { + ...nextInfo.analyzeContext, + selectedMetadata: { + ...(nextInfo.analyzeContext?.selectedMetadata && typeof nextInfo.analyzeContext.selectedMetadata === 'object' + ? nextInfo.analyzeContext.selectedMetadata + : {}), + ...nextInfo.selectedMetadata + } + }; + } + nextInfo.cdparanoiaCmd = String( + recovery.cdparanoiaCmd + || existingInfo.cdparanoiaCmd + || 'cdparanoia' + ).trim() || 'cdparanoia'; + nextInfo.importRecovery = { + source: 'orphan_raw_import', + importedAt, + logFiles: Array.isArray(recovery.importedLogFiles) ? recovery.importedLogFiles : [], + outputPath: recovery.outputPath || null, + outputPathExists: Boolean(recovery.outputPathExists), + recoveredFormat: normalizeCdFormat(recovery.format), + selectedTracks: Array.isArray(recovery.selectedTracks) ? recovery.selectedTracks : [] + }; + } + + return nextInfo; + } + + async restoreImportedProcessLog(jobId, logSources = [], options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + return { + imported: false, + sourceCount: 0, + lineCount: 0 + }; + } + + const normalizedSources = (Array.isArray(logSources) ? logSources : []) + .filter((source) => source && source.filePath && Array.isArray(source.lines) && source.lines.length > 0); + const importInfo = options.importInfo && typeof options.importInfo === 'object' + ? options.importInfo + : null; + const shouldAppend = Boolean(options.append); + if (normalizedSources.length === 0 && !importInfo) { + return { + imported: false, + sourceCount: 0, + lineCount: 0 + }; + } + + const filePath = toProcessLogPath(normalizedJobId); + if (!filePath) { + return { + imported: false, + sourceCount: 0, + lineCount: 0 + }; + } + + await this.closeProcessLog(normalizedJobId); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (!shouldAppend) { + try { + fs.unlinkSync(filePath); + } catch (_error) { + // ignore missing file on overwrite + } + } + + const lines = []; + if (normalizedSources.length > 0) { + const fileSummary = normalizedSources + .map((source) => source.fileName || path.basename(source.filePath)) + .filter(Boolean) + .join(', '); + lines.push( + `[${getServerTimestamp()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}` + ); + for (const source of normalizedSources) { + lines.push(...source.lines); + } + } + if (importInfo) { + lines.push( + `[${getServerTimestamp()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}` + ); + } + + if (lines.length === 0) { + return { + imported: false, + sourceCount: normalizedSources.length, + lineCount: 0 + }; + } + + try { + const prefix = shouldAppend && fs.existsSync(filePath) && fs.statSync(filePath).size > 0 ? '\n' : ''; + fs.writeFileSync(filePath, `${prefix}${lines.join('\n')}\n`, { + encoding: 'utf-8', + flag: shouldAppend ? 'a' : 'w' + }); + } catch (error) { + logger.warn('job:process-log:restore-failed', { + jobId: normalizedJobId, + path: filePath, + error: error?.message || String(error) + }); + return { + imported: false, + sourceCount: normalizedSources.length, + lineCount: 0 + }; + } + + return { + imported: normalizedSources.length > 0, + sourceCount: normalizedSources.length, + lineCount: lines.length + }; + } + + async resolveOrphanImportSourceJob(options = {}) { + const candidateJobIds = Array.isArray(options.candidateJobIds) + ? options.candidateJobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + const desiredMediaProfile = normalizeMediaTypeValue(options.mediaProfile) || null; + if (candidateJobIds.length === 0) { + return null; + } + + const uniqueCandidateIds = Array.from(new Set(candidateJobIds)); + const db = await getDb(); + const placeholders = uniqueCandidateIds.map(() => '?').join(', '); + const rows = await db.all( + `SELECT * FROM jobs WHERE id IN (${placeholders})`, + uniqueCandidateIds + ); + if (!Array.isArray(rows) || rows.length === 0) { + return null; + } + + const byId = new Map(rows.map((row) => [normalizeJobIdValue(row?.id), row])); + let best = null; + let bestScore = -1; + + for (const candidateId of uniqueCandidateIds) { + const row = byId.get(candidateId); + if (!row) { + continue; + } + + const makemkvInfo = parseJsonSafe(row.makemkv_info_json, {}); + const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(makemkvInfo); + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const rowMediaProfile = normalizeMediaTypeValue( + makemkvInfo?.analyzeContext?.mediaProfile + || makemkvInfo?.mediaProfile + || inferMediaTypeFromJobKind(row?.job_kind) + || row?.media_type + ); + const posterCandidate = String( + row?.poster_url + || selectedMetadata?.coverUrl + || selectedMetadata?.poster + || selectedMetadata?.posterUrl + || '' + ).trim() || null; + const hasTitle = Boolean(String( + selectedMetadata?.title + || selectedMetadata?.album + || row?.title + || row?.detected_title + || '' + ).trim()); + const hasArtist = Boolean(String(selectedMetadata?.artist || '').trim()); + const hasPoster = Boolean(posterCandidate); + const hasExternalMetadata = Boolean( + row?.imdb_id + || selectedMetadata?.tmdbId + || analyzeContext?.tmdbId + || selectedMetadata?.mbId + || selectedMetadata?.musicBrainzId + || selectedMetadata?.musicbrainzId + || selectedMetadata?.musicbrainz_id + || selectedMetadata?.music_brainz_id + || selectedMetadata?.musicbrainz + || selectedMetadata?.mbid + ); + const isFinished = String(row?.status || row?.last_state || '').trim().toUpperCase() === 'FINISHED'; + + let score = 0; + if (desiredMediaProfile && rowMediaProfile === desiredMediaProfile) { + score += 10; + } + if (hasTitle) { + score += 6; + } + if (desiredMediaProfile === 'cd' && hasArtist) { + score += 4; + } + if (hasPoster) { + score += 3; + } + if (hasExternalMetadata) { + score += 2; + } + if (isFinished) { + score += 1; + } + + if (score > bestScore) { + bestScore = score; + best = { + job: row, + makemkvInfo, + selectedMetadata, + analyzeContext, + mediaProfile: rowMediaProfile, + posterCandidate + }; + } + } + + return best; + } + + getPreferredPosterCandidateForImport(sourceContext = null, explicitPosterUrl = null) { + const explicit = String(explicitPosterUrl || '').trim() || null; + if (explicit) { + return explicit; + } + const sourceJob = sourceContext?.job && typeof sourceContext.job === 'object' + ? sourceContext.job + : null; + const selectedMetadata = sourceContext?.selectedMetadata && typeof sourceContext.selectedMetadata === 'object' + ? sourceContext.selectedMetadata + : {}; + return String( + sourceJob?.poster_url + || selectedMetadata?.coverUrl + || selectedMetadata?.poster + || selectedMetadata?.posterUrl + || '' + ).trim() || null; + } + + async restoreImportedPoster(jobId, sourceContext = null, explicitPosterUrl = null) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + return null; + } + + const sourceJob = sourceContext?.job && typeof sourceContext.job === 'object' + ? sourceContext.job + : null; + const sourceJobId = normalizeJobIdValue(sourceJob?.id); + const sourcePosterUrl = String(sourceJob?.poster_url || '').trim() || null; + if (sourceJobId && sourcePosterUrl && thumbnailService.isLocalUrl(sourcePosterUrl)) { + const copiedUrl = thumbnailService.copyThumbnail(sourceJobId, normalizedJobId); + if (copiedUrl) { + await this.updateJob(normalizedJobId, { poster_url: copiedUrl }); + return copiedUrl; + } + } + + const preferredPosterUrl = this.getPreferredPosterCandidateForImport(sourceContext, explicitPosterUrl); + if (!preferredPosterUrl || thumbnailService.isLocalUrl(preferredPosterUrl)) { + return null; + } + + const result = await this.cacheAndPromoteExternalPoster(normalizedJobId, preferredPosterUrl, { + source: 'Coverart', + logFailures: true + }); + if (!result?.ok || !result.localUrl) { + return null; + } + return result.localUrl; + } + + async repairImportedCdJobArtifacts(job, settings = null) { + if (!job || typeof job !== 'object') { + return job; + } + + const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null); + const mediaProfile = normalizeMediaTypeValue( + makemkvInfo?.mediaProfile + || makemkvInfo?.analyzeContext?.mediaProfile + || inferMediaTypeFromJobKind(job?.job_kind) + || job?.media_type + ); + if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import' || mediaProfile !== 'cd') { + return job; + } + + const hasTracks = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0; + const hasEncodePlan = Boolean(String(job?.encode_plan_json || '').trim()); + const outputPathRaw = String(job?.output_path || '').trim(); + const hasOutputPath = Boolean(outputPathRaw); + const outputPathExists = hasOutputPath && (() => { + try { + return fs.existsSync(outputPathRaw); + } catch (_error) { + return false; + } + })(); + const hasExistingLog = hasProcessLogFile(job.id); + if (hasTracks && hasEncodePlan && hasOutputPath && outputPathExists && hasExistingLog) { + return job; + } + + const importContext = makemkvInfo?.importContext && typeof makemkvInfo.importContext === 'object' + ? makemkvInfo.importContext + : {}; + const rawPathCandidates = [ + job?.raw_path, + makemkvInfo?.rawPath, + importContext?.requestedRawPath, + importContext?.originalRawPath + ].filter(Boolean); + const currentRawPath = String(job?.raw_path || makemkvInfo?.rawPath || '').trim() || null; + const rawFolderMeta = parseRawFolderMetadata(path.basename(currentRawPath || rawPathCandidates[0] || '')); + const recovery = recoverCdJobArtifactsForImport({ + currentRawPath, + rawPathCandidates, + relatedJobIds: [ + importContext?.sourceFolderJobId, + rawFolderMeta.folderJobId + ], + excludeJobIds: [], + settings: settings || {}, + baseMetadata: { + title: job?.title || makemkvInfo?.selectedMetadata?.title || null, + album: makemkvInfo?.selectedMetadata?.album || job?.title || null, + artist: makemkvInfo?.selectedMetadata?.artist || null, + year: makemkvInfo?.selectedMetadata?.year || job?.year || null + }, + fallbackOutputPath: job?.output_path || null + }); + const sourceJobContext = await this.resolveOrphanImportSourceJob({ + candidateJobIds: [ + importContext?.sourceFolderJobId, + rawFolderMeta.folderJobId, + ...recovery.logSources.map((source) => source?.jobId) + ], + mediaProfile: 'cd' + }); + const sourceJob = sourceJobContext?.job || null; + const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' + ? sourceJobContext.selectedMetadata + : {}; + const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null); + + const recoveryHasData = recovery.outputPath || recovery.logSources.length > 0 || recovery.tracks.length > 0; + if (!recoveryHasData && !sourceJob) { + return job; + } + + const importedAt = String(makemkvInfo?.importedAt || job?.end_time || new Date().toISOString()).trim() + || new Date().toISOString(); + const nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt, + rawPath: currentRawPath, + requestedRawPath: importContext?.requestedRawPath || currentRawPath, + sourceFolderJobId: importContext?.sourceFolderJobId || rawFolderMeta.folderJobId || null, + mediaProfile: 'cd', + existingInfo: makemkvInfo, + recovery + }); + const nextEncodePlan = this.buildRecoveredCdEncodePlan(recovery); + const patch = {}; + const nextMakemkvInfoJson = JSON.stringify(nextMakemkvInfo); + + if (nextMakemkvInfoJson !== String(job?.makemkv_info_json || '')) { + patch.makemkv_info_json = nextMakemkvInfoJson; + } + if (!hasEncodePlan && nextEncodePlan) { + patch.encode_plan_json = JSON.stringify(nextEncodePlan); + } + const recoveredOutputPath = String(recovery.outputPath || '').trim() || null; + const normalizedOutputPath = hasOutputPath ? normalizeComparablePath(outputPathRaw) : null; + const normalizedRecoveredOutputPath = recoveredOutputPath ? normalizeComparablePath(recoveredOutputPath) : null; + const recoveredOutputPathExists = Boolean(recovery.outputPathExists); + const shouldPatchOutputPath = Boolean( + recoveredOutputPath + && ( + !hasOutputPath + || ( + !outputPathExists + && ( + recoveredOutputPathExists + || recovery.logSources.length > 0 + ) + ) + ) + && normalizedRecoveredOutputPath !== normalizedOutputPath + ); + if (shouldPatchOutputPath) { + patch.output_path = recoveredOutputPath; + } + if (!job?.title && nextMakemkvInfo?.selectedMetadata?.title) { + patch.title = nextMakemkvInfo.selectedMetadata.title; + } else if (!job?.title && (sourceSelectedMetadata?.title || sourceSelectedMetadata?.album || sourceJob?.title)) { + patch.title = sourceSelectedMetadata.title || sourceSelectedMetadata.album || sourceJob.title; + } + if (!job?.year && nextMakemkvInfo?.selectedMetadata?.year) { + patch.year = nextMakemkvInfo.selectedMetadata.year; + } else if (!job?.year && (sourceSelectedMetadata?.year || sourceJob?.year)) { + patch.year = sourceSelectedMetadata.year ?? sourceJob.year ?? null; + } + if (!job?.imdb_id) { + const recoveredMbId = String( + sourceSelectedMetadata?.mbId + || sourceSelectedMetadata?.musicBrainzId + || sourceSelectedMetadata?.musicbrainzId + || sourceSelectedMetadata?.musicbrainz_id + || sourceSelectedMetadata?.music_brainz_id + || sourceSelectedMetadata?.musicbrainz + || sourceSelectedMetadata?.mbid + || sourceJob?.imdb_id + || '' + ).trim() || null; + if (recoveredMbId) { + patch.imdb_id = recoveredMbId; + } + } + if (!job?.poster_url && sourcePosterCandidate && !thumbnailService.isLocalUrl(sourcePosterCandidate)) { + patch.poster_url = sourcePosterCandidate; + } + + if (!hasExistingLog && recovery.logSources.length > 0) { + await this.restoreImportedProcessLog(job.id, recovery.logSources, { + importInfo: nextMakemkvInfo + }); + } + + let updatedJob = job; + if (Object.keys(patch).length > 0) { + updatedJob = await this.updateJob(job.id, patch); + } + + if (!job?.poster_url && sourcePosterCandidate) { + await this.restoreImportedPoster(job.id, sourceJobContext, sourcePosterCandidate); + updatedJob = await this.getJobById(job.id); + } + + return updatedJob; + } + + async repairImportedOrphanJobClassification(job, settings = null) { + if (!job || typeof job !== 'object') { + return job; + } + + const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null); + if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import') { + return job; + } + + const settingsMap = settings && typeof settings === 'object' + ? settings + : {}; + const resolvedRawPath = String( + job?.raw_path + || makemkvInfo?.rawPath + || '' + ).trim() || null; + const folderMeta = parseRawFolderMetadata(path.basename(String(resolvedRawPath || '').trim())); + const seriesRawPathHint = isLikelySeriesRawPath(resolvedRawPath, settingsMap); + const detectedMediaType = detectOrphanMediaType(resolvedRawPath, { seriesRawPathHint }); + const mkMediaType = normalizeMediaTypeValue( + makemkvInfo?.analyzeContext?.mediaProfile + || makemkvInfo?.mediaProfile + || inferMediaTypeFromJobKind(job?.job_kind) + || job?.media_type + ); + const effectiveMediaType = detectedMediaType === 'other' + ? (mkMediaType || (seriesRawPathHint ? 'dvd' : 'other')) + : detectedMediaType; + const patch = {}; + + if (['bluray', 'dvd', 'cd', 'audiobook'].includes(effectiveMediaType)) { + if (normalizeMediaTypeValue(job?.media_type) !== effectiveMediaType) { + patch.media_type = effectiveMediaType; + } + if (normalizeJobKindValue(job?.job_kind) !== effectiveMediaType) { + patch.job_kind = effectiveMediaType; + } + } + + let nextMakemkvInfo = makemkvInfo && typeof makemkvInfo === 'object' + ? makemkvInfo + : {}; + const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(nextMakemkvInfo); + const analyzeContext = nextMakemkvInfo?.analyzeContext && typeof nextMakemkvInfo.analyzeContext === 'object' + ? nextMakemkvInfo.analyzeContext + : {}; + const hasSeriesSignals = hasSeriesMetadataSignals(selectedMetadata, analyzeContext); + + if (effectiveMediaType === 'dvd' && seriesRawPathHint && !hasSeriesSignals) { + const seriesImportHints = buildSeriesImportHints({ + folderName: path.basename(String(resolvedRawPath || '').trim()), + rawPath: resolvedRawPath, + metadata: folderMeta, + sourceSelectedMetadata: selectedMetadata, + sourceAnalyzeContext: analyzeContext + }); + if (seriesImportHints) { + nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt: nextMakemkvInfo?.importedAt || job?.end_time || new Date().toISOString(), + rawPath: resolvedRawPath, + requestedRawPath: nextMakemkvInfo?.importContext?.requestedRawPath || resolvedRawPath, + sourceFolderJobId: nextMakemkvInfo?.importContext?.sourceFolderJobId || folderMeta.folderJobId || null, + mediaProfile: effectiveMediaType, + existingInfo: nextMakemkvInfo, + selectedMetadata: seriesImportHints.selectedMetadata, + analyzeContextPatch: seriesImportHints.analyzeContextPatch + }); + } + } else if ( + effectiveMediaType !== 'other' + && normalizeMediaTypeValue(nextMakemkvInfo?.analyzeContext?.mediaProfile || nextMakemkvInfo?.mediaProfile) !== effectiveMediaType + ) { + nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt: nextMakemkvInfo?.importedAt || job?.end_time || new Date().toISOString(), + rawPath: resolvedRawPath, + requestedRawPath: nextMakemkvInfo?.importContext?.requestedRawPath || resolvedRawPath, + sourceFolderJobId: nextMakemkvInfo?.importContext?.sourceFolderJobId || folderMeta.folderJobId || null, + mediaProfile: effectiveMediaType, + existingInfo: nextMakemkvInfo + }); + } + + const nextMakemkvInfoJson = JSON.stringify(nextMakemkvInfo || {}); + if (nextMakemkvInfoJson !== String(job?.makemkv_info_json || '')) { + patch.makemkv_info_json = nextMakemkvInfoJson; + } + + if (Object.keys(patch).length === 0) { + return job; + } + return this.updateJob(job.id, patch); + } + + async getJobById(jobId, options = {}) { + const db = await getDb(); + const row = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]); + if (!row || options?.skipRepair) { + return row; + } + const settings = options?.settings && typeof options.settings === 'object' + ? options.settings + : await settingsService.getSettingsMap(); + return this.repairImportedOrphanJobClassification(row, settings); + } + + async getJobs(filters = {}) { + const db = await getDb(); + const where = []; + const values = []; + const includeFsChecks = filters?.includeFsChecks !== false; + const includeChildren = filters?.includeChildren === true; + const rawStatuses = Array.isArray(filters?.statuses) + ? filters.statuses + : (typeof filters?.statuses === 'string' + ? String(filters.statuses).split(',') + : []); + const normalizedStatuses = rawStatuses + .map((value) => String(value || '').trim().toUpperCase()) + .filter(Boolean); + const limitRaw = Number(filters?.limit); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.trunc(limitRaw), 500) + : 500; + + if (normalizedStatuses.length > 0) { + const placeholders = normalizedStatuses.map(() => '?').join(', '); + where.push(`status IN (${placeholders})`); + values.push(...normalizedStatuses); + } else if (filters.status) { + where.push('status = ?'); + values.push(filters.status); + } + + if (filters.search) { + where.push('(title LIKE ? OR imdb_id LIKE ? OR detected_title LIKE ? OR makemkv_info_json LIKE ?)'); + values.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`); + } + + if (!includeChildren) { + where.push(`parent_job_id IS NULL`); + } + + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + const [jobs, settings] = await Promise.all([ + db.all( + ` + SELECT j.* + FROM jobs j + ${whereClause} + ORDER BY COALESCE(j.updated_at, j.created_at) DESC, j.id DESC + LIMIT ${limit} + `, + values + ), + settingsService.getSettingsMap() + ]); + + const repairedJobs = await Promise.all( + jobs.map((job) => this.repairImportedOrphanJobClassification(job, settings)) + ); + const derivedParentIds = new Set( + repairedJobs + .map((job) => normalizeJobIdValue(job?.parent_job_id)) + .filter(Boolean) + ); + const adjustedJobs = repairedJobs.map((job) => { + const normalizedId = normalizeJobIdValue(job?.id); + const hasParent = normalizeJobIdValue(job?.parent_job_id) !== null; + const jobKind = String(job?.job_kind || '').trim().toLowerCase(); + if (hasParent && !jobKind) { + return { ...job, job_kind: 'dvd_series_child' }; + } + if (!hasParent && normalizedId && derivedParentIds.has(normalizedId) && !jobKind) { + return { ...job, job_kind: 'dvd_series_container' }; + } + return job; + }); + + const containerJobs = adjustedJobs.filter((job) => isDiskContainerRow(job)); + const containerIds = containerJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); + const seriesCandidates = adjustedJobs.filter((job) => { + const mkInfo = parseJsonSafe(job?.makemkv_info_json, {}); + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const selected = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; + const tmdbId = Number(selected?.tmdbId || 0) || null; + const seasonNumber = Number(selected?.seasonNumber || 0) || null; + return (tmdbId && seasonNumber) || hasSeriesMetadataSignals(selected, analyzeContext); + }); + const seriesCandidateIds = seriesCandidates.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); + let childRows = []; + let nestedChildRows = []; + let outputFolders = []; + if (containerIds.length > 0) { + const placeholders = containerIds.map(() => '?').join(', '); + childRows = await db.all( + `SELECT id, parent_job_id, output_path, raw_path, handbrake_info_json, status, encode_plan_json, makemkv_info_json, rip_successful FROM jobs WHERE parent_job_id IN (${placeholders})`, + containerIds + ); + const childIds = childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean); + if (childIds.length > 0) { + const childPlaceholders = childIds.map(() => '?').join(', '); + nestedChildRows = await db.all( + `SELECT id, parent_job_id, output_path, raw_path, handbrake_info_json, status, encode_plan_json, makemkv_info_json, rip_successful FROM jobs WHERE parent_job_id IN (${childPlaceholders})`, + childIds + ); + } + const summaryJobIds = Array.from(new Set([ + ...childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean), + ...nestedChildRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean) + ])); + if (summaryJobIds.length > 0) { + const childPlaceholders = summaryJobIds.map(() => '?').join(', '); + outputFolders = await db.all( + `SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${childPlaceholders})`, + summaryJobIds + ); + } + } + + const allContainerChildRows = [...childRows, ...nestedChildRows]; + const directDiskRowsByContainerId = new Map(); + const directEpisodeRowsByContainerId = new Map(); + const directChildRowById = new Map(); + for (const row of childRows) { + const containerId = normalizeJobIdValue(row?.parent_job_id); + const isEpisodeSubJob = isSeriesBatchEpisodeSubJobRow(row); + if (containerId) { + const map = isEpisodeSubJob + ? directEpisodeRowsByContainerId + : directDiskRowsByContainerId; + if (!map.has(containerId)) { + map.set(containerId, []); + } + map.get(containerId).push(row); + } + if (isEpisodeSubJob) { + continue; + } + const childId = normalizeJobIdValue(row?.id); + if (childId) { + directChildRowById.set(childId, row); + } + } + const nestedRowsByParentId = new Map(); + for (const row of nestedChildRows) { + const parentId = normalizeJobIdValue(row?.parent_job_id); + if (!parentId) { + continue; + } + if (!nestedRowsByParentId.has(parentId)) { + nestedRowsByParentId.set(parentId, []); + } + nestedRowsByParentId.get(parentId).push(row); + } + + const childOutputMap = new Map(); + for (const child of allContainerChildRows) { + const childId = normalizeJobIdValue(child?.id); + if (!childId) { + continue; + } + if (!childOutputMap.has(childId)) { + childOutputMap.set(childId, new Set()); + } + const childSet = childOutputMap.get(childId); + const directOutput = String(child?.output_path || '').trim(); + if (directOutput) { + childSet.add(directOutput); + } + } + for (const folder of outputFolders) { + const childId = normalizeJobIdValue(folder?.job_id); + if (!childId) { + continue; + } + if (!childOutputMap.has(childId)) { + childOutputMap.set(childId, new Set()); + } + const outputPath = String(folder?.output_path || '').trim(); + if (outputPath) { + childOutputMap.get(childId).add(outputPath); + } + } + + const containerOutputSummary = new Map(); + const containerRawSummary = new Map(); + const containerEncodeSummary = new Map(); + const containerChildSummary = new Map(); + for (const container of containerJobs) { + const containerId = normalizeJobIdValue(container?.id); + if (!containerId) { + continue; + } + const containerKind = String(container?.job_kind || '').trim().toLowerCase(); + const isMultipartContainer = containerKind === 'multipart_movie_container'; + const mkInfo = parseJsonSafe(container?.makemkv_info_json, {}); + const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; + const episodeCount = Number(selectedMetadata?.episodeCount || 0); + const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0; + const containerChildRowsRaw = directDiskRowsByContainerId.get(containerId) || []; + const containerChildRows = isMultipartContainer + ? containerChildRowsRaw.filter((row) => !isMultipartMergeRow(row)) + : containerChildRowsRaw; + const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || []; + const expectedTotal = isMultipartContainer + ? containerChildRows.length + : (episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0)); + const childIds = containerChildRows + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean); + let rawExistsAny = false; + let encodeSuccessAny = false; + let rawExistsCount = 0; + let backupSuccessCount = 0; + let encodeSuccessCount = 0; + let expectedFromPlans = 0; + const seenOutputs = new Set(); + let existingCount = 0; + let existingCountFromSeriesBatch = 0; + const totalChildren = childIds.length; + for (const childId of childIds) { + const childRow = directChildRowById.get(childId) || null; + const descendantRows = nestedRowsByParentId.get(childId) || []; + const relatedRows = [childRow, ...descendantRows].filter(Boolean); + if (childRow) { + const rawPath = String(childRow?.raw_path || '').trim(); + if (rawPath) { + const resolvedChildPaths = resolveEffectiveStoragePathsForJob(settings, childRow); + const rawStatus = includeFsChecks + ? inspectDirectory(resolvedChildPaths.effectiveRawPath) + : buildUnknownDirectoryStatus(resolvedChildPaths.effectiveRawPath); + if (rawStatus?.exists) { + rawExistsAny = true; + rawExistsCount += 1; + } + } + const mkInfo = parseJsonSafe(childRow?.makemkv_info_json, null); + const ripSuccessful = Number(childRow?.rip_successful || 0) === 1 + || String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + if (ripSuccessful) { + backupSuccessCount += 1; + } + let selectedCount = 0; + const childPlan = parseJsonSafe(childRow?.encode_plan_json, null); + if (childPlan && typeof childPlan === 'object') { + selectedCount = countSelectedEpisodeSlotsFromPlan(childPlan); + } + const handbrakeInfo = parseJsonSafe(childRow?.handbrake_info_json, null); + const hbStatus = String(handbrakeInfo?.status || '').trim().toUpperCase(); + const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object' + ? handbrakeInfo.seriesBatch + : null; + const seriesBatchTotal = Number(seriesBatchSummary?.totalCount || 0); + const seriesBatchFinished = Number(seriesBatchSummary?.finishedCount || 0); + const seriesBatchErrors = Number(seriesBatchSummary?.errorCount || 0); + const seriesBatchCancelled = Number(seriesBatchSummary?.cancelledCount || 0); + const expectedFromNested = descendantRows.length > 0 ? descendantRows.length : 0; + const expectedFromChild = seriesBatchTotal > 0 + ? seriesBatchTotal + : (selectedCount > 0 ? selectedCount : expectedFromNested); + if (expectedFromChild > 0) { + expectedFromPlans += expectedFromChild; + } + if (seriesBatchFinished > 0) { + existingCountFromSeriesBatch += seriesBatchFinished; + } + + let childEncodeSuccess = hbStatus === 'SUCCESS'; + if ( + !childEncodeSuccess + && seriesBatchTotal > 0 + && seriesBatchFinished >= seriesBatchTotal + && seriesBatchErrors <= 0 + && seriesBatchCancelled <= 0 + ) { + childEncodeSuccess = true; + } + if (!childEncodeSuccess && descendantRows.length > 0) { + let finishedDescendants = 0; + let errorDescendants = 0; + let cancelledDescendants = 0; + for (const row of descendantRows) { + const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase(); + if (rowState === 'FINISHED') { + finishedDescendants += 1; + } else if (rowState === 'ERROR') { + errorDescendants += 1; + } else if (rowState === 'CANCELLED') { + cancelledDescendants += 1; + } + } + childEncodeSuccess = finishedDescendants >= descendantRows.length + && errorDescendants === 0 + && cancelledDescendants === 0; + } + if (childEncodeSuccess) { + encodeSuccessAny = true; + encodeSuccessCount += 1; + } + } else if (descendantRows.length > 0) { + expectedFromPlans += descendantRows.length; + } + for (const relatedRow of relatedRows) { + const relatedRowId = normalizeJobIdValue(relatedRow?.id); + if (!relatedRowId) { + continue; + } + const outputs = Array.from(childOutputMap.get(relatedRowId) || []); + for (const outputPath of outputs) { + if (!outputPath || seenOutputs.has(outputPath)) { + continue; + } + seenOutputs.add(outputPath); + if (!includeFsChecks || fs.existsSync(outputPath)) { + existingCount += 1; + } + } + } + } + for (const episodeRow of containerEpisodeRows) { + const episodeRowId = normalizeJobIdValue(episodeRow?.id); + if (!episodeRowId) { + continue; + } + const outputs = Array.from(childOutputMap.get(episodeRowId) || []); + for (const outputPath of outputs) { + if (!outputPath || seenOutputs.has(outputPath)) { + continue; + } + seenOutputs.add(outputPath); + if (!includeFsChecks || fs.existsSync(outputPath)) { + existingCount += 1; + } + } + } + // For History UI with FS checks enabled, never override real filesystem + // counts with stale series-batch metadata counters. + if (!includeFsChecks && existingCountFromSeriesBatch > existingCount) { + existingCount = existingCountFromSeriesBatch; + } + if (!encodeSuccessAny && existingCount > 0) { + encodeSuccessAny = true; + if (encodeSuccessCount === 0 && totalChildren > 0) { + encodeSuccessCount = Math.min(1, totalChildren); + } + } + let mergeSummary = null; + if (isMultipartContainer) { + const mergeRows = containerChildRowsRaw.filter((row) => isMultipartMergeRow(row)); + let mergeJobId = null; + let mergeActive = false; + let mergeCompleted = false; + let mergeOutputExists = false; + for (const mergeRow of mergeRows) { + const mergeRowId = normalizeJobIdValue(mergeRow?.id); + if (mergeRowId && (!mergeJobId || mergeRowId > mergeJobId)) { + mergeJobId = mergeRowId; + } + const mergeStatus = String(mergeRow?.status || mergeRow?.last_state || '').trim().toUpperCase(); + if (mergeStatus === 'ENCODING') { + mergeActive = true; + } + const outputCandidates = new Set(); + const directOutputPath = String(mergeRow?.output_path || '').trim(); + if (directOutputPath) { + outputCandidates.add(directOutputPath); + } + if (mergeRowId) { + const linkedOutputs = Array.from(childOutputMap.get(mergeRowId) || []); + for (const outputPath of linkedOutputs) { + if (outputPath) { + outputCandidates.add(outputPath); + } + } + } + let rowOutputExists = false; + for (const outputPath of outputCandidates) { + if (!outputPath) { + continue; + } + if (!includeFsChecks || fs.existsSync(outputPath)) { + rowOutputExists = true; + break; + } + } + if (rowOutputExists) { + mergeOutputExists = true; + } + const mergeHbInfo = parseJsonSafe(mergeRow?.handbrake_info_json, null); + const mergeHbStatus = String(mergeHbInfo?.status || '').trim().toUpperCase(); + if (mergeStatus === 'FINISHED' || mergeHbStatus === 'SUCCESS' || rowOutputExists) { + mergeCompleted = true; + } + } + const mergeInputExpected = totalChildren; + const mergeInputReady = mergeInputExpected > 0 + ? Math.min(existingCount, mergeInputExpected) + : 0; + const mergeReady = mergeInputExpected >= 2 && mergeInputReady >= mergeInputExpected; + const mergeMissingInputs = mergeInputExpected > mergeInputReady + ? (mergeInputExpected - mergeInputReady) + : 0; + let mergeState = 'missing'; + if (mergeActive) { + mergeState = 'active'; + } else if (mergeCompleted) { + mergeState = 'done'; + } else if (mergeReady) { + mergeState = mergeRows.length > 0 ? 'ready' : 'restorable'; + } else if (mergeRows.length > 0) { + mergeState = 'blocked'; + } + mergeSummary = { + hasJob: mergeRows.length > 0, + jobId: mergeJobId, + active: mergeActive, + completed: mergeCompleted, + ready: mergeReady, + outputExists: mergeOutputExists, + inputReady: mergeInputReady, + inputExpected: mergeInputExpected, + missingInputs: mergeMissingInputs, + state: mergeState + }; + } + const expectedFinal = isMultipartContainer + ? (totalChildren > 0 ? totalChildren : existingCount) + : (expectedFromPlans > 0 + ? expectedFromPlans + : (expectedTotal > 0 ? expectedTotal : existingCount)); + containerOutputSummary.set(containerId, { + existing: existingCount, + expected: expectedFinal + }); + containerRawSummary.set(containerId, { exists: rawExistsAny }); + containerEncodeSummary.set(containerId, { success: encodeSuccessAny }); + containerChildSummary.set(containerId, { + raw: { existing: rawExistsCount, expected: totalChildren }, + backup: { existing: backupSuccessCount, expected: totalChildren }, + encode: { existing: encodeSuccessCount, expected: totalChildren }, + ...(mergeSummary ? { merge: mergeSummary } : {}) + }); + } + + const standaloneSeriesOutputSummary = new Map(); + if (seriesCandidateIds.length > 0) { + const placeholders = seriesCandidateIds.map(() => '?').join(', '); + const seriesOutputRows = await db.all( + `SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`, + seriesCandidateIds + ); + const outputsByJobId = new Map(); + for (const row of seriesOutputRows) { + const jobId = normalizeJobIdValue(row?.job_id); + if (!jobId) continue; + if (!outputsByJobId.has(jobId)) outputsByJobId.set(jobId, new Set()); + const pathValue = String(row?.output_path || '').trim(); + if (pathValue) outputsByJobId.get(jobId).add(pathValue); + } + for (const job of seriesCandidates) { + const jobId = normalizeJobIdValue(job?.id); + if (!jobId || containerIds.includes(jobId)) { + continue; + } + const mkInfo = parseJsonSafe(job?.makemkv_info_json, {}); + const selected = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; + const episodeCount = Number(selected?.episodeCount || 0); + const episodesLength = Array.isArray(selected?.episodes) ? selected.episodes.length : 0; + const expectedFromMetadata = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0); + const plan = parseJsonSafe(job?.encode_plan_json, null); + const expectedFromPlan = countSelectedEpisodeSlotsFromPlan(plan); + const expectedFinal = expectedFromPlan > 0 ? expectedFromPlan : expectedFromMetadata; + const outputSet = outputsByJobId.get(jobId) || new Set(); + const directOutput = String(job?.output_path || '').trim(); + if (directOutput) outputSet.add(directOutput); + let existingCount = 0; + for (const outputPath of outputSet) { + if (!outputPath) continue; + if (!includeFsChecks || fs.existsSync(outputPath)) { + existingCount += 1; + } + } + standaloneSeriesOutputSummary.set(jobId, { + existing: existingCount, + expected: expectedFinal > 0 ? expectedFinal : existingCount + }); + } + } + + return adjustedJobs.map((job) => { + const enriched = enrichJobRow(job, settings, { includeFsChecks }); + const containerId = normalizeJobIdValue(job?.id); + const summary = containerId + ? (containerOutputSummary.get(containerId) || standaloneSeriesOutputSummary.get(containerId) || null) + : (standaloneSeriesOutputSummary.get(containerId) || null); + const rawSummary = containerId ? containerRawSummary.get(containerId) : null; + const encodeSummary = containerId ? containerEncodeSummary.get(containerId) : null; + const childSummary = containerId ? containerChildSummary.get(containerId) : null; + const rawStatus = rawSummary + ? { + ...enriched.rawStatus, + exists: rawSummary.exists, + isDirectory: rawSummary.exists + } + : enriched.rawStatus; + const outputStatus = summary + ? { + ...enriched.outputStatus, + exists: summary.existing > 0, + isFile: summary.existing > 0 + } + : enriched.outputStatus; + const encodeSuccess = encodeSummary ? Boolean(encodeSummary.success) : enriched.encodeSuccess; + return { + ...enriched, + rawStatus, + outputStatus, + encodeSuccess, + ...(childSummary ? { seriesChildSummary: childSummary } : {}), + ...(summary ? { seriesOutputSummary: summary } : {}), + log_count: includeFsChecks ? (hasProcessLogFile(job.id) ? 1 : 0) : 0 + }; + }); + } + + async getTmdbMigrationPendingJobs(options = {}) { + const db = await getDb(); + const limitRaw = Number(options?.limit); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.trunc(limitRaw), 2000) + : 1000; + const includeFsChecks = options?.includeFsChecks === true; + + const [rows, settings] = await Promise.all([ + db.all( + ` + SELECT * + FROM jobs + WHERE COALESCE(migrate_tmdb, 0) = 0 + AND LOWER(TRIM(COALESCE(media_type, ''))) NOT IN ('cd', 'audiobook') + AND LOWER(TRIM(COALESCE(job_kind, ''))) NOT IN ('cd', 'audiobook') + ORDER BY COALESCE(updated_at, created_at) DESC, id DESC + LIMIT ? + `, + [limit] + ), + settingsService.getSettingsMap() + ]); + + const repairedRows = await Promise.all( + (Array.isArray(rows) ? rows : []).map((row) => this.repairImportedOrphanJobClassification(row, settings)) + ); + return repairedRows.map((row) => enrichJobRow(row, settings, { includeFsChecks })); + } + + async setTmdbMigrationFlag(jobId, migrated = false) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const existing = await this.getJobById(normalizedJobId); + if (!existing) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + await this.updateJob(normalizedJobId, { + migrate_tmdb: migrated ? 1 : 0 + }); + + const [updated, settings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); + } + + async getJobsByIds(jobIds = []) { + const ids = Array.isArray(jobIds) + ? jobIds + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + : []; + if (ids.length === 0) { + return []; + } + + const [rows, settings] = await Promise.all([ + (async () => { + const db = await getDb(); + const placeholders = ids.map(() => '?').join(', '); + return db.all(`SELECT * FROM jobs WHERE id IN (${placeholders})`, ids); + })(), + settingsService.getSettingsMap() + ]); + const byId = new Map(rows.map((row) => [Number(row.id), row])); + const repairedRows = await Promise.all( + ids + .map((id) => byId.get(id)) + .filter(Boolean) + .map((job) => this.repairImportedOrphanJobClassification(job, settings)) + ); + + const repairedById = new Map( + repairedRows + .filter(Boolean) + .map((row) => [Number(row.id), row]) + ); + + return ids + .map((id) => repairedById.get(id)) + .filter(Boolean) + .map((job) => ({ + ...enrichJobRow(job, settings), + log_count: hasProcessLogFile(job.id) ? 1 : 0 + })); + } + + async findSeriesContainerJob(tmdbId, seasonNumber) { + const normalizedTmdbId = Number(tmdbId || 0) || null; + const normalizedSeason = Number(seasonNumber || 0) || null; + if (!normalizedTmdbId || !normalizedSeason) { + return null; + } + const db = await getDb(); + const row = await db.get( + ` + SELECT * + FROM jobs + WHERE job_kind = 'dvd_series_container' + AND ( + json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.tmdbId') = ? + OR json_extract(makemkv_info_json, '$.selectedMetadata.tmdbId') = ? + ) + AND ( + json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.seasonNumber') = ? + OR json_extract(makemkv_info_json, '$.selectedMetadata.seasonNumber') = ? + ) + ORDER BY id DESC + LIMIT 1 + `, + [normalizedTmdbId, normalizedTmdbId, normalizedSeason, normalizedSeason] + ); + return row || null; + } + + async findLikelySeriesContainerJob({ title = null, year = null } = {}) { + const normalizedTitle = normalizeComparableLabel(title || ''); + const normalizedYear = Number.isFinite(Number(year)) && Number(year) > 0 + ? Math.trunc(Number(year)) + : null; + if (!normalizedTitle) { + return null; + } + + const db = await getDb(); + const rows = await db.all( + ` + SELECT * + FROM jobs + WHERE job_kind = 'dvd_series_container' + ORDER BY id DESC + ` + ); + const candidates = []; + for (const row of (Array.isArray(rows) ? rows : [])) { + const mkInfo = parseJsonSafe(row?.makemkv_info_json, {}) || {}; + const selected = extractSelectedMetadataFromMakemkvInfo(mkInfo); + const containerTitle = String( + selected?.title + || selected?.seriesTitle + || row?.title + || row?.detected_title + || '' + ).trim(); + const containerNormalizedTitle = normalizeComparableLabel(containerTitle); + if (!containerNormalizedTitle) { + continue; + } + + let score = 0; + if (containerNormalizedTitle === normalizedTitle) { + score += 10; + } else if (containerNormalizedTitle.includes(normalizedTitle) || normalizedTitle.includes(containerNormalizedTitle)) { + score += 6; + } else { + continue; + } + + const containerYearRaw = Number(selected?.year || row?.year || 0); + const containerYear = Number.isFinite(containerYearRaw) && containerYearRaw > 0 + ? Math.trunc(containerYearRaw) + : null; + if (normalizedYear && containerYear === normalizedYear) { + score += 4; + } else if (normalizedYear && containerYear && Math.abs(containerYear - normalizedYear) <= 1) { + score += 2; + } + + const tmdbId = normalizePositiveIntegerOrNull(selected?.tmdbId || selected?.providerId || null); + const seasonNumber = normalizePositiveIntegerOrNull(selected?.seasonNumber || null); + if (tmdbId && seasonNumber) { + score += 3; + } + + candidates.push({ + row, + score + }); + } + + if (candidates.length === 0) { + return null; + } + + candidates.sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return Number(right.row?.id || 0) - Number(left.row?.id || 0); + }); + + const best = candidates[0]; + const second = candidates[1] || null; + if (second && second.score === best.score) { + return null; + } + return best.row || null; + } + + async listSeriesSiblingJobs(tmdbId, seasonNumber) { + const normalizedTmdbId = Number(tmdbId || 0) || null; + const normalizedSeason = Number(seasonNumber || 0) || null; + if (!normalizedTmdbId || !normalizedSeason) { + return []; + } + const db = await getDb(); + const rows = await db.all( + ` + SELECT * + FROM jobs + WHERE job_kind != 'dvd_series_container' + AND ( + json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.tmdbId') = ? + OR json_extract(makemkv_info_json, '$.selectedMetadata.tmdbId') = ? + ) + AND ( + json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.seasonNumber') = ? + OR json_extract(makemkv_info_json, '$.selectedMetadata.seasonNumber') = ? + ) + ORDER BY id ASC + `, + [normalizedTmdbId, normalizedTmdbId, normalizedSeason, normalizedSeason] + ); + return Array.isArray(rows) ? rows : []; + } + + async listChildJobs(parentJobId) { + const normalizedParentJobId = normalizeJobIdValue(parentJobId); + if (!normalizedParentJobId) { + return []; + } + const db = await getDb(); + const rows = await db.all( + `SELECT * FROM jobs WHERE parent_job_id = ? ORDER BY id ASC`, + [normalizedParentJobId] + ); + return Array.isArray(rows) ? rows : []; + } + + async findLatestReplacementJobId(sourceJobId) { + const normalizedSourceJobId = normalizeJobIdValue(sourceJobId); + if (!normalizedSourceJobId) { + return null; + } + + const db = await getDb(); + const rows = await db.all( + ` + SELECT a.job_id + FROM job_lineage_artifacts a + JOIN jobs j ON j.id = a.job_id + WHERE a.source_job_id = ? + ORDER BY a.id DESC + `, + [normalizedSourceJobId] + ); + + for (const row of (Array.isArray(rows) ? rows : [])) { + const candidateJobId = normalizeJobIdValue(row?.job_id); + if (!candidateJobId || candidateJobId === normalizedSourceJobId) { + continue; + } + return candidateJobId; + } + + return null; + } + + async getJobByIdOrReplacement(jobId) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + return { + requestedJobId: null, + resolvedJobId: null, + replaced: false, + job: null + }; + } + + const directJob = await this.getJobById(normalizedJobId); + if (directJob) { + return { + requestedJobId: normalizedJobId, + resolvedJobId: normalizedJobId, + replaced: false, + job: directJob + }; + } + + const replacementJobId = await this.findLatestReplacementJobId(normalizedJobId); + if (!replacementJobId) { + return { + requestedJobId: normalizedJobId, + resolvedJobId: normalizedJobId, + replaced: false, + job: null + }; + } + + const replacementJob = await this.getJobById(replacementJobId); + return { + requestedJobId: normalizedJobId, + resolvedJobId: replacementJobId, + replaced: Boolean(replacementJob), + job: replacementJob || null + }; + } + + async getRunningJobs() { + const db = await getDb(); + const [rows, settings] = await Promise.all([ + db.all( + ` + SELECT * + FROM jobs + WHERE status IN ('ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') + AND COALESCE(job_kind, '') != 'dvd_series_container' + ORDER BY updated_at ASC, id ASC + ` + ), + settingsService.getSettingsMap() + ]); + return rows.map((job) => ({ + ...enrichJobRow(job, settings), + log_count: hasProcessLogFile(job.id) ? 1 : 0 + })); + } + + async getQueueIdleJobs() { + const db = await getDb(); + const [rows, settings] = await Promise.all([ + db.all( + ` + SELECT * + FROM jobs + WHERE status IN ( + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'READY_TO_START', + 'READY_TO_ENCODE', + 'CD_METADATA_SELECTION', + 'CD_READY_TO_RIP' + ) + AND COALESCE(job_kind, '') != 'dvd_series_container' + ORDER BY updated_at ASC, id ASC + ` + ), + settingsService.getSettingsMap() + ]); + return rows.map((job) => ({ + ...enrichJobRow(job, settings), + log_count: hasProcessLogFile(job.id) ? 1 : 0 + })); + } + + async getRunningEncodeJobs() { + const db = await getDb(); + const [rows, settings] = await Promise.all([ + db.all( + ` + SELECT * + FROM jobs + WHERE status IN ('ENCODING', 'CD_ENCODING') + ORDER BY updated_at ASC, id ASC + ` + ), + settingsService.getSettingsMap() + ]); + return rows.map((job) => ({ + ...enrichJobRow(job, settings), + log_count: hasProcessLogFile(job.id) ? 1 : 0 + })); + } + + async getRunningFilmEncodeJobs() { + const db = await getDb(); + const rows = await db.all( + `SELECT id, status FROM jobs WHERE status = 'ENCODING' ORDER BY updated_at ASC, id ASC` + ); + return rows; + } + + async getRunningCdEncodeJobs() { + const db = await getDb(); + const rows = await db.all( + `SELECT id, status FROM jobs WHERE status IN ('CD_RIPPING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC` + ); + return rows; + } + + async getJobWithLogs(jobId, options = {}) { + const db = await getDb(); + const includeFsChecks = options?.includeFsChecks !== false; + const [loadedJob, settings] = await Promise.all([ + db.get('SELECT * FROM jobs WHERE id = ?', [jobId]), + settingsService.getSettingsMap() + ]); + if (!loadedJob) { + return null; + } + const classifiedJob = await this.repairImportedOrphanJobClassification(loadedJob, settings); + const job = await this.repairImportedCdJobArtifacts(classifiedJob, settings); + const childRows = await this.listChildJobs(jobId); + const childJobs = await Promise.all( + childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings)) + ); + const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; + const isMultipartContainer = String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container'; + const isDiskContainer = isSeriesContainer || isMultipartContainer; + const detailChildJobs = isSeriesContainer + ? childJobs.filter((child) => !isSeriesBatchEpisodeSubJobRow(child)) + : childJobs; + const parsedTail = Number(options.logTailLines); + const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 + ? Math.trunc(parsedTail) + : 800; + const includeLiveLog = Boolean(options.includeLiveLog); + const includeLogs = Boolean(options.includeLogs); + const includeAllLogs = Boolean(options.includeAllLogs); + const shouldLoadLogs = includeLiveLog || includeLogs; + const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false; + const baseLogCount = hasProcessLog ? 1 : 0; + const enrichedChildren = await Promise.all( + detailChildJobs.map(async (child) => { + const base = { + ...enrichJobRow(child, settings, { includeFsChecks }), + log_count: includeFsChecks ? (hasProcessLogFile(child.id) ? 1 : 0) : 0 + }; + if (!shouldLoadLogs) { + return { + ...base, + logs: [], + log: '', + logMeta: { + loaded: false, + total: base.log_count, + returned: 0, + truncated: false + } + }; + } + const childLog = await this.readProcessLogLines(child.id, { + includeAll: includeAllLogs, + tailLines: logTailLines + }); + return { + ...base, + log: childLog.lines.join('\n'), + logMeta: { + loaded: true, + total: includeAllLogs ? childLog.total : childLog.returned, + returned: childLog.returned, + truncated: childLog.truncated + } + }; + }) + ); + + let seriesChildSummary = null; + if (isDiskContainer && enrichedChildren.length > 0) { + const summaryChildren = isMultipartContainer + ? enrichedChildren.filter((child) => !isMultipartMergeRow(child)) + : enrichedChildren; + const mergeChildren = isMultipartContainer + ? enrichedChildren.filter((child) => isMultipartMergeRow(child)) + : []; + const total = summaryChildren.length; + const outputReadyCount = summaryChildren.reduce((count, child) => ( + count + (child?.outputStatus?.exists ? 1 : 0) + ), 0); + const buildMergeSummary = () => { + if (!isMultipartContainer) { + return null; + } + let mergeJobId = null; + let mergeActive = false; + let mergeCompleted = false; + let mergeOutputExists = false; + for (const child of mergeChildren) { + const mergeRowId = normalizeJobIdValue(child?.id); + if (mergeRowId && (!mergeJobId || mergeRowId > mergeJobId)) { + mergeJobId = mergeRowId; + } + const mergeStatus = String(child?.status || child?.last_state || '').trim().toUpperCase(); + if (mergeStatus === 'ENCODING') { + mergeActive = true; + } + const hasOutput = Boolean(child?.outputStatus?.exists || String(child?.output_path || '').trim()); + if (hasOutput) { + mergeOutputExists = true; + } + if ( + mergeStatus === 'FINISHED' + || child?.encodeSuccess + || hasOutput + || String(child?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS' + ) { + mergeCompleted = true; + } + } + const mergeInputExpected = total; + const mergeInputReady = mergeInputExpected > 0 + ? Math.min(outputReadyCount, mergeInputExpected) + : 0; + const mergeReady = mergeInputExpected >= 2 && mergeInputReady >= mergeInputExpected; + const mergeMissingInputs = mergeInputExpected > mergeInputReady + ? (mergeInputExpected - mergeInputReady) + : 0; + let mergeState = 'missing'; + if (mergeActive) { + mergeState = 'active'; + } else if (mergeCompleted) { + mergeState = 'done'; + } else if (mergeReady) { + mergeState = mergeChildren.length > 0 ? 'ready' : 'restorable'; + } else if (mergeChildren.length > 0) { + mergeState = 'blocked'; + } + return { + hasJob: mergeChildren.length > 0, + jobId: mergeJobId, + active: mergeActive, + completed: mergeCompleted, + ready: mergeReady, + outputExists: mergeOutputExists, + inputReady: mergeInputReady, + inputExpected: mergeInputExpected, + missingInputs: mergeMissingInputs, + state: mergeState + }; + }; + if (total <= 0) { + seriesChildSummary = { + raw: { existing: 0, expected: 0 }, + backup: { existing: 0, expected: 0 }, + encode: { existing: 0, expected: 0 }, + ...(isMultipartContainer ? { merge: buildMergeSummary() } : {}) + }; + } + let rawCount = 0; + let backupCount = 0; + let encodeCount = 0; + for (const child of summaryChildren) { + if (child?.rawStatus?.exists) { + rawCount += 1; + } + // Backup should be considered present when rip-success markers are set + // or when RAW is physically available for that child. + const backupPresent = Boolean( + child?.backupSuccess + || child?.rawStatus?.exists + || String(child?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS' + ); + if (backupPresent) { + backupCount += 1; + } + if (child?.encodeSuccess) { + encodeCount += 1; + } + } + if (total > 0) { + seriesChildSummary = { + raw: { existing: rawCount, expected: total }, + backup: { existing: backupCount, expected: total }, + encode: { existing: encodeCount, expected: total }, + ...(isMultipartContainer ? { merge: buildMergeSummary() } : {}) + }; + } + } + + const outputFolders = await this.getJobOutputFoldersForLineage(jobId); + + if (!shouldLoadLogs) { + return { + ...enrichJobRow(job, settings, { includeFsChecks }), + outputFolders, + log_count: baseLogCount, + children: enrichedChildren, + ...(seriesChildSummary ? { seriesChildSummary } : {}), + logs: [], + log: '', + logMeta: { + loaded: false, + total: baseLogCount, + returned: 0, + truncated: false + } + }; + } + + const processLog = await this.readProcessLogLines(jobId, { + includeAll: includeAllLogs, + tailLines: logTailLines + }); + + return { + ...enrichJobRow(job, settings, { includeFsChecks }), + outputFolders, + log_count: processLog.exists ? processLog.total : 0, + children: enrichedChildren, + ...(seriesChildSummary ? { seriesChildSummary } : {}), + logs: [], + log: processLog.lines.join('\n'), + logMeta: { + loaded: true, + total: includeAllLogs ? processLog.total : processLog.returned, + returned: processLog.returned, + truncated: processLog.truncated + } + }; + } + + async generateJobNfo(jobId, options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const mode = String(options?.mode || 'manual').trim().toLowerCase() || 'manual'; + const requireSettingEnabled = Boolean(options?.requireSettingEnabled); + const requireSettingDisabled = Boolean(options?.requireSettingDisabled); + const failIfExists = Boolean(options?.failIfExists); + const failIfOutputMissing = Boolean(options?.failIfOutputMissing); + + const [job, settings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const enriched = enrichJobRow(job, settings, { includeFsChecks: true }); + const nfoStatus = enriched?.nfoStatus && typeof enriched.nfoStatus === 'object' + ? enriched.nfoStatus + : buildNfoStatusForJob({ + job: enriched, + outputPath: enriched?.output_path || null, + outputStatus: enriched?.outputStatus || null, + includeFsChecks: true, + settings, + mediaType: enriched?.mediaType || null + }); + + if (!nfoStatus.supported) { + if (mode === 'auto') { + return { + generated: false, + skipped: true, + reason: 'unsupported_media', + nfoStatus + }; + } + const error = new Error( + 'NFO-Generierung ist nur für Filme verfügbar (nicht für Serien, Audiobooks oder CDs).' + ); + error.statusCode = 409; + throw error; + } + + if (requireSettingEnabled && !nfoStatus.settingEnabled) { + return { + generated: false, + skipped: true, + reason: 'setting_disabled', + nfoStatus + }; + } + + if (requireSettingDisabled && nfoStatus.settingEnabled) { + const error = new Error('NFO-Generierung ist in den Settings aktiv. Manuelle Generierung ist nur bei deaktivierter Auto-NFO erlaubt.'); + error.statusCode = 409; + throw error; + } + + if (!nfoStatus.nfoPath) { + const error = new Error('Für diesen Job kann keine NFO-Datei erzeugt werden (Output ist keine Datei).'); + error.statusCode = 409; + throw error; + } + + if (!nfoStatus.outputExists || !nfoStatus.outputIsFile) { + if (failIfOutputMissing) { + const error = new Error('NFO kann nicht erzeugt werden: Output-Datei fehlt.'); + error.statusCode = 409; + throw error; + } + return { + generated: false, + skipped: true, + reason: 'output_missing', + nfoStatus + }; + } + + if (nfoStatus.nfoExists) { + if (failIfExists) { + const error = new Error('NFO-Datei existiert bereits.'); + error.statusCode = 409; + throw error; + } + return { + generated: false, + skipped: true, + reason: 'nfo_exists', + nfoStatus + }; + } + + const metadata = resolveNfoMetadataFromJob(enriched); + const xml = buildNfoXml(metadata); + fs.writeFileSync(nfoStatus.nfoPath, xml, 'utf8'); + + const refreshedNfoStatus = buildNfoStatusForJob({ + job: enriched, + outputPath: enriched?.output_path || null, + outputStatus: enriched?.outputStatus || null, + includeFsChecks: true, + settings, + mediaType: enriched?.mediaType || null + }); + + await this.appendLog( + normalizedJobId, + 'SYSTEM', + `NFO-Datei erstellt (${mode}): ${nfoStatus.nfoPath}` + ).catch(() => {}); + + logger.info('job:nfo:generated', { + jobId: normalizedJobId, + mode, + nfoPath: nfoStatus.nfoPath + }); + + return { + generated: true, + skipped: false, + mode, + nfoPath: nfoStatus.nfoPath, + metadata, + nfoStatus: refreshedNfoStatus + }; + } + + async getJobArchiveDescriptor(jobId, target, options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const normalizedTarget = normalizeArchiveTarget(target); + if (!normalizedTarget) { + const error = new Error('Ungültiges Download-Ziel. Erlaubt sind raw und output.'); + error.statusCode = 400; + throw error; + } + + const [job, settings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const enrichedJob = enrichJobRow(job, settings, { includeFsChecks: true }); + const requestedOutputPath = normalizedTarget === 'output' + ? (String(options?.outputPath || '').trim() || null) + : null; + let sourcePath = normalizedTarget === 'raw' + ? resolvedPaths.effectiveRawPath + : resolvedPaths.effectiveOutputPath; + + if (normalizedTarget === 'output' && requestedOutputPath) { + const trackedFolders = await this.getJobOutputFoldersForLineage(normalizedJobId); + const allowedOutputPathMap = new Map(); + const registerAllowedOutputPath = (candidatePath) => { + const raw = String(candidatePath || '').trim(); + if (!raw) { + return; + } + const normalized = normalizeComparablePath(raw); + if (!normalized || allowedOutputPathMap.has(normalized)) { + return; + } + allowedOutputPathMap.set(normalized, raw); + }; + + registerAllowedOutputPath(resolvedPaths.effectiveOutputPath); + registerAllowedOutputPath(job?.output_path); + for (const folder of trackedFolders) { + registerAllowedOutputPath(folder?.output_path); + } + + const normalizedRequestedOutputPath = normalizeComparablePath(requestedOutputPath); + if (!normalizedRequestedOutputPath || !allowedOutputPathMap.has(normalizedRequestedOutputPath)) { + const error = new Error('Der angeforderte Output-Ordner gehört nicht zu diesem Job.'); + error.statusCode = 404; + throw error; + } + + sourcePath = allowedOutputPathMap.get(normalizedRequestedOutputPath); + } + + if (!sourcePath) { + const error = new Error( + normalizedTarget === 'raw' + ? 'Kein RAW-Pfad für diesen Job vorhanden.' + : 'Kein Output-Pfad für diesen Job vorhanden.' + ); + error.statusCode = 404; + throw error; + } + + if (normalizedTarget === 'raw') { + const discMediaType = String(resolvedPaths?.mediaType || '').trim().toLowerCase(); + const isDiscMedia = ['dvd', 'bluray', 'cd'].includes(discMediaType); + const rawExists = Boolean(enrichedJob?.rawStatus?.exists); + const rawNotEmpty = enrichedJob?.rawStatus?.isEmpty !== true; + if (isIncompleteRawStoragePath(sourcePath) || !rawExists || !rawNotEmpty) { + const error = new Error('RAW ist noch nicht vollständig und kann nicht heruntergeladen werden.'); + error.statusCode = 409; + throw error; + } + if (isDiscMedia && !Boolean(enrichedJob?.ripSuccessful)) { + const error = new Error('RAW-Rip ist noch nicht abgeschlossen und kann nicht heruntergeladen werden.'); + error.statusCode = 409; + throw error; + } + } + + if (normalizedTarget === 'output') { + const statusUpper = String(enrichedJob?.status || '').trim().toUpperCase(); + const outputReady = Boolean(enrichedJob?.encodeSuccess || statusUpper === 'FINISHED'); + const outputExists = Boolean(enrichedJob?.outputStatus?.exists); + if (isIncompleteOutputStoragePath(sourcePath) || !outputExists || !outputReady) { + const error = new Error('Output ist noch nicht vollständig und kann nicht heruntergeladen werden.'); + error.statusCode = 409; + throw error; + } + } + + let sourceStat; + try { + sourceStat = await fs.promises.stat(sourcePath); + } catch (_error) { + const error = new Error( + normalizedTarget === 'raw' + ? 'RAW-Pfad wurde nicht gefunden.' + : 'Output-Pfad wurde nicht gefunden.' + ); + error.statusCode = 404; + throw error; + } + + if (!sourceStat.isDirectory() && !sourceStat.isFile()) { + const error = new Error('Nur Dateien oder Verzeichnisse können als ZIP heruntergeladen werden.'); + error.statusCode = 400; + throw error; + } + + return { + jobId: normalizedJobId, + displayTitle: buildJobDisplayTitle(job), + target: normalizedTarget, + sourcePath, + sourceType: sourceStat.isDirectory() ? 'directory' : 'file', + sourceMtimeMs: Number(sourceStat.mtimeMs || 0), + sourceModifiedAt: sourceStat.mtime ? sourceStat.mtime.toISOString() : null, + entryName: path.basename(sourcePath) || (normalizedTarget === 'raw' ? 'raw' : 'output'), + archiveName: buildJobArchiveName(job, normalizedTarget) + }; + } + + async getOrphanRawFolders() { + const settings = await settingsService.getSettingsMap(); + const rawDirs = getOrphanRawScanPathList(settings); + if (rawDirs.length === 0) { + const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).'); + error.statusCode = 400; + throw error; + } + + const db = await getDb(); + const [linkedRows, existingJobIdRows] = await Promise.all([ + db.all( + ` + SELECT id, raw_path, status, makemkv_info_json, mediainfo_info_json, encode_plan_json, encode_input_path + FROM jobs + WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' + ` + ), + db.all(`SELECT id FROM jobs`) + ]); + const existingJobIdSet = new Set( + (Array.isArray(existingJobIdRows) ? existingJobIdRows : []) + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + ); + + const linkedPathMap = new Map(); + const addLinkedPath = (candidatePath, rowRef) => { + const normalizedPath = normalizeComparablePath(candidatePath); + if (!normalizedPath) { + return; + } + if (!linkedPathMap.has(normalizedPath)) { + linkedPathMap.set(normalizedPath, []); + } + linkedPathMap.get(normalizedPath).push(rowRef); + }; + for (const row of linkedRows) { + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row); + const linkedCandidates = [ + normalizeComparablePath(row.raw_path), + normalizeComparablePath(resolvedPaths.effectiveRawPath) + ].filter(Boolean); + + for (const linkedPath of linkedCandidates) { + const rowRef = { + id: row.id, + status: row.status + }; + addLinkedPath(linkedPath, rowRef); + + // /database scannt nur Top-Level-Ordner je RAW-Root. + // Viele Jobs (insb. Disc-Backups) speichern raw_path jedoch auf einer tieferen Ebene. + // Deshalb markieren wir zusätzlich den zugehörigen Top-Level-Ordner als "linked". + for (const rawRoot of rawDirs) { + const normalizedRawRoot = normalizeComparablePath(rawRoot); + if (!normalizedRawRoot || !isPathInside(normalizedRawRoot, linkedPath)) { + continue; + } + const relative = String(path.relative(normalizedRawRoot, linkedPath) || '').trim(); + if (!relative || relative === '.' || relative.startsWith('..')) { + continue; + } + const topSegment = relative.split(path.sep).find((segment) => String(segment || '').trim() && segment !== '.'); + if (!topSegment) { + continue; + } + addLinkedPath(path.join(normalizedRawRoot, topSegment), rowRef); + } + } + } + + const orphanRows = []; + const seenOrphanPaths = new Set(); + + for (const rawDir of rawDirs) { + const rawDirInfo = inspectDirectory(rawDir); + if (!rawDirInfo.exists || !rawDirInfo.isDirectory) { + continue; + } + const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true }); + + for (const entry of dirEntries) { + if (!entry.isDirectory() || isHiddenDirectoryName(entry.name)) { + continue; + } + if (/^incomplete_/i.test(String(entry.name || '').trim())) { + // Incomplete RAW folders represent unfinished rips and are intentionally + // not re-importable from /database. + continue; + } + + const rawPath = path.join(rawDir, entry.name); + const normalizedPath = normalizeComparablePath(rawPath); + if (!normalizedPath || linkedPathMap.has(normalizedPath) || seenOrphanPaths.has(normalizedPath)) { + continue; + } + + const dirInfo = inspectDirectory(rawPath); + if (!dirInfo.exists || !dirInfo.isDirectory || dirInfo.isEmpty) { + continue; + } + + const stat = fs.statSync(rawPath); + const metadata = parseRawFolderMetadata(entry.name); + if (metadata.folderJobId && existingJobIdSet.has(Number(metadata.folderJobId))) { + continue; + } + const detectedMediaType = detectOrphanMediaType(rawPath, { + seriesRawPathHint: isLikelySeriesRawPath(rawPath, settings) + }); + orphanRows.push({ + rawPath, + folderName: entry.name, + title: metadata.title, + year: metadata.year, + imdbId: metadata.imdbId, + folderJobId: metadata.folderJobId, + entryCount: Number(dirInfo.entryCount || 0), + detectedMediaType, + hasBlurayStructure: detectedMediaType === 'bluray', + hasDvdStructure: detectedMediaType === 'dvd', + hasCdStructure: detectedMediaType === 'cd', + hasAudiobookStructure: detectedMediaType === 'audiobook', + lastModifiedAt: stat.mtime.toISOString() + }); + seenOrphanPaths.add(normalizedPath); + } + } + + orphanRows.sort((a, b) => String(b.lastModifiedAt).localeCompare(String(a.lastModifiedAt))); + return { + rawDir: rawDirs[0] || null, + rawDirs, + rows: orphanRows + }; + } + + async deleteOrphanRawFolder(rawPath) { + const settings = await settingsService.getSettingsMap(); + const rawDirs = getOrphanRawScanPathList(settings); + const requestedRawPath = String(rawPath || '').trim(); + + if (!requestedRawPath) { + const error = new Error('rawPath fehlt.'); + error.statusCode = 400; + throw error; + } + + if (rawDirs.length === 0) { + const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).'); + error.statusCode = 400; + throw error; + } + + const insideConfiguredRawDir = rawDirs.some((candidate) => isPathInside(candidate, requestedRawPath)); + if (!insideConfiguredRawDir) { + const error = new Error(`RAW-Pfad liegt außerhalb der konfigurierten RAW-Verzeichnisse: ${requestedRawPath}`); + error.statusCode = 400; + throw error; + } + + const absRawPath = normalizeComparablePath(requestedRawPath); + const dirInfo = inspectDirectory(absRawPath); + if (!dirInfo.exists || !dirInfo.isDirectory) { + const error = new Error(`RAW-Pfad existiert nicht als Verzeichnis: ${absRawPath}`); + error.statusCode = 404; + throw error; + } + + const orphanScan = await this.getOrphanRawFolders(); + const orphanRows = Array.isArray(orphanScan?.rows) ? orphanScan.rows : []; + const orphanRow = orphanRows.find((row) => normalizeComparablePath(row?.rawPath) === absRawPath); + if (!orphanRow) { + const error = new Error('RAW-Ordner ist nicht als verwaister /database-Eintrag verfügbar (ggf. bereits verknüpft).'); + error.statusCode = 409; + throw error; + } + + try { + const deleteResult = deleteFilesRecursively(absRawPath, false); + return { + deleted: true, + rawPath: absRawPath, + folderName: String(orphanRow?.folderName || path.basename(absRawPath)).trim() || path.basename(absRawPath), + filesDeleted: Number(deleteResult?.filesDeleted || 0), + dirsRemoved: Number(deleteResult?.dirsRemoved || 0) + }; + } catch (deleteError) { + const error = new Error(`RAW-Ordner konnte nicht gelöscht werden: ${deleteError?.message || deleteError}`); + error.statusCode = 500; + throw error; + } + } + + async importOrphanRawFolder(rawPath) { + const settings = await settingsService.getSettingsMap(); + const rawDirs = getOrphanRawScanPathList(settings); + const requestedRawPath = String(rawPath || '').trim(); + + if (!requestedRawPath) { + const error = new Error('rawPath fehlt.'); + error.statusCode = 400; + throw error; + } + + if (rawDirs.length === 0) { + const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).'); + error.statusCode = 400; + throw error; + } + + const insideConfiguredRawDir = rawDirs.some((candidate) => isPathInside(candidate, requestedRawPath)); + if (!insideConfiguredRawDir) { + const error = new Error(`RAW-Pfad liegt außerhalb der konfigurierten RAW-Verzeichnisse: ${requestedRawPath}`); + error.statusCode = 400; + throw error; + } + + const absRawPath = normalizeComparablePath(requestedRawPath); + const dirInfo = inspectDirectory(absRawPath); + if (!dirInfo.exists || !dirInfo.isDirectory) { + const error = new Error(`RAW-Pfad existiert nicht als Verzeichnis: ${absRawPath}`); + error.statusCode = 400; + throw error; + } + if (dirInfo.isEmpty) { + const error = new Error(`RAW-Pfad ist leer: ${absRawPath}`); + error.statusCode = 400; + throw error; + } + + const db = await getDb(); + const linkedRows = await db.all( + ` + SELECT id, raw_path + FROM jobs + WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' + ` + ); + const existing = linkedRows.find((row) => normalizeComparablePath(row.raw_path) === absRawPath); + if (existing) { + const error = new Error(`Für RAW-Pfad existiert bereits Job #${existing.id}.`); + error.statusCode = 409; + throw error; + } + + const folderName = path.basename(absRawPath); + const metadata = parseRawFolderMetadata(folderName); + const effectiveTitle = metadata.title || folderName; + const importedAt = new Date().toISOString(); + const created = await this.createJob({ + discDevice: null, + status: 'FINISHED', + detectedTitle: effectiveTitle + }); + + const renameSteps = []; + let finalRawPath = absRawPath; + const renamedRawPath = buildRawPathForJobId(absRawPath, created.id); + const shouldRenameRawFolder = normalizeComparablePath(renamedRawPath) !== absRawPath; + if (shouldRenameRawFolder) { + if (fs.existsSync(renamedRawPath)) { + await db.run('DELETE FROM jobs WHERE id = ?', [created.id]); + const error = new Error(`RAW-Ordner für neue Job-ID existiert bereits: ${renamedRawPath}`); + error.statusCode = 409; + throw error; + } + + try { + fs.renameSync(absRawPath, renamedRawPath); + finalRawPath = normalizeComparablePath(renamedRawPath); + renameSteps.push({ from: absRawPath, to: finalRawPath }); + } catch (error) { + await db.run('DELETE FROM jobs WHERE id = ?', [created.id]); + const wrapped = new Error(`RAW-Ordner konnte nicht auf neue Job-ID umbenannt werden: ${error.message}`); + wrapped.statusCode = 500; + throw wrapped; + } + } + + const ripCompleteFolderName = applyRawFolderPrefix(path.basename(finalRawPath), RAW_RIP_COMPLETE_PREFIX); + const ripCompleteRawPath = path.join(path.dirname(finalRawPath), ripCompleteFolderName); + const shouldMarkRipComplete = normalizeComparablePath(ripCompleteRawPath) !== normalizeComparablePath(finalRawPath); + if (shouldMarkRipComplete) { + if (fs.existsSync(ripCompleteRawPath)) { + await db.run('DELETE FROM jobs WHERE id = ?', [created.id]); + const error = new Error(`RAW-Ordner für Rip_Complete-Zustand existiert bereits: ${ripCompleteRawPath}`); + error.statusCode = 409; + throw error; + } + + try { + const previousRawPath = finalRawPath; + fs.renameSync(previousRawPath, ripCompleteRawPath); + finalRawPath = normalizeComparablePath(ripCompleteRawPath); + renameSteps.push({ from: previousRawPath, to: finalRawPath }); + } catch (error) { + await db.run('DELETE FROM jobs WHERE id = ?', [created.id]); + const wrapped = new Error(`RAW-Ordner konnte nicht als Rip_Complete markiert werden: ${error.message}`); + wrapped.statusCode = 500; + throw wrapped; + } + } + + const seriesRawPathHint = isLikelySeriesRawPath(finalRawPath, settings); + const detectedMediaType = detectOrphanMediaType(finalRawPath, { + seriesRawPathHint + }); + const initialSourceJobContext = await this.resolveOrphanImportSourceJob({ + candidateJobIds: [metadata.folderJobId], + mediaProfile: detectedMediaType + }); + const fallbackMediaTypeFromSource = normalizeMediaTypeValue( + initialSourceJobContext?.mediaProfile + || initialSourceJobContext?.makemkvInfo?.analyzeContext?.mediaProfile + || initialSourceJobContext?.makemkvInfo?.mediaProfile + || null + ); + const effectiveDetectedMediaType = detectedMediaType === 'other' + ? (fallbackMediaTypeFromSource || (seriesRawPathHint ? 'dvd' : 'other')) + : detectedMediaType; + const initialSourceJob = initialSourceJobContext?.job || null; + const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object' + ? initialSourceJobContext.selectedMetadata + : {}; + const cdRecovery = effectiveDetectedMediaType === 'cd' + ? recoverCdJobArtifactsForImport({ + currentRawPath: finalRawPath, + rawPathCandidates: [ + absRawPath, + renamedRawPath, + finalRawPath + ], + relatedJobIds: [metadata.folderJobId], + excludeJobIds: [created.id], + settings, + baseMetadata: { + title: initialSourceSelectedMetadata?.title || initialSourceSelectedMetadata?.album || initialSourceJob?.title || metadata.title || null, + album: initialSourceSelectedMetadata?.album || initialSourceSelectedMetadata?.title || initialSourceJob?.title || metadata.title || null, + artist: initialSourceSelectedMetadata?.artist || null, + year: initialSourceSelectedMetadata?.year || initialSourceJob?.year || metadata.year || null + } + }) + : null; + const sourceJobContext = await this.resolveOrphanImportSourceJob({ + candidateJobIds: [ + metadata.folderJobId, + ...(cdRecovery?.logSources || []).map((source) => source?.jobId) + ], + mediaProfile: effectiveDetectedMediaType + }) || initialSourceJobContext; + const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt, + rawPath: finalRawPath, + requestedRawPath: absRawPath, + sourceFolderJobId: metadata.folderJobId || null, + mediaProfile: effectiveDetectedMediaType, + // RAW-Import soll wie "Disk analysieren" starten: + // keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan. + existingInfo: null, + recovery: cdRecovery, + selectedMetadata: null, + analyzeContextPatch: null, + stripRecoveryMetadata: true + }); + await this.updateJob(created.id, { + ...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), + ...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}), + ...(effectiveDetectedMediaType === 'dvd' ? { job_kind: 'dvd', media_type: 'dvd', parent_job_id: null } : {}), + ...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}), + status: 'FINISHED', + last_state: 'FINISHED', + // /database-Import startet bewusst metadata-clean: keine Übernahme aus früheren Läufen. + title: null, + year: null, + imdb_id: null, + poster_url: null, + omdb_json: null, + selected_from_omdb: 0, + rip_successful: 1, + raw_path: finalRawPath, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + error_message: null, + end_time: importedAt, + makemkv_info_json: JSON.stringify(orphanImportInfo) + }); + + if (cdRecovery?.logSources?.length > 0) { + await this.restoreImportedProcessLog(created.id, cdRecovery.logSources, { + importInfo: orphanImportInfo + }); + } + + if (!cdRecovery?.logSources?.length) { + await this.appendLog( + created.id, + 'SYSTEM', + `Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(orphanImportInfo)}` + ); + } + + await this.appendLog( + created.id, + 'SYSTEM', + renameSteps.length > 0 + ? `Historieneintrag aus RAW erstellt (Medientyp: ${effectiveDetectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` + : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${effectiveDetectedMediaType})` + ); + await this.appendLog( + created.id, + 'SYSTEM', + 'Metadaten aus früheren Läufen wurden beim /database-Import verworfen (metadata-clean Start).' + ); + + logger.info('job:import-orphan-raw', { + jobId: created.id, + rawPath: absRawPath, + detectedMediaType: effectiveDetectedMediaType, + detectedMediaTypeRaw: detectedMediaType, + seriesRawPathHint + }); + + const imported = await this.getJobById(created.id); + return enrichJobRow(imported, settings); + } + + async assignMetadata(jobId, payload = {}) { + const job = await this.getJobById(jobId); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const parseTmdbId = (value) => { + const direct = normalizePositiveIntegerOrNull(value); + if (direct !== null) { + return direct; + } + const text = String(value || '').trim(); + if (!text) { + return null; + } + const providerMatch = text.match(/tmdb:(\d+)/i); + if (providerMatch?.[1]) { + return normalizePositiveIntegerOrNull(providerMatch[1]); + } + return null; + }; + const requestedProviderRaw = String(payload.metadataProvider || '').trim().toLowerCase(); + const requestedTmdbId = parseTmdbId(payload?.tmdbId ?? payload?.providerId ?? null); + const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(payload?.workflowKind); + const metadataProvider = requestedProviderRaw === 'themoviedb' + ? 'tmdb' + : (requestedProviderRaw || (requestedTmdbId !== null ? 'tmdb' : 'tmdb')); + const makemkvInfo = parseJsonSafe(job.makemkv_info_json, {}); + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const existingSelectedMetadata = extractSelectedMetadataFromMakemkvInfo(makemkvInfo); + + if (metadataProvider === 'tmdb') { + const manualTitle = String(payload.title || '').trim(); + const manualYearRaw = Number(payload.year); + const manualYear = Number.isFinite(manualYearRaw) ? Math.trunc(manualYearRaw) : null; + const manualImdbId = String(payload.imdbId || '').trim().toLowerCase() || null; + const manualPoster = String(payload.poster || '').trim() || null; + const manualSeasonName = String(payload.seasonName || '').trim() || null; + const manualSeasonNumber = normalizePositiveNumberOrNull(payload.seasonNumber); + const manualDiscNumber = normalizePositiveIntegerOrNull(payload.discNumber); + const manualEpisodeCountRaw = Number(payload.episodeCount); + const manualEpisodeCount = Number.isFinite(manualEpisodeCountRaw) && manualEpisodeCountRaw > 0 + ? Math.trunc(manualEpisodeCountRaw) + : null; + const manualEpisodes = Array.isArray(payload.episodes) ? payload.episodes : null; + const hasExplicitManualEpisodes = Boolean(manualEpisodes && manualEpisodes.length > 0); + const manualMetadataKind = String(payload.metadataKind || '').trim().toLowerCase() || null; + const manualProviderId = String(payload.providerId || '').trim() || null; + + let effectiveTmdbId = requestedTmdbId; + if (effectiveTmdbId === null) { + effectiveTmdbId = parseTmdbId( + existingSelectedMetadata?.tmdbId + ?? existingSelectedMetadata?.providerId + ?? analyzeContext?.tmdbId + ?? analyzeContext?.providerId + ?? null + ); + } + + const hasTmdbManualData = Boolean( + manualTitle + || manualYear !== null + || manualImdbId + || manualPoster + || manualSeasonName + || manualSeasonNumber !== null + || manualDiscNumber !== null + || manualEpisodeCount !== null + || (manualEpisodes && manualEpisodes.length > 0) + ); + if (effectiveTmdbId === null && !hasTmdbManualData) { + const error = new Error('Keine TMDb-/Metadaten zum Aktualisieren angegeben.'); + error.statusCode = 400; + throw error; + } + + const existingYearRaw = Number(existingSelectedMetadata?.year); + const existingYear = Number.isFinite(existingYearRaw) ? Math.trunc(existingYearRaw) : null; + const existingWorkflowKind = normalizeDvdMetadataWorkflowKind( + existingSelectedMetadata?.workflowKind + || analyzeContext?.workflowKind + || null + ); + const resolvedWorkflowKind = requestedWorkflowKind + || existingWorkflowKind + || (manualSeasonNumber !== null ? 'series' : 'film'); + const isFilmWorkflow = resolvedWorkflowKind === 'film'; + let title = manualTitle + || String(existingSelectedMetadata?.title || '').trim() + || job.title + || job.detected_title + || null; + let year = manualYear !== null + ? manualYear + : (existingYear !== null ? existingYear : (job.year ?? null)); + let imdbId = manualImdbId + || String(existingSelectedMetadata?.imdbId || job.imdb_id || '').trim().toLowerCase() + || null; + let posterUrl = manualPoster + || String(existingSelectedMetadata?.poster || job.poster_url || '').trim() + || null; + let shouldQueuePosterCache = false; + if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { + try { + const cacheResult = await this.cacheAndPromoteExternalPoster(jobId, posterUrl, { + source: 'TMDb Poster', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + posterUrl = cacheResult.localUrl; + } else { + shouldQueuePosterCache = true; + } + } catch (_error) { + shouldQueuePosterCache = true; + } + } + let seasonNumber = manualSeasonNumber !== null + ? manualSeasonNumber + : normalizePositiveNumberOrNull( + existingSelectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ); + let seasonName = manualSeasonName + || String(existingSelectedMetadata?.seasonName || '').trim() + || null; + let episodeCount = manualEpisodeCount !== null + ? manualEpisodeCount + : (Number(existingSelectedMetadata?.episodeCount || 0) || 0); + let episodes = hasExplicitManualEpisodes + ? manualEpisodes + : (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []); + const discNumber = manualDiscNumber !== null + ? manualDiscNumber + : normalizePositiveIntegerOrNull( + existingSelectedMetadata?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + const resolvedJobMediaType = inferMediaType( + job, + makemkvInfo, + job?.mediainfo_info_json, + job?.encode_plan_json, + job?.handbrake_info_json + ); + if (!isFilmWorkflow && (resolvedJobMediaType === 'dvd' || resolvedJobMediaType === 'bluray') && discNumber === null) { + const seriesDiscLabel = resolvedJobMediaType === 'bluray' ? 'Blu-ray' : 'DVD'; + const error = new Error(`Serien-${seriesDiscLabel} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`); + error.statusCode = 400; + throw error; + } + let metadataKind = manualMetadataKind + || String( + existingSelectedMetadata?.metadataKind + || analyzeContext?.metadataKind + || '' + ).trim().toLowerCase() + || null; + if (!metadataKind) { + metadataKind = isFilmWorkflow ? 'movie' : (seasonNumber !== null ? 'season' : 'series'); + } + let providerId = manualProviderId + || String(existingSelectedMetadata?.providerId || '').trim() + || null; + if (!providerId && effectiveTmdbId !== null) { + providerId = isFilmWorkflow + ? `tmdb:${effectiveTmdbId}` + : (seasonNumber !== null + ? `tmdb:${effectiveTmdbId}:season:${seasonNumber}` + : `tmdb:${effectiveTmdbId}`); + } + let tmdbLanguage = null; + try { + const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null; + } catch (_settingsError) { + tmdbLanguage = null; + } + + let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object' + ? { ...existingSelectedMetadata.tmdbDetails } + : null; + if (effectiveTmdbId !== null && !isFilmWorkflow) { + try { + const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { + language: tmdbLanguage, + appendToResponse: ['credits', 'external_ids'] + }); + if (seriesDetails) { + const detailsSummary = tmdbService.buildSeriesDetailsSummary(seriesDetails); + const createdBy = tmdbService.normalizeNameList(seriesDetails?.created_by, { maxItems: 3 }); + const genres = tmdbService.normalizeNameList(seriesDetails?.genres, { maxItems: 3 }); + tmdbDetails = { + ...(detailsSummary && typeof detailsSummary === 'object' ? detailsSummary : {}), + createdBy, + genres + }; + if (!title) { + title = String(seriesDetails?.name || seriesDetails?.original_name || '').trim() || null; + } + if ((!year || Number(year) <= 0) && tmdbDetails?.firstAirDate) { + const tmdbYear = Number(String(tmdbDetails.firstAirDate).slice(0, 4)); + if (Number.isFinite(tmdbYear) && tmdbYear > 0) { + year = Math.trunc(tmdbYear); + } + } + if (!imdbId && tmdbDetails?.imdbId) { + imdbId = String(tmdbDetails.imdbId).trim().toLowerCase() || null; + } + if (!posterUrl) { + posterUrl = tmdbService.buildImageUrl(seriesDetails?.poster_path, 'w342') || null; + } + } + } catch (tmdbDetailsErr) { + logger.warn('assignMetadata:tmdb-series-details-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + message: tmdbDetailsErr?.message || String(tmdbDetailsErr) + }); + } + } + + if (effectiveTmdbId !== null && isFilmWorkflow) { + try { + const movieDetails = await tmdbService.getMovieDetails(effectiveTmdbId, { + language: tmdbLanguage, + appendToResponse: ['credits', 'external_ids'] + }); + const detailsSummary = tmdbService.buildMovieDetailsSummary(movieDetails); + const genres = tmdbService.normalizeNameList(movieDetails?.genres, { maxItems: 3 }); + const cast = tmdbService.normalizeNameList(movieDetails?.credits?.cast, { maxItems: 6 }); + tmdbDetails = { + ...(detailsSummary && typeof detailsSummary === 'object' ? detailsSummary : {}), + genres, + seasonCast: cast + }; + if (!title) { + title = String(movieDetails?.title || movieDetails?.original_title || '').trim() || null; + } + if ((!year || Number(year) <= 0) && tmdbDetails?.releaseDate) { + const tmdbYear = Number(String(tmdbDetails.releaseDate).slice(0, 4)); + if (Number.isFinite(tmdbYear) && tmdbYear > 0) { + year = Math.trunc(tmdbYear); + } + } + if (!imdbId && tmdbDetails?.imdbId) { + imdbId = String(tmdbDetails.imdbId).trim().toLowerCase() || null; + } + if (!posterUrl) { + posterUrl = tmdbService.buildImageUrl(movieDetails?.poster_path, 'w342') || null; + } + } catch (tmdbMovieErr) { + logger.warn('assignMetadata:tmdb-movie-details-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + message: tmdbMovieErr?.message || String(tmdbMovieErr) + }); + } + } + + if (!isFilmWorkflow && effectiveTmdbId !== null && seasonNumber !== null) { + try { + const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber, { + language: tmdbLanguage + }); + const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber, { + language: tmdbLanguage + }); + const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); + if (seasonSummary) { + const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; + if (fetchedEpisodes.length > 0 && !hasExplicitManualEpisodes) { + episodes = fetchedEpisodes; + } + const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; + if (fetchedEpisodeCount > 0) { + episodeCount = fetchedEpisodeCount; + } + if (!seasonName && seasonSummary.name) { + seasonName = String(seasonSummary.name).trim() || null; + } + } + const seasonVoteAverageRaw = Number(seasonDetails?.vote_average || 0); + const seasonVoteAverage = Number.isFinite(seasonVoteAverageRaw) && seasonVoteAverageRaw > 0 + ? Number(seasonVoteAverageRaw.toFixed(1)) + : null; + const seasonRuntimeValues = Array.isArray(seasonDetails?.episodes) + ? seasonDetails.episodes + .map((episode) => Number(episode?.runtime || 0)) + .filter((value) => Number.isFinite(value) && value > 0) + : []; + const seasonRuntime = tmdbService.formatRuntimeLabel(seasonRuntimeValues); + const seasonCast = tmdbService.normalizeNameList(seasonCredits?.cast, { maxItems: 6 }); + tmdbDetails = { + ...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}), + seasonNumber, + seasonVoteAverage, + seasonRuntime, + runtime: seasonRuntime || (tmdbDetails?.runtime || null), + seasonCast + }; + } catch (tmdbSeasonErr) { + logger.warn('assignMetadata:tmdb-season-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + seasonNumber, + message: tmdbSeasonErr?.message || String(tmdbSeasonErr) + }); + } + } + + const nextSelectedMetadata = { + ...(existingSelectedMetadata && typeof existingSelectedMetadata === 'object' ? existingSelectedMetadata : {}), + title, + year, + imdbId, + poster: posterUrl, + workflowKind: isFilmWorkflow ? 'film' : 'series', + metadataProvider: 'tmdb', + providerId, + tmdbId: effectiveTmdbId, + metadataKind, + seasonNumber: isFilmWorkflow ? null : seasonNumber, + seasonName: isFilmWorkflow ? null : seasonName, + episodeCount: isFilmWorkflow ? 0 : episodeCount, + episodes: isFilmWorkflow ? [] : (Array.isArray(episodes) ? episodes : []), + ...(isFilmWorkflow ? {} : (discNumber !== null ? { discNumber } : {})), + ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) + }; + const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const existingSeriesLookupHint = analyzeContext?.seriesLookupHint && typeof analyzeContext.seriesLookupHint === 'object' + ? analyzeContext.seriesLookupHint + : {}; + const nextAnalyzeContext = { + ...analyzeContext, + workflowKind: isFilmWorkflow ? 'film' : 'series', + metadataProvider: 'tmdb', + metadataKind, + selectedMetadata: { + ...existingAnalyzeSelected, + ...nextSelectedMetadata + }, + seriesLookupHint: isFilmWorkflow + ? { + ...existingSeriesLookupHint, + seasonNumber: null, + discNumber: null + } + : { + ...existingSeriesLookupHint, + query: String(existingSeriesLookupHint.query || title || '').trim() || null, + seasonNumber: seasonNumber !== null + ? seasonNumber + : normalizePositiveNumberOrNull(existingSeriesLookupHint.seasonNumber ?? null), + discNumber: discNumber !== null + ? discNumber + : normalizePositiveIntegerOrNull(existingSeriesLookupHint.discNumber ?? null) + } + }; + const topLevelSelected = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + const nextMakemkvInfo = { + ...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}), + selectedMetadata: { + ...topLevelSelected, + ...nextSelectedMetadata + }, + analyzeContext: nextAnalyzeContext + }; + + await this.updateJob(jobId, { + title, + year, + imdb_id: imdbId, + poster_url: posterUrl, + omdb_json: null, + selected_from_omdb: 0, + migrate_tmdb: 1, + makemkv_info_json: JSON.stringify(nextMakemkvInfo) + }); + + if (shouldQueuePosterCache && posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { + this.queuePosterCache(jobId, posterUrl, { + source: 'TMDb Poster', + logFailures: true + }); + } + + await this.appendLog( + jobId, + 'USER_ACTION', + effectiveTmdbId !== null + ? `TMDb-Zuordnung aktualisiert: ${effectiveTmdbId}${!isFilmWorkflow && seasonNumber !== null ? ` (Staffel ${seasonNumber})` : ''} (${title || '-'})` + : `TMDb-Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"` + ); + + const [updated, settings] = await Promise.all([ + this.getJobById(jobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); + } + + const unsupportedError = new Error('Nur TMDb-Metadaten werden unterstützt.'); + unsupportedError.statusCode = 400; + throw unsupportedError; + } + + async assignCdMetadata(jobId, payload = {}) { + const job = await this.getJobById(jobId); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const title = String(payload.title || '').trim() || null; + const artist = String(payload.artist || '').trim() || null; + const yearRaw = Number(payload.year); + const year = Number.isFinite(yearRaw) && yearRaw > 0 ? Math.trunc(yearRaw) : null; + const mbId = String(payload.mbId || '').trim() || null; + let coverUrl = String(payload.coverUrl || '').trim() || null; + const selectedTracks = Array.isArray(payload.tracks) ? payload.tracks : null; + + if (!title && !artist && !mbId) { + const error = new Error('Keine CD-Metadaten zum Aktualisieren angegeben.'); + error.statusCode = 400; + throw error; + } + + const cdInfo = parseJsonSafe(job.makemkv_info_json, {}); + const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; + + let mergedTracks = tocTracks; + if (selectedTracks && tocTracks.length > 0) { + mergedTracks = tocTracks.map((t) => { + const selected = selectedTracks.find((st) => Number(st.position) === Number(t.position)); + const resolvedTitle = String(selected?.title || t.title || `Track ${t.position}`).replace(/\s+/g, ' ').trim(); + const resolvedArtist = String(selected?.artist || t.artist || artist || '').replace(/\s+/g, ' ').trim() || null; + return { + ...t, + title: resolvedTitle, + artist: resolvedArtist, + selected: selected ? Boolean(selected.selected) : true + }; + }); + } + + let shouldQueueCoverCache = false; + if (coverUrl && !thumbnailService.isLocalUrl(coverUrl)) { + try { + const cacheResult = await this.cacheAndPromoteExternalPoster(jobId, coverUrl, { + source: 'Coverart', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + coverUrl = cacheResult.localUrl; + } else { + shouldQueueCoverCache = true; + } + } catch (_error) { + shouldQueueCoverCache = true; + } + } + + const prevSelected = cdInfo.selectedMetadata && typeof cdInfo.selectedMetadata === 'object' ? cdInfo.selectedMetadata : {}; + const updatedCdInfo = { + ...cdInfo, + tracks: mergedTracks, + selectedMetadata: { + ...prevSelected, + title: title || prevSelected.title || null, + artist: artist || prevSelected.artist || null, + year: year !== null ? year : (prevSelected.year || null), + mbId: mbId || prevSelected.mbId || null, + coverUrl: coverUrl || prevSelected.coverUrl || null + } + }; + + await this.updateJob(jobId, { + title: title || null, + year: year || null, + imdb_id: mbId || null, + poster_url: coverUrl || null, + makemkv_info_json: JSON.stringify(updatedCdInfo) + }); + + if (shouldQueueCoverCache && coverUrl && !thumbnailService.isLocalUrl(coverUrl)) { + this.queuePosterCache(jobId, coverUrl, { + source: 'Coverart', + logFailures: true + }); + } + + await this.appendLog( + jobId, + 'USER_ACTION', + `CD-Metadaten aktualisiert: album="${title || '-'}", artist="${artist || '-'}", year="${year || '-'}", mbId="${mbId || '-'}"` + ); + + const [updated, settings] = await Promise.all([ + this.getJobById(jobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); + } + + async acknowledgeJobError(jobId) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const job = await this.getJobById(normalizedJobId); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const hasErrorMessage = Boolean(String(job?.error_message || '').trim()); + const statusUpper = String(job?.status || '').trim().toUpperCase(); + const setCancelled = statusUpper === 'ERROR'; + + if (!hasErrorMessage && !setCancelled) { + const [updatedUnchanged, unchangedSettings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updatedUnchanged, unchangedSettings); + } + + await this.updateJob(normalizedJobId, { + error_message: null, + ...(setCancelled ? { status: 'CANCELLED' } : {}), + ...(!job?.end_time ? { end_time: new Date().toISOString() } : {}) + }); + + await this.appendLog( + normalizedJobId, + 'USER_ACTION', + 'Fehlermeldung quittiert.' + ); + + const [updated, settings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); + } + + async _resolveRelatedJobsForDeletion(jobId, options = {}) { + const includeRelated = options?.includeRelated !== false; + const includeLogLinks = options?.includeLogLinks !== false; + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const db = await getDb(); + const rows = await db.all('SELECT * FROM jobs ORDER BY id ASC'); + const byId = new Map(rows.map((row) => [Number(row.id), row])); + const primary = byId.get(normalizedJobId); + if (!primary) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + if (!includeRelated) { + return [primary]; + } + + const childrenByParent = new Map(); + const childrenBySource = new Map(); + for (const row of rows) { + const rowId = normalizeJobIdValue(row?.id); + if (!rowId) { + continue; + } + const parentJobId = normalizeJobIdValue(row?.parent_job_id); + if (parentJobId) { + if (!childrenByParent.has(parentJobId)) { + childrenByParent.set(parentJobId, new Set()); + } + childrenByParent.get(parentJobId).add(rowId); + } + const sourceJobId = parseSourceJobIdFromPlan(row?.encode_plan_json); + if (sourceJobId) { + if (!childrenBySource.has(sourceJobId)) { + childrenBySource.set(sourceJobId, new Set()); + } + childrenBySource.get(sourceJobId).add(rowId); + } + } + + const pending = [normalizedJobId]; + const visited = new Set(); + const parentRow = normalizeJobIdValue(primary?.parent_job_id) !== null + ? byId.get(normalizeJobIdValue(primary?.parent_job_id)) + : null; + const isPrimarySeriesChild = isSeriesChildRow(primary) + || (parentRow && isSeriesContainerRow(parentRow)); + const isPrimaryMultipartChild = isMultipartChildRow(primary) + || isMultipartMergeRow(primary) + || (parentRow && isMultipartContainerRow(parentRow)); + const isPrimarySeriesContainer = isSeriesContainerRow(primary); + const isPrimaryMultipartContainer = isMultipartContainerRow(primary); + const isPrimaryScopedChild = isPrimarySeriesChild || isPrimaryMultipartChild; + const isPrimaryDiskContainer = isPrimarySeriesContainer || isPrimaryMultipartContainer; + const enqueue = (value) => { + const id = normalizeJobIdValue(value); + if (!id || visited.has(id)) { + return; + } + pending.push(id); + }; + + while (pending.length > 0) { + const currentId = normalizeJobIdValue(pending.shift()); + if (!currentId || visited.has(currentId)) { + continue; + } + visited.add(currentId); + + const row = byId.get(currentId); + if (!row) { + continue; + } + + if (!(isPrimaryScopedChild || isPrimaryDiskContainer)) { + enqueue(row.parent_job_id); + enqueue(parseSourceJobIdFromPlan(row.encode_plan_json)); + } + + for (const childId of (childrenByParent.get(currentId) || [])) { + enqueue(childId); + } + for (const childId of (childrenBySource.get(currentId) || [])) { + enqueue(childId); + } + + if (includeLogLinks && !(isPrimaryScopedChild || isPrimaryDiskContainer)) { + try { + const processLog = await this.readProcessLogLines(currentId, { includeAll: true }); + const linkedJobIds = parseRetryLinkedJobIdsFromLogLines(processLog.lines); + for (const linkedId of linkedJobIds) { + enqueue(linkedId); + } + } catch (_error) { + // optional fallback links from process logs; ignore read errors + } + } + } + + if (isPrimaryScopedChild && parentRow && isDiskContainerRow(parentRow)) { + const parentId = normalizeJobIdValue(parentRow?.id); + if (parentId) { + const remainingChildren = rows.filter((row) => { + const rowId = normalizeJobIdValue(row?.id); + if (!rowId) { + return false; + } + if (normalizeJobIdValue(row?.parent_job_id) !== parentId) { + return false; + } + return !visited.has(rowId); + }); + if (remainingChildren.length === 0) { + visited.add(parentId); + } + } + } + + return Array.from(visited) + .map((id) => byId.get(id)) + .filter(Boolean) + .sort((left, right) => Number(left.id || 0) - Number(right.id || 0)); + } + + _collectDeleteCandidatesForJob(job, settings = null, options = {}) { + const normalizedJobId = normalizeJobIdValue(job?.id); + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const lineageArtifacts = Array.isArray(options?.lineageArtifacts) ? options.lineageArtifacts : []; + const trackedOutputPaths = Array.isArray(options?.trackedOutputPaths) + ? options.trackedOutputPaths + : []; + const toNormalizedPath = (value) => { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + return normalizeComparablePath(raw); + }; + const unique = (values = []) => Array.from(new Set((Array.isArray(values) ? values : []).filter(Boolean))); + const sanitizeRoots = (values = []) => unique(values).filter((root) => !isFilesystemRootPath(root)); + + const artifactRawPaths = lineageArtifacts + .map((artifact) => toNormalizedPath(artifact?.rawPath)) + .filter(Boolean); + const artifactMoviePaths = lineageArtifacts + .map((artifact) => toNormalizedPath(artifact?.outputPath)) + .filter(Boolean); + + const explicitRawPaths = unique([ + toNormalizedPath(job?.raw_path), + toNormalizedPath(resolvedPaths?.effectiveRawPath), + ...artifactRawPaths + ]); + const explicitMovieSeedPaths = unique([ + toNormalizedPath(job?.output_path), + toNormalizedPath(resolvedPaths?.effectiveOutputPath), + ...trackedOutputPaths.map((candidatePath) => toNormalizedPath(candidatePath)), + ...artifactMoviePaths + ]); + const inferredSiblingMoviePaths = explicitMovieSeedPaths.flatMap((candidatePath) => inferSiblingOutputFolders(candidatePath)); + const explicitMoviePaths = unique([ + ...explicitMovieSeedPaths, + ...inferredSiblingMoviePaths.map((candidatePath) => toNormalizedPath(candidatePath)) + ]); + + const rawRoots = sanitizeRoots([ + toNormalizedPath(resolvedPaths?.rawDir), + ...explicitRawPaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) + ]); + // Only effective base dir for this job becomes protectedRoot (must never be deleted itself) + const movieRoots = sanitizeRoots([ + toNormalizedPath(resolvedPaths?.movieDir) + ]); + // Includes parent dirs of output files for addCandidate safety checks + const movieAllowedPaths = sanitizeRoots([ + ...movieRoots, + ...explicitMoviePaths, + ...explicitMoviePaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) + ]); + + const rawCandidates = []; + const movieCandidates = []; + const addCandidate = (bucket, target, candidatePath, source, allowedRoots = []) => { + const normalizedPath = toNormalizedPath(candidatePath); + if (!normalizedPath) { + return; + } + if (isFilesystemRootPath(normalizedPath)) { + return; + } + const roots = Array.isArray(allowedRoots) ? allowedRoots.filter(Boolean) : []; + if (roots.length > 0 && !roots.some((root) => isPathInside(root, normalizedPath))) { + return; + } + bucket.push({ + target, + path: normalizedPath, + source, + jobId: normalizedJobId + }); + }; + + const artifactRawPathSet = new Set(artifactRawPaths); + for (const rawPath of explicitRawPaths) { + addCandidate( + rawCandidates, + 'raw', + rawPath, + artifactRawPathSet.has(rawPath) ? 'lineage_raw_path' : 'raw_path', + rawRoots + ); + } + + const rawFolderNames = new Set(); + for (const rawPath of explicitRawPaths) { + const folderName = String(path.basename(rawPath || '') || '').trim(); + if (!folderName || folderName === '.' || folderName === path.sep) { + continue; + } + rawFolderNames.add(folderName); + const stripped = stripRawFolderStatePrefix(folderName); + if (stripped) { + rawFolderNames.add(stripped); + rawFolderNames.add(applyRawFolderPrefix(stripped, RAW_INCOMPLETE_PREFIX)); + rawFolderNames.add(applyRawFolderPrefix(stripped, RAW_RIP_COMPLETE_PREFIX)); + } + } + for (const rootPath of rawRoots) { + for (const folderName of rawFolderNames) { + addCandidate(rawCandidates, 'raw', path.join(rootPath, folderName), 'raw_variant', rawRoots); + } + } + + if (normalizedJobId) { + for (const rootPath of rawRoots) { + try { + if (!fs.existsSync(rootPath) || !fs.lstatSync(rootPath).isDirectory()) { + continue; + } + const entries = fs.readdirSync(rootPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry?.isDirectory?.()) { + continue; + } + const metadata = parseRawFolderMetadata(entry.name); + if (normalizeJobIdValue(metadata?.folderJobId) === normalizedJobId) { + addCandidate( + rawCandidates, + 'raw', + path.join(rootPath, entry.name), + 'raw_jobid_scan', + rawRoots + ); + } + } + } catch (_error) { + // ignore fs errors while collecting optional candidates + } + } + } + + const artifactMoviePathSet = new Set(artifactMoviePaths); + for (const outputPath of explicitMoviePaths) { + addCandidate( + movieCandidates, + 'movie', + outputPath, + artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path', + movieAllowedPaths + ); + } + + if (normalizedJobId && resolvedPaths?.mediaType !== 'cd') { + const incompleteName = `Incomplete_job-${normalizedJobId}`; + for (const rootPath of movieRoots) { + addCandidate(movieCandidates, 'movie', path.join(rootPath, incompleteName), 'movie_incomplete_folder', movieRoots); + try { + if (!fs.existsSync(rootPath) || !fs.lstatSync(rootPath).isDirectory()) { + continue; + } + const entries = fs.readdirSync(rootPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry?.isDirectory?.()) { + continue; + } + const match = String(entry.name || '').match(/^incomplete_job-(\d+)\s*$/i); + if (normalizeJobIdValue(match?.[1]) !== normalizedJobId) { + continue; + } + addCandidate( + movieCandidates, + 'movie', + path.join(rootPath, entry.name), + 'movie_incomplete_scan', + movieRoots + ); + } + } catch (_error) { + // ignore fs errors while collecting optional candidates + } + } + } + + return { + rawCandidates, + movieCandidates, + rawRoots, + movieRoots + }; + } + + _buildDeletePreviewFromJobs(jobs = [], settings = null, lineageArtifactsByJobId = null, outputFoldersByJobId = null) { + const rows = Array.isArray(jobs) ? jobs : []; + const artifactsMap = lineageArtifactsByJobId instanceof Map ? lineageArtifactsByJobId : new Map(); + const trackedFoldersMap = outputFoldersByJobId instanceof Map ? outputFoldersByJobId : new Map(); + const candidateMap = new Map(); + const protectedRoots = { + raw: new Set(), + movie: new Set() + }; + const upsertCandidate = (candidate) => { + const target = String(candidate?.target || '').trim().toLowerCase(); + const candidatePath = String(candidate?.path || '').trim(); + if (!target || !candidatePath) { + return; + } + const key = `${target}:${candidatePath}`; + if (!candidateMap.has(key)) { + candidateMap.set(key, { + target, + path: candidatePath, + jobIds: new Set(), + sources: new Set() + }); + } + const row = candidateMap.get(key); + const candidateJobId = normalizeJobIdValue(candidate?.jobId); + if (candidateJobId) { + row.jobIds.add(candidateJobId); + } + const source = String(candidate?.source || '').trim(); + if (source) { + row.sources.add(source); + } + }; + + for (const job of rows) { + const lineageArtifacts = artifactsMap.get(normalizeJobIdValue(job?.id)) || []; + const trackedFolders = trackedFoldersMap.get(normalizeJobIdValue(job?.id)) || []; + const collected = this._collectDeleteCandidatesForJob(job, settings, { + lineageArtifacts, + trackedOutputPaths: trackedFolders + .map((folder) => String(folder?.output_path || '').trim()) + .filter(Boolean) + }); + for (const rootPath of collected.rawRoots || []) { + protectedRoots.raw.add(rootPath); + } + for (const rootPath of collected.movieRoots || []) { + protectedRoots.movie.add(rootPath); + } + for (const candidate of collected.rawCandidates || []) { + upsertCandidate(candidate); + } + for (const candidate of collected.movieCandidates || []) { + upsertCandidate(candidate); + } + } + + const hasTrackedOutputSource = (sources = []) => { + const normalizedSources = Array.isArray(sources) + ? sources + .map((source) => String(source || '').trim().toLowerCase()) + .filter(Boolean) + : []; + return normalizedSources.includes('output_path') || normalizedSources.includes('lineage_output_path'); + }; + + const buildList = (target) => { + const rows = Array.from(candidateMap.values()) + .filter((row) => row.target === target); + if (target !== 'movie') { + return rows + .map((row) => { + const inspection = inspectDeletionPath(row.path); + const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right)); + return { + target, + path: row.path, + exists: Boolean(inspection.exists), + isDirectory: Boolean(inspection.isDirectory), + isFile: Boolean(inspection.isFile), + jobIds: Array.from(row.jobIds).sort((left, right) => left - right), + sources + }; + }) + .filter((row) => Boolean(row?.exists)) + .filter(Boolean) + .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); + } + + const moviePathMap = new Map(); + const upsertMoviePath = ({ path: candidatePath, exists, isDirectory, isFile, jobIds, sources }) => { + const normalizedPath = normalizeComparablePath(candidatePath); + if (!normalizedPath) { + return; + } + if (!moviePathMap.has(normalizedPath)) { + moviePathMap.set(normalizedPath, { + target: 'movie', + path: normalizedPath, + exists: Boolean(exists), + isDirectory: Boolean(isDirectory), + isFile: Boolean(isFile), + jobIds: new Set(), + sources: new Set() + }); + } + const row = moviePathMap.get(normalizedPath); + row.exists = row.exists || Boolean(exists); + row.isDirectory = row.isDirectory || Boolean(isDirectory); + row.isFile = row.isFile || Boolean(isFile); + for (const jobId of (Array.isArray(jobIds) ? jobIds : [])) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (normalizedJobId) { + row.jobIds.add(normalizedJobId); + } + } + for (const source of (Array.isArray(sources) ? sources : [])) { + const normalizedSource = String(source || '').trim(); + if (normalizedSource) { + row.sources.add(normalizedSource); + } + } + }; + + for (const row of rows) { + const inspection = inspectDeletionPath(row.path); + const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right)); + // Do not expose stale output paths from DB/lineage in delete preview. + if (!inspection.exists && hasTrackedOutputSource(sources)) { + continue; + } + if (!inspection.exists) { + // Output candidates must stay file-based only. Missing paths are not actionable. + continue; + } + if (inspection.isDirectory) { + const files = collectFilesRecursively(inspection.path); + for (const filePath of files) { + upsertMoviePath({ + path: filePath, + exists: true, + isDirectory: false, + isFile: true, + jobIds: Array.from(row.jobIds), + sources + }); + } + continue; + } + upsertMoviePath({ + path: inspection.path, + exists: true, + isDirectory: false, + isFile: true, + jobIds: Array.from(row.jobIds), + sources + }); + } + + return Array.from(moviePathMap.values()) + .map((row) => ({ + target: row.target, + path: row.path, + exists: Boolean(row.exists), + isDirectory: false, + isFile: true, + jobIds: Array.from(row.jobIds).sort((left, right) => left - right), + sources: Array.from(row.sources).sort((left, right) => left.localeCompare(right)) + })) + .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); + }; + + return { + pathCandidates: { + raw: buildList('raw'), + movie: buildList('movie') + }, + protectedRoots: { + raw: Array.from(protectedRoots.raw).sort((left, right) => left.localeCompare(right)), + movie: Array.from(protectedRoots.movie).sort((left, right) => left.localeCompare(right)) + } + }; + } + + async getJobDeletePreview(jobId, options = {}) { + const includeRelated = options?.includeRelated !== false; + const requestedSelectedJobIds = Array.isArray(options?.selectedJobIds) + ? options.selectedJobIds + .map((item) => normalizeJobIdValue(item)) + .filter(Boolean) + : null; + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const jobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { includeRelated }); + const allJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); + const allJobIdSet = new Set(allJobIds); + const hasExplicitSelectedJobIds = Array.isArray(requestedSelectedJobIds); + const effectiveSelectedJobIds = includeRelated + ? (hasExplicitSelectedJobIds + ? requestedSelectedJobIds.filter((id) => allJobIdSet.has(id)) + : allJobIds) + : [normalizedJobId]; + const effectiveSelectedJobIdSet = new Set(effectiveSelectedJobIds); + const selectedJobs = jobs.filter((job) => effectiveSelectedJobIdSet.has(normalizeJobIdValue(job?.id))); + + const settings = await settingsService.getSettingsMap(); + const relatedJobIds = selectedJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); + const [lineageArtifactsByJobId, outputFoldersByJobId] = relatedJobIds.length > 0 + ? await Promise.all([ + this.listJobLineageArtifactsByJobIds(relatedJobIds), + this.listJobOutputFoldersByJobIds(relatedJobIds) + ]) + : [new Map(), new Map()]; + const preview = this._buildDeletePreviewFromJobs( + selectedJobs, + settings, + lineageArtifactsByJobId, + outputFoldersByJobId + ); + const relatedJobs = jobs.map((job) => { + const mkInfo = parseJsonSafe(job?.makemkv_info_json, {}); + const roleMeta = resolveDeletePreviewRole(job); + const id = normalizeJobIdValue(job?.id); + return { + id: Number(job.id), + parentJobId: normalizeJobIdValue(job.parent_job_id), + title: buildJobDisplayTitle(job), + status: String(job.status || '').trim() || null, + discDevice: String(job.disc_device || '').trim() || null, + isPrimary: Number(job.id) === normalizedJobId, + selected: Boolean(id && effectiveSelectedJobIdSet.has(id)), + roleKey: roleMeta.roleKey, + roleLabel: roleMeta.roleLabel, + createdAt: String(job.created_at || '').trim() || null, + orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo) + }; + }); + const orphanRawImportJobs = relatedJobs.filter( + (row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport) + ).length; + const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length; + const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length; + + return { + jobId: normalizedJobId, + includeRelated, + selectedJobIds: effectiveSelectedJobIds, + relatedJobs, + pathCandidates: preview.pathCandidates, + protectedRoots: preview.protectedRoots, + flags: { + containsOrphanRawImportJob: orphanRawImportJobs > 0 + }, + counts: { + relatedJobs: relatedJobs.length, + selectedJobs: effectiveSelectedJobIds.length, + orphanRawImportJobs, + rawCandidates: preview.pathCandidates.raw.length, + movieCandidates: preview.pathCandidates.movie.length, + existingRawCandidates, + existingMovieCandidates + } + }; + } + + _deletePathsFromPreview(preview, target = 'both', options = {}) { + const normalizedTarget = String(target || 'both').trim().toLowerCase(); + const includesRaw = normalizedTarget === 'raw' || normalizedTarget === 'both'; + const includesMovie = normalizedTarget === 'movie' || normalizedTarget === 'both'; + const selectedRawPathFilter = new Set( + (Array.isArray(options?.selectedRawPaths) ? options.selectedRawPaths : []) + .map((rawPath) => String(rawPath || '').trim()) + .filter(Boolean) + .map((rawPath) => normalizeComparablePath(rawPath)) + ); + const selectedMoviePathFilter = new Set( + (Array.isArray(options?.selectedMoviePaths) ? options.selectedMoviePaths : []) + .map((moviePath) => String(moviePath || '').trim()) + .filter(Boolean) + .map((moviePath) => normalizeComparablePath(moviePath)) + ); + + const summary = { + target: normalizedTarget, + raw: { attempted: includesRaw, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null }, + movie: { attempted: includesMovie, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null }, + selectedRawPaths: Array.from(selectedRawPathFilter), + selectedMoviePaths: Array.from(selectedMoviePathFilter), + deletedPaths: [] + }; + + const applyTarget = (targetKey) => { + let candidates = (Array.isArray(preview?.pathCandidates?.[targetKey]) ? preview.pathCandidates[targetKey] : []) + .filter((item) => Boolean(item?.exists) && (Boolean(item?.isDirectory) || Boolean(item?.isFile))); + if (targetKey === 'raw' && selectedRawPathFilter.size > 0) { + candidates = candidates.filter((item) => { + const candidatePath = String(item?.path || '').trim(); + if (!candidatePath) { + return false; + } + return selectedRawPathFilter.has(normalizeComparablePath(candidatePath)); + }); + } + if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) { + candidates = candidates.filter((item) => { + const candidatePath = String(item?.path || '').trim(); + if (!candidatePath) { + return false; + } + return selectedMoviePathFilter.has(normalizeComparablePath(candidatePath)); + }); + + // If selected movie paths are passed explicitly, trust this list as source of + // truth as well. This protects against stale preview maps after path renames. + const candidatePathSet = new Set( + candidates + .map((item) => normalizeComparablePath(item?.path)) + .filter(Boolean) + ); + for (const selectedPath of selectedMoviePathFilter) { + if (!selectedPath || candidatePathSet.has(selectedPath)) { + continue; + } + const inspection = inspectDeletionPath(selectedPath); + if (!inspection.exists || (!inspection.isDirectory && !inspection.isFile)) { + continue; + } + candidates.push({ + target: 'movie', + path: inspection.path, + exists: true, + isDirectory: Boolean(inspection.isDirectory), + isFile: Boolean(inspection.isFile), + jobIds: [], + sources: ['selected_movie_paths'] + }); + candidatePathSet.add(inspection.path); + } + } + if (candidates.length === 0) { + if (targetKey === 'raw' && selectedRawPathFilter.size > 0) { + summary[targetKey].reason = 'Keine ausgewählten RAW-Dateien/Ordner gefunden.'; + } else if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) { + summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Dateien gefunden.'; + } else { + summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.'; + } + return; + } + + const protectedRoots = new Set( + (Array.isArray(preview?.protectedRoots?.[targetKey]) ? preview.protectedRoots[targetKey] : []) + .map((rootPath) => String(rootPath || '').trim()) + .filter(Boolean) + .map((rootPath) => normalizeComparablePath(rootPath)) + ); + + const orderedCandidates = [...candidates].sort( + (left, right) => String(right?.path || '').length - String(left?.path || '').length + ); + for (const candidate of orderedCandidates) { + const candidatePath = String(candidate?.path || '').trim(); + if (!candidatePath) { + continue; + } + const inspection = inspectDeletionPath(candidatePath); + if (!inspection.exists) { + continue; + } + + if (inspection.isDirectory) { + if (targetKey === 'movie') { + const filesInDirectory = collectFilesRecursively(inspection.path); + let filesDeleted = 0; + for (const filePath of filesInDirectory) { + const fileInspection = inspectDeletionPath(filePath); + if (!fileInspection.exists) { + continue; + } + if (fileInspection.isDirectory) { + continue; + } + try { + fs.unlinkSync(fileInspection.path); + filesDeleted += 1; + summary.deletedPaths.push({ + target: targetKey, + path: fileInspection.path, + type: 'file', + keepRoot: false, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } catch (_error) { + // continue best-effort for remaining files + } + } + summary[targetKey].filesDeleted += filesDeleted; + + const keepRoot = protectedRoots.has(inspection.path); + const dirDeleteResult = deleteFilesRecursively(inspection.path, keepRoot); + const dirsRemoved = Number(dirDeleteResult?.dirsRemoved || 0); + if (dirsRemoved > 0 || filesDeleted > 0) { + summary[targetKey].dirsRemoved += dirsRemoved; + summary[targetKey].pathsDeleted += 1; + summary.deletedPaths.push({ + target: targetKey, + path: inspection.path, + type: 'directory', + keepRoot, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } + const removedParentDirs = removeEmptyParentDirectories(path.dirname(inspection.path), { + protectedRoots: Array.from(protectedRoots) + }); + for (const removedPath of removedParentDirs) { + summary[targetKey].dirsRemoved += 1; + summary.deletedPaths.push({ + target: targetKey, + path: removedPath, + type: 'directory', + keepRoot: false, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } + continue; + } + const keepRoot = protectedRoots.has(inspection.path); + const result = deleteFilesRecursively(inspection.path, keepRoot); + const filesDeleted = Number(result?.filesDeleted || 0); + const dirsRemoved = Number(result?.dirsRemoved || 0); + const directoryRemoved = !keepRoot && !fs.existsSync(inspection.path); + const changed = filesDeleted > 0 || dirsRemoved > 0 || directoryRemoved; + summary[targetKey].filesDeleted += filesDeleted; + summary[targetKey].dirsRemoved += dirsRemoved; + if (changed) { + summary[targetKey].pathsDeleted += 1; + summary.deletedPaths.push({ + target: targetKey, + path: inspection.path, + type: 'directory', + keepRoot, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } + continue; + } + + fs.unlinkSync(inspection.path); + summary[targetKey].filesDeleted += 1; + summary[targetKey].pathsDeleted += 1; + summary.deletedPaths.push({ + target: targetKey, + path: inspection.path, + type: 'file', + keepRoot: false, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + + if (targetKey === 'movie') { + const parentDir = normalizeComparablePath(path.dirname(inspection.path)); + const removedParentDirs = removeEmptyParentDirectories(parentDir, { + protectedRoots: Array.from(protectedRoots) + }); + for (const removedPath of removedParentDirs) { + summary[targetKey].dirsRemoved += 1; + summary.deletedPaths.push({ + target: targetKey, + path: removedPath, + type: 'directory', + keepRoot: false, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } + } + } + + summary[targetKey].deleted = summary[targetKey].pathsDeleted > 0 + || summary[targetKey].filesDeleted > 0 + || summary[targetKey].dirsRemoved > 0; + if (!summary[targetKey].deleted) { + summary[targetKey].reason = 'Keine vorhandenen Dateien/Ordner gelöscht.'; + } + }; + + if (includesRaw) { + applyTarget('raw'); + } + if (includesMovie) { + applyTarget('movie'); + } + + return summary; + } + + _deleteProcessLogFile(jobId) { + const processLogPath = toProcessLogPath(jobId); + if (!processLogPath || !fs.existsSync(processLogPath)) { + return; + } + try { + fs.unlinkSync(processLogPath); + } catch (error) { + logger.warn('job:process-log:delete-failed', { + jobId, + path: processLogPath, + error: error?.message || String(error) + }); + } + } + + _archiveProcessLogFile(jobId, options = {}) { + const processLogPath = toProcessLogPath(jobId); + if (!processLogPath || !fs.existsSync(processLogPath)) { + return null; + } + const archiveTarget = buildArchivedProcessLogPath(jobId, options); + if (!archiveTarget?.archivePath) { + return null; + } + + try { + fs.mkdirSync(archiveTarget.archiveDir, { recursive: true }); + fs.renameSync(processLogPath, archiveTarget.archivePath); + return archiveTarget.archivePath; + } catch (error) { + logger.warn('job:process-log:archive-failed', { + jobId, + fromPath: processLogPath, + toPath: archiveTarget.archivePath, + reason: String(options?.reason || '').trim() || null, + replacementJobId: Number(options?.replacementJobId) || null, + error: error?.message || String(error) + }); + return null; + } + } + + async deleteJobFiles(jobId, target = 'both', options = {}) { + const allowedTargets = new Set(['raw', 'movie', 'both']); + if (!allowedTargets.has(target)) { + const error = new Error(`Ungültiges target '${target}'. Erlaubt: raw, movie, both.`); + error.statusCode = 400; + throw error; + } + + const job = await this.getJobById(jobId); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const includeRelated = Boolean(options?.includeRelated); + const selectedJobIds = Array.isArray(options?.selectedJobIds) + ? options.selectedJobIds + .map((item) => normalizeJobIdValue(item)) + .filter(Boolean) + : null; + const selectedRawPaths = Array.isArray(options?.selectedRawPaths) + ? options.selectedRawPaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) + ? options.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + + const hasSelectedJobFilter = Array.isArray(options?.selectedJobIds); + if ( + includeRelated + || hasSelectedJobFilter + || (selectedRawPaths && selectedRawPaths.length > 0) + || (selectedMoviePaths && selectedMoviePaths.length > 0) + ) { + const normalizedJobId = normalizeJobIdValue(jobId); + const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated, selectedJobIds }); + const summary = this._deletePathsFromPreview(preview, target, { selectedRawPaths, selectedMoviePaths }); + const relatedJobIds = Array.from(new Set( + (Array.isArray(preview?.selectedJobIds) ? preview.selectedJobIds : []) + .map((row) => normalizeJobIdValue(row)) + .filter(Boolean) + )); + + if (summary.movie?.deleted) { + await this.removeMissingJobOutputFoldersFromJobs( + relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId] + ); + } + + await this.appendLog( + normalizedJobId, + 'USER_ACTION', + `Dateien gelöscht (${target}) - includeRelated=${includeRelated} - raw=${JSON.stringify(summary.raw)} movie=${JSON.stringify(summary.movie)}` + ); + logger.info('job:delete-files', { + jobId: normalizedJobId, + includeRelated, + selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0, + selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0, + selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0, + summary + }); + + const [updated, enrichSettings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + return { + summary, + includeRelated, + relatedJobIds, + job: enrichJobRow(updated, enrichSettings) + }; + } + + const settings = await settingsService.getSettingsMap(); + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const effectiveRawPath = resolvedPaths.effectiveRawPath; + const effectiveOutputPath = resolvedPaths.effectiveOutputPath; + const effectiveRawDir = resolvedPaths.rawDir; + const effectiveMovieDir = resolvedPaths.movieDir; + const summary = { + target, + raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null }, + movie: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null } + }; + let movieDeletedPathForTracking = null; + let movieCandidatePathForTracking = null; + + if (target === 'raw' || target === 'both') { + summary.raw.attempted = true; + if (!effectiveRawPath) { + summary.raw.reason = 'Kein raw_path im Job gesetzt.'; + } else if (!effectiveRawDir) { + const error = new Error(`Kein gültiger RAW-Basispfad für Job ${jobId} (${resolvedPaths.mediaType || 'unknown'}).`); + error.statusCode = 400; + throw error; + } else if (!isPathInside(effectiveRawDir, effectiveRawPath)) { + const error = new Error( + `RAW-Pfad liegt außerhalb des effektiven RAW-Basispfads. raw_path=${effectiveRawPath} raw_base=${effectiveRawDir}` + ); + error.statusCode = 400; + throw error; + } else if (!fs.existsSync(effectiveRawPath)) { + summary.raw.reason = 'RAW-Pfad existiert nicht.'; + } else { + const rawPath = normalizeComparablePath(effectiveRawPath); + const rawRoot = normalizeComparablePath(effectiveRawDir); + const stat = fs.lstatSync(rawPath); + const isFile = stat.isFile(); + const plan = resolvedPaths.encodePlan || {}; + + // Für Converter-Jobs: nur die zum Job gehörenden Dateien löschen. + // Der übergeordnete Ordner wird nur entfernt, wenn er danach leer ist. + // Ausnahme: Ordner-Jobs (isFolder=true) haben einen dedizierten Ordner → komplett löschen. + const isConverterFolder = resolvedPaths.mediaType === 'converter' && Boolean(plan.isFolder); + const isSharedAudio = resolvedPaths.mediaType === 'converter' && Boolean(plan.isSharedAudio); + const isConverterFileJob = resolvedPaths.mediaType === 'converter' && !isConverterFolder; + + if (isConverterFileJob) { + // Bestimme die zu löschenden Dateien dieses Jobs + let filesToDelete = null; + if (isSharedAudio && Array.isArray(plan.inputPaths) && plan.inputPaths.length > 0) { + // Shared-Audio-Job: nur die explizit gelisteten Einzeldateien entfernen + filesToDelete = plan.inputPaths.map(normalizeComparablePath).filter(Boolean); + } else if (isFile) { + // Einzeldatei-Job: nur diese eine Datei entfernen + filesToDelete = [rawPath]; + } + + if (filesToDelete) { + let filesDeleted = 0; + const parentDirs = new Set(); + for (const filePath of filesToDelete) { + if (!isPathInside(rawRoot, filePath)) continue; + if (!fs.existsSync(filePath)) continue; + const fStat = fs.lstatSync(filePath); + if (!fStat.isFile()) continue; + fs.unlinkSync(filePath); + filesDeleted++; + const parentDir = normalizeComparablePath(path.dirname(filePath)); + if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) { + parentDirs.add(parentDir); + } + } + // Übergeordneten Ordner löschen, wenn er nach dem Löschen leer ist + let dirsRemoved = 0; + for (const parentDir of parentDirs) { + try { + if (!fs.existsSync(parentDir)) continue; + const remaining = fs.readdirSync(parentDir); + if (remaining.length === 0) { + fs.rmdirSync(parentDir); + dirsRemoved++; + } + } catch (_err) { /* ignore */ } + } + summary.raw.deleted = true; + summary.raw.filesDeleted = filesDeleted; + summary.raw.dirsRemoved = dirsRemoved; + } else { + // Fallback: rawPath ist ein Verzeichnis ohne explizite Dateiliste + const keepRoot = rawPath === rawRoot; + const result = deleteFilesRecursively(rawPath, keepRoot); + summary.raw.deleted = true; + summary.raw.filesDeleted = result.filesDeleted; + summary.raw.dirsRemoved = result.dirsRemoved; + } + } else { + // Regulärer Job oder dedizierter Converter-Ordner-Job (isFolder=true): gesamten Pfad löschen + const keepRoot = rawPath === rawRoot; + const result = deleteFilesRecursively(rawPath, keepRoot); + summary.raw.deleted = true; + summary.raw.filesDeleted = result.filesDeleted; + summary.raw.dirsRemoved = result.dirsRemoved; + } + } + } + + if (target === 'movie' || target === 'both') { + summary.movie.attempted = true; + if (!effectiveOutputPath) { + summary.movie.reason = 'Kein output_path im Job gesetzt.'; + } else if (!effectiveMovieDir) { + const error = new Error(`Kein gültiger Movie-Basispfad für Job ${jobId} (${resolvedPaths.mediaType || 'unknown'}).`); + error.statusCode = 400; + throw error; + } else if (!isPathInside(effectiveMovieDir, effectiveOutputPath)) { + const error = new Error( + `Movie-Pfad liegt außerhalb des effektiven Movie-Basispfads. output_path=${effectiveOutputPath} movie_base=${effectiveMovieDir}` + ); + error.statusCode = 400; + throw error; + } else if (!fs.existsSync(effectiveOutputPath)) { + const movieRoot = normalizeComparablePath(effectiveMovieDir); + const pruneEmptyMovieParents = (startPath) => { + if (!startPath || !movieRoot) { + return []; + } + return removeEmptyParentDirectories(startPath, { + protectedRoots: [movieRoot] + }); + }; + const trackedFolders = await this.getJobOutputFolders(jobId); + const trackedExistingPaths = Array.from(new Set( + trackedFolders + .map((row) => normalizeComparablePath(row?.output_path)) + .filter(Boolean) + .filter((candidatePath) => isPathInside(effectiveMovieDir, candidatePath)) + .filter((candidatePath) => candidatePath !== movieRoot) + .filter((candidatePath) => fs.existsSync(candidatePath)) + )); + + if (trackedExistingPaths.length === 1) { + const trackedPath = trackedExistingPaths[0]; + const stat = fs.lstatSync(trackedPath); + if (stat.isDirectory()) { + const keepRoot = trackedPath === movieRoot; + const result = deleteFilesRecursively(trackedPath, keepRoot); + const removedParentDirs = keepRoot + ? [] + : pruneEmptyMovieParents(path.dirname(trackedPath)); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0); + movieDeletedPathForTracking = trackedPath; + movieCandidatePathForTracking = trackedPath; + summary.movie.reason = null; + } else { + const parentDir = normalizeComparablePath(path.dirname(trackedPath)); + const isConverterJob = resolvedPaths.mediaType === 'converter'; + const canDeleteParentDir = !isConverterJob + && parentDir + && parentDir !== movieRoot + && isPathInside(movieRoot, parentDir) + && fs.existsSync(parentDir) + && fs.lstatSync(parentDir).isDirectory(); + + if (canDeleteParentDir) { + const result = deleteFilesRecursively(parentDir, false); + const removedParentDirs = pruneEmptyMovieParents(path.dirname(parentDir)); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0); + movieDeletedPathForTracking = parentDir; + movieCandidatePathForTracking = trackedPath; + } else { + fs.unlinkSync(trackedPath); + const removedParentDirs = pruneEmptyMovieParents(parentDir); + summary.movie.deleted = true; + summary.movie.filesDeleted = 1; + summary.movie.dirsRemoved = Number(removedParentDirs.length || 0); + movieDeletedPathForTracking = trackedPath; + movieCandidatePathForTracking = trackedPath; + } + summary.movie.reason = null; + } + } else if (trackedExistingPaths.length > 1) { + summary.movie.reason = 'Mehrere bekannte Output-Ordner gefunden. Bitte gezielt über die Ordnerauswahl löschen.'; + } else { + summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.'; + } + } else { + const outputPath = normalizeComparablePath(effectiveOutputPath); + const movieRoot = normalizeComparablePath(effectiveMovieDir); + const pruneEmptyMovieParents = (startPath) => { + if (!startPath || !movieRoot) { + return []; + } + return removeEmptyParentDirectories(startPath, { + protectedRoots: [movieRoot] + }); + }; + const stat = fs.lstatSync(outputPath); + if (stat.isDirectory()) { + const keepRoot = outputPath === movieRoot; + const result = deleteFilesRecursively(outputPath, keepRoot ? true : false); + const removedParentDirs = keepRoot + ? [] + : pruneEmptyMovieParents(path.dirname(outputPath)); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0); + movieDeletedPathForTracking = outputPath; + movieCandidatePathForTracking = outputPath; + } else { + const parentDir = normalizeComparablePath(path.dirname(outputPath)); + const parentDirName = String(path.basename(parentDir || '') || '').trim(); + const isIncompleteMergeParentDir = /^incomplete_merge_.+_job_\d+\s*$/i.test(parentDirName); + // Converter jobs output a single file — never delete the parent dir + const isConverterJob = resolvedPaths.mediaType === 'converter'; + const canDeleteParentDir = !isConverterJob + && !isIncompleteMergeParentDir + && parentDir + && parentDir !== movieRoot + && isPathInside(movieRoot, parentDir) + && fs.existsSync(parentDir) + && fs.lstatSync(parentDir).isDirectory(); + + if (canDeleteParentDir) { + const result = deleteFilesRecursively(parentDir, false); + const removedParentDirs = pruneEmptyMovieParents(path.dirname(parentDir)); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0); + movieDeletedPathForTracking = parentDir; + movieCandidatePathForTracking = outputPath; + } else { + fs.unlinkSync(outputPath); + const removedParentDirs = pruneEmptyMovieParents(parentDir); + summary.movie.deleted = true; + summary.movie.filesDeleted = 1; + summary.movie.dirsRemoved = Number(removedParentDirs.length || 0); + movieDeletedPathForTracking = outputPath; + movieCandidatePathForTracking = outputPath; + } + } + } + } + + // Remove deleted movie paths from output folders tracking + if (summary.movie?.deleted) { + const trackingCleanupPaths = Array.from(new Set([ + movieCandidatePathForTracking, + movieDeletedPathForTracking + ].filter(Boolean))); + for (const candidatePath of trackingCleanupPaths) { + await this.removeJobOutputFolder(jobId, candidatePath); + } + } + + await this.appendLog( + jobId, + 'USER_ACTION', + `Dateien gelöscht (${target}) - raw=${JSON.stringify(summary.raw)} movie=${JSON.stringify(summary.movie)}` + ); + logger.info('job:delete-files', { jobId, summary }); + + const [updated, enrichSettings] = await Promise.all([ + this.getJobById(jobId), + settingsService.getSettingsMap() + ]); + return { + summary, + job: enrichJobRow(updated, enrichSettings) + }; + } + + async addJobOutputFolder(jobId, outputPath, label = null) { + const normalizedJobId = Number(jobId); + const normalizedPath = String(outputPath || '').trim(); + if (!normalizedJobId || !normalizedPath) return null; + const db = await getDb(); + await db.run( + 'INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label) VALUES (?, ?, ?)', + [normalizedJobId, normalizedPath, label || null] + ); + } + + async getJobOutputFolders(jobId) { + const normalizedJobId = Number(jobId); + if (!normalizedJobId) return []; + const db = await getDb(); + const rows = await db.all( + 'SELECT id, job_id, output_path, label, created_at FROM job_output_folders WHERE job_id = ? ORDER BY id ASC', + [normalizedJobId] + ); + return rows || []; + } + + async listJobOutputFoldersByJobIds(jobIds = []) { + const normalizedJobIds = Array.isArray(jobIds) + ? jobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + if (normalizedJobIds.length === 0) { + return new Map(); + } + + const db = await getDb(); + const placeholders = normalizedJobIds.map(() => '?').join(', '); + const rows = await db.all( + ` + SELECT id, job_id, output_path, label, created_at + FROM job_output_folders + WHERE job_id IN (${placeholders}) + ORDER BY id ASC + `, + normalizedJobIds + ); + + const byJobId = new Map(); + for (const row of (Array.isArray(rows) ? rows : [])) { + const ownerJobId = normalizeJobIdValue(row?.job_id); + if (!ownerJobId) { + continue; + } + if (!byJobId.has(ownerJobId)) { + byJobId.set(ownerJobId, []); + } + byJobId.get(ownerJobId).push({ + id: normalizeJobIdValue(row?.id), + job_id: ownerJobId, + output_path: String(row?.output_path || '').trim() || null, + label: String(row?.label || '').trim() || null, + created_at: String(row?.created_at || '').trim() || null + }); + } + + return byJobId; + } + + async getJobOutputFoldersForLineage(jobId, options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) return []; + const includeMissing = Boolean(options?.includeMissing); + + const relatedJobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { + includeRelated: true, + includeLogLinks: false + }); + const relatedJobIds = Array.from(new Set( + relatedJobs + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + )); + if (relatedJobIds.length === 0) { + return []; + } + + const db = await getDb(); + const placeholders = relatedJobIds.map(() => '?').join(', '); + const trackedRows = await db.all( + ` + SELECT id, job_id, output_path, label, created_at + FROM job_output_folders + WHERE job_id IN (${placeholders}) + ORDER BY id ASC + `, + relatedJobIds + ); + const artifactsByJobId = await this.listJobLineageArtifactsByJobIds(relatedJobIds); + + const merged = []; + const seen = new Set(); + const addFolder = (rawFolder, defaults = {}) => { + const outputPathRaw = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim(); + if (!outputPathRaw) { + return; + } + const normalizedRawPath = normalizeComparablePath(outputPathRaw); + if (!normalizedRawPath) { + return; + } + let exists = false; + let resolvedOutputPath = normalizedRawPath; + try { + exists = fs.existsSync(normalizedRawPath); + } catch (_error) { + exists = false; + } + if (!exists) { + const repairedPath = resolveExistingOutputPathVariant(normalizedRawPath); + if (repairedPath) { + resolvedOutputPath = repairedPath; + try { + exists = fs.existsSync(repairedPath); + } catch (_error) { + exists = false; + } + } + } + const normalized = normalizeComparablePath(resolvedOutputPath); + if (!normalized || seen.has(normalized)) { + return; + } + if (!includeMissing && !exists) { + return; + } + seen.add(normalized); + merged.push({ + id: normalizeJobIdValue(rawFolder?.id), + job_id: normalizeJobIdValue(rawFolder?.job_id ?? rawFolder?.jobId ?? defaults.job_id ?? normalizedJobId), + output_path: resolvedOutputPath, + label: String(rawFolder?.label || defaults.label || '').trim() || null, + created_at: String(rawFolder?.created_at || rawFolder?.createdAt || '').trim() || null, + exists + }); + }; + + for (const row of (Array.isArray(trackedRows) ? trackedRows : [])) { + addFolder(row); + } + for (const relatedJob of relatedJobs) { + addFolder({ + output_path: relatedJob?.output_path || null, + job_id: relatedJob?.id || null + }); + const artifacts = artifactsByJobId.get(normalizeJobIdValue(relatedJob?.id)) || []; + for (const artifact of artifacts) { + addFolder({ + output_path: artifact?.outputPath || null, + job_id: relatedJob?.id || null, + label: 'Lineage-Output', + created_at: artifact?.createdAt || null + }); + } + } + + // Fallback for legacy runs before output-folder tracking: + // detect numbered sibling directories on filesystem + // /path/Album (1995), /path/Album (1995)_2, /path/Album (1995)_3, ... + const seedPaths = merged + .filter((row) => Boolean(row?.exists)) + .map((row) => String(row?.output_path || '').trim()) + .filter(Boolean); + for (const seedPath of seedPaths) { + const siblings = inferSiblingOutputFolders(seedPath); + for (const siblingPath of siblings) { + addFolder({ + output_path: siblingPath, + job_id: normalizedJobId, + label: 'Auto-erkannt' + }); + } + } + + merged.sort((left, right) => compareOutputFolderPaths(left?.output_path, right?.output_path)); + return merged; + } + + async removeJobOutputFolder(jobId, outputPath) { + const normalizedJobId = Number(jobId); + const normalizedPath = normalizeComparablePath(outputPath); + if (!normalizedJobId || !normalizedPath) return; + const db = await getDb(); + const rows = await db.all( + 'SELECT id, output_path FROM job_output_folders WHERE job_id = ?', + [normalizedJobId] + ); + for (const row of (Array.isArray(rows) ? rows : [])) { + const candidatePath = normalizeComparablePath(row?.output_path); + if (!candidatePath || candidatePath !== normalizedPath) { + continue; + } + await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]); + } + } + + async removeJobOutputFolderFromJobs(jobIds = [], outputPath) { + const normalizedPath = normalizeComparablePath(outputPath); + const normalizedJobIds = Array.isArray(jobIds) + ? jobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + if (!normalizedPath || normalizedJobIds.length === 0) return; + const db = await getDb(); + const placeholders = normalizedJobIds.map(() => '?').join(', '); + const rows = await db.all( + `SELECT id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`, + normalizedJobIds + ); + for (const row of (Array.isArray(rows) ? rows : [])) { + const candidatePath = normalizeComparablePath(row?.output_path); + if (!candidatePath || candidatePath !== normalizedPath) { + continue; + } + await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]); + } + } + + async removeMissingJobOutputFoldersFromJobs(jobIds = []) { + const normalizedJobIds = Array.isArray(jobIds) + ? jobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + if (normalizedJobIds.length === 0) { + return { removed: 0, removedPaths: [] }; + } + const db = await getDb(); + const placeholders = normalizedJobIds.map(() => '?').join(', '); + const rows = await db.all( + `SELECT id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`, + normalizedJobIds + ); + const removedPaths = []; + for (const row of (Array.isArray(rows) ? rows : [])) { + const normalizedPath = normalizeComparablePath(row?.output_path); + let removeEntry = false; + if (!normalizedPath) { + removeEntry = true; + } else if (!fs.existsSync(normalizedPath)) { + removeEntry = true; + } else { + try { + const stat = fs.lstatSync(normalizedPath); + if (stat.isDirectory()) { + const entries = fs.readdirSync(normalizedPath); + if (entries.length === 0) { + fs.rmdirSync(normalizedPath); + removeEntry = true; + } + } + } catch (_error) { + removeEntry = true; + } + } + if (!removeEntry) { + continue; + } + await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]); + if (normalizedPath) { + removedPaths.push(normalizedPath); + } + } + return { + removed: removedPaths.length, + removedPaths: Array.from(new Set(removedPaths)).sort((left, right) => left.localeCompare(right, 'de')) + }; + } + + async deleteSpecificOutputFolders(jobId, folderPaths = []) { + const paths = Array.isArray(folderPaths) ? folderPaths.filter((p) => typeof p === 'string' && p.trim()) : []; + if (paths.length === 0) return { deleted: [], failed: [] }; + + const job = await this.getJobById(jobId); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const settings = await settingsService.getSettingsMap(); + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const effectiveMovieDir = resolvedPaths.movieDir; + if (!effectiveMovieDir) { + const error = new Error('Kein gültiger Movie-Basispfad konfiguriert.'); + error.statusCode = 400; + throw error; + } + + const deleted = []; + const failed = []; + const relatedJobs = await this._resolveRelatedJobsForDeletion(jobId, { + includeRelated: true, + includeLogLinks: false + }); + const relatedJobIds = Array.from(new Set( + relatedJobs + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + )); + const trackedOutputFolders = await this.getJobOutputFoldersForLineage(jobId, { includeMissing: true }); + const trackedOutputPathSet = new Set( + trackedOutputFolders + .map((folder) => normalizeComparablePath(folder?.output_path)) + .filter(Boolean) + ); + + for (const folderPath of paths) { + const normalizedFolderPath = normalizeComparablePath(folderPath); + const isInsideEffectiveRoot = isPathInside(effectiveMovieDir, normalizedFolderPath); + const isTrackedPath = trackedOutputPathSet.has(normalizedFolderPath); + if (!isInsideEffectiveRoot && !isTrackedPath) { + failed.push({ path: folderPath, reason: 'Pfad liegt außerhalb des Output-Verzeichnisses.' }); + continue; + } + if (!fs.existsSync(normalizedFolderPath)) { + await this.removeJobOutputFolderFromJobs(relatedJobIds, normalizedFolderPath); + deleted.push(normalizedFolderPath); + continue; + } + try { + const stat = fs.lstatSync(normalizedFolderPath); + const movieRoot = normalizeComparablePath(effectiveMovieDir); + if (stat.isDirectory()) { + const keepRoot = normalizedFolderPath === movieRoot; + deleteFilesRecursively(normalizedFolderPath, keepRoot); + if (!keepRoot) { + removeEmptyParentDirectories(path.dirname(normalizedFolderPath), { + protectedRoots: [movieRoot] + }); + } + } else { + const parentDir = normalizeComparablePath(path.dirname(normalizedFolderPath)); + const canDeleteParent = parentDir && parentDir !== movieRoot + && isPathInside(movieRoot, parentDir) + && fs.existsSync(parentDir) + && fs.lstatSync(parentDir).isDirectory(); + if (canDeleteParent) { + deleteFilesRecursively(parentDir, false); + removeEmptyParentDirectories(path.dirname(parentDir), { + protectedRoots: [movieRoot] + }); + } else { + fs.unlinkSync(normalizedFolderPath); + if (parentDir) { + removeEmptyParentDirectories(parentDir, { + protectedRoots: [movieRoot] + }); + } + } + } + await this.removeJobOutputFolderFromJobs(relatedJobIds, normalizedFolderPath); + deleted.push(normalizedFolderPath); + } catch (error) { + failed.push({ path: folderPath, reason: error.message }); + } + } + + await this.appendLog( + jobId, + 'USER_ACTION', + `Output-Ordner gelöscht: ${deleted.length > 0 ? deleted.join(', ') : 'keine'}${failed.length > 0 ? ` | Fehler: ${failed.map((f) => f.path).join(', ')}` : ''}` + ); + return { deleted, failed }; + } + + async _cleanupEmptyDirectoriesForDeletedJobs(jobRows = [], options = {}) { + const rows = Array.isArray(jobRows) + ? jobRows.filter((row) => normalizeJobIdValue(row?.id)) + : []; + if (rows.length === 0) { + return { + attemptedCandidates: { raw: 0, movie: 0 }, + removed: { raw: 0, movie: 0, total: 0 }, + removedPaths: [], + failures: [] + }; + } + + const jobIds = Array.from(new Set( + rows + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + )); + + const settings = options?.settings && typeof options.settings === 'object' + ? options.settings + : await settingsService.getSettingsMap(); + const lineageArtifactsByJobId = options?.lineageArtifactsByJobId instanceof Map + ? options.lineageArtifactsByJobId + : await this.listJobLineageArtifactsByJobIds(jobIds).catch(() => new Map()); + const outputFoldersByJobId = options?.outputFoldersByJobId instanceof Map + ? options.outputFoldersByJobId + : await this.listJobOutputFoldersByJobIds(jobIds).catch(() => new Map()); + + const candidateSets = { + raw: new Set(), + movie: new Set() + }; + const protectedRootSets = { + raw: new Set(), + movie: new Set() + }; + for (const row of rows) { + const rowId = normalizeJobIdValue(row?.id); + if (!rowId) { + continue; + } + const lineageArtifacts = lineageArtifactsByJobId.get(rowId) || []; + const trackedOutputPaths = (outputFoldersByJobId.get(rowId) || []) + .map((folder) => String(folder?.output_path || '').trim()) + .filter(Boolean); + const collected = this._collectDeleteCandidatesForJob(row, settings, { + lineageArtifacts, + trackedOutputPaths + }); + for (const rootPath of (collected?.rawRoots || [])) { + const normalizedRoot = normalizeComparablePath(rootPath); + if (normalizedRoot && !isFilesystemRootPath(normalizedRoot)) { + protectedRootSets.raw.add(normalizedRoot); + } + } + for (const rootPath of (collected?.movieRoots || [])) { + const normalizedRoot = normalizeComparablePath(rootPath); + if (normalizedRoot && !isFilesystemRootPath(normalizedRoot)) { + protectedRootSets.movie.add(normalizedRoot); + } + } + for (const candidate of (collected?.rawCandidates || [])) { + const normalizedPath = normalizeComparablePath(candidate?.path); + if (normalizedPath && !isFilesystemRootPath(normalizedPath)) { + candidateSets.raw.add(normalizedPath); + } + } + for (const candidate of (collected?.movieCandidates || [])) { + const normalizedPath = normalizeComparablePath(candidate?.path); + if (normalizedPath && !isFilesystemRootPath(normalizedPath)) { + candidateSets.movie.add(normalizedPath); + } + } + } + + const removedPaths = []; + const removedPathSet = new Set(); + const failures = []; + const removedCountByTarget = { + raw: 0, + movie: 0 + }; + + const rememberRemovedPath = (target, candidatePath) => { + const normalizedPath = normalizeComparablePath(candidatePath); + if (!normalizedPath || removedPathSet.has(normalizedPath)) { + return; + } + removedPathSet.add(normalizedPath); + removedPaths.push(normalizedPath); + if (target === 'raw' || target === 'movie') { + removedCountByTarget[target] += 1; + } + }; + + const cleanupTarget = (target) => { + const protectedRoots = Array.from(protectedRootSets[target] || []); + const protectedRootSet = new Set(protectedRoots); + const candidates = Array.from(candidateSets[target] || []) + .filter(Boolean) + .sort((left, right) => String(right || '').length - String(left || '').length); + + for (const candidatePathRaw of candidates) { + const candidatePath = normalizeComparablePath(candidatePathRaw); + if (!candidatePath || isFilesystemRootPath(candidatePath)) { + continue; + } + + const inspection = inspectDeletionPath(candidatePath); + if (inspection.exists && inspection.isDirectory) { + if (protectedRootSet.has(inspection.path)) { + continue; + } + if (protectedRoots.length > 0 && !protectedRoots.some((rootPath) => isPathInside(rootPath, inspection.path))) { + continue; + } + try { + const entries = fs.readdirSync(inspection.path); + if (entries.length > 0) { + continue; + } + fs.rmdirSync(inspection.path); + rememberRemovedPath(target, inspection.path); + if (protectedRoots.length > 0) { + const removedParentDirs = removeEmptyParentDirectories(path.dirname(inspection.path), { + protectedRoots + }); + for (const removedPath of removedParentDirs) { + rememberRemovedPath(target, removedPath); + } + } + } catch (error) { + if (error?.code !== 'ENOENT') { + failures.push({ + target, + path: inspection.path, + error: error?.message || String(error) + }); + } + } + continue; + } + + const parentDir = normalizeComparablePath(path.dirname(candidatePath)); + if (!parentDir || isFilesystemRootPath(parentDir)) { + continue; + } + if (protectedRoots.length === 0) { + continue; + } + if (!protectedRoots.some((rootPath) => isPathInside(rootPath, parentDir))) { + continue; + } + try { + const removedParentDirs = removeEmptyParentDirectories(parentDir, { + protectedRoots + }); + for (const removedPath of removedParentDirs) { + rememberRemovedPath(target, removedPath); + } + } catch (error) { + failures.push({ + target, + path: parentDir, + error: error?.message || String(error) + }); + } + } + }; + + cleanupTarget('raw'); + cleanupTarget('movie'); + + return { + attemptedCandidates: { + raw: (candidateSets.raw || new Set()).size, + movie: (candidateSets.movie || new Set()).size + }, + removed: { + raw: removedCountByTarget.raw, + movie: removedCountByTarget.movie, + total: removedPaths.length + }, + removedPaths: [...removedPaths].sort((left, right) => left.localeCompare(right, 'de')), + failures + }; + } + + async deleteJob(jobId, fileTarget = 'none', options = {}) { + const allowedTargets = new Set(['none', 'raw', 'movie', 'both']); + if (!allowedTargets.has(fileTarget)) { + const error = new Error(`Ungültiges target '${fileTarget}'. Erlaubt: none, raw, movie, both.`); + error.statusCode = 400; + throw error; + } + + const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs); + const isOrphanRawImportJobRow = (row) => { + if (!row || typeof row !== 'object') { + return false; + } + const mkInfo = parseJsonSafe(row?.makemkv_info_json, {}); + return isOrphanRawImportMakemkvInfo(mkInfo); + }; + const resolveEffectiveFileTarget = (requestedTarget, row) => { + if (!preserveRawForImportJobs || !isOrphanRawImportJobRow(row)) { + return requestedTarget; + } + const normalizedRequested = String(requestedTarget || 'none').trim().toLowerCase(); + if (normalizedRequested === 'raw') { + return 'none'; + } + if (normalizedRequested === 'both') { + return 'movie'; + } + return normalizedRequested; + }; + const collectIncompleteMoviePathsFromPreview = (preview) => { + const rows = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : []; + const incompleteJobPattern = /(^|[\\/])incomplete_job-\d+([\\/]|$)/i; + const incompleteMergePattern = /(^|[\\/])incomplete_merge_[^\\/]+_job_\d+([\\/]|$)/i; + return rows + .filter((row) => Boolean(row?.exists)) + .filter((row) => { + const candidatePath = String(row?.path || '').trim(); + if (!candidatePath) { + return false; + } + if (incompleteJobPattern.test(candidatePath)) { + return true; + } + if (incompleteMergePattern.test(candidatePath)) { + // Shared multipart merge folders must never be auto-selected as a whole directory. + return Boolean(row?.isFile); + } + return false; + }) + .map((row) => String(row?.path || '').trim()) + .filter(Boolean); + }; + const collectIncompleteRawPathsFromPreview = (preview, selectedJobRows = []) => { + const rows = Array.isArray(selectedJobRows) ? selectedJobRows : []; + if (rows.length === 0) { + return []; + } + + const incompleteCancelledJobIds = new Set( + rows + .filter((row) => { + if (!row || typeof row !== 'object') { + return false; + } + if (isOrphanRawImportJobRow(row)) { + return false; + } + const status = String(row?.status || '').trim().toUpperCase(); + const lastState = String(row?.last_state || '').trim().toUpperCase(); + const isCancelled = status === 'CANCELLED' || lastState === 'CANCELLED'; + if (!isCancelled) { + return false; + } + return Number(row?.rip_successful || 0) !== 1; + }) + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + ); + if (incompleteCancelledJobIds.size === 0) { + return []; + } + + const rawRows = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : []; + return rawRows + .filter((row) => Boolean(row?.exists)) + .filter((row) => { + const jobIds = Array.isArray(row?.jobIds) ? row.jobIds : []; + return jobIds.some((jobId) => incompleteCancelledJobIds.has(normalizeJobIdValue(jobId))); + }) + .map((row) => String(row?.path || '').trim()) + .filter(Boolean); + }; + const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => { + const normalizedOwnerJobIds = Array.from(new Set( + (Array.isArray(ownerJobIds) ? ownerJobIds : []) + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + )); + const rows = Array.isArray(jobRows) ? jobRows : []; + if (rows.length === 0) { + return { attempted: 0, deleted: 0, failed: 0, paths: [] }; + } + + const settings = await settingsService.getSettingsMap(); + const candidatePaths = new Set(); + const fallbackIncompletePattern = /^incomplete_job-(\d+)\s*$/i; + + for (const row of rows) { + const rowId = normalizeJobIdValue(row?.id); + if (!rowId) { + continue; + } + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row); + const movieRoot = normalizeComparablePath(resolvedPaths?.movieDir); + if (!movieRoot || isFilesystemRootPath(movieRoot)) { + continue; + } + const canonicalPath = normalizeComparablePath(path.join(movieRoot, `Incomplete_job-${rowId}`)); + if (canonicalPath && isPathInside(movieRoot, canonicalPath)) { + candidatePaths.add(canonicalPath); + } + try { + if (!fs.existsSync(movieRoot) || !fs.lstatSync(movieRoot).isDirectory()) { + continue; + } + const entries = fs.readdirSync(movieRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry?.isDirectory?.()) { + continue; + } + const match = String(entry?.name || '').match(fallbackIncompletePattern); + if (normalizeJobIdValue(match?.[1]) !== rowId) { + continue; + } + const scannedPath = normalizeComparablePath(path.join(movieRoot, entry.name)); + if (scannedPath && isPathInside(movieRoot, scannedPath)) { + candidatePaths.add(scannedPath); + } + } + } catch (_error) { + // best-effort scan + } + } + + if (candidatePaths.size === 0) { + return { attempted: 0, deleted: 0, failed: 0, paths: [] }; + } + + const deletedPaths = []; + let failedCount = 0; + for (const candidatePath of candidatePaths) { + try { + const inspection = inspectDeletionPath(candidatePath); + if (!inspection.exists) { + await this.removeJobOutputFolderFromJobs(normalizedOwnerJobIds, candidatePath); + deletedPaths.push(candidatePath); + continue; + } + if (inspection.isDirectory) { + deleteFilesRecursively(inspection.path, false); + } else if (inspection.isFile) { + fs.unlinkSync(inspection.path); + } + await this.removeJobOutputFolderFromJobs(normalizedOwnerJobIds, candidatePath); + deletedPaths.push(candidatePath); + } catch (_error) { + failedCount += 1; + } + } + + return { + attempted: candidatePaths.size, + deleted: deletedPaths.length, + failed: failedCount, + paths: deletedPaths + }; + }; + const normalizedSelectedRawPaths = Array.isArray(options?.selectedRawPaths) + ? options.selectedRawPaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const normalizedSelectedMoviePaths = Array.isArray(options?.selectedMoviePaths) + ? options.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const normalizedSelectedJobIds = Array.isArray(options?.selectedJobIds) + ? options.selectedJobIds + .map((item) => normalizeJobIdValue(item)) + .filter(Boolean) + : null; + + const includeRelated = Boolean(options?.includeRelated); + if (!includeRelated) { + const resolved = await this.getJobByIdOrReplacement(jobId); + const existing = resolved?.job || null; + const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId); + if (!existing) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + if (isDiskContainerRow(existing)) { + const containerChildren = await this.listChildJobs(normalizedJobId); + if (Array.isArray(containerChildren) && containerChildren.length > 0) { + const error = new Error( + 'Container kann nicht einzeln gelöscht werden, solange noch Child-Jobs vorhanden sind.' + ); + error.statusCode = 409; + throw error; + } + } + + const cleanupDeletedJobRows = [existing]; + let emptyDirectoryCleanupContext = { + settings: null, + lineageArtifactsByJobId: new Map(), + outputFoldersByJobId: new Map() + }; + try { + const [cleanupSettings, cleanupLineageArtifactsByJobId, cleanupOutputFoldersByJobId] = await Promise.all([ + settingsService.getSettingsMap(), + this.listJobLineageArtifactsByJobIds([normalizedJobId]), + this.listJobOutputFoldersByJobIds([normalizedJobId]) + ]); + emptyDirectoryCleanupContext = { + settings: cleanupSettings, + lineageArtifactsByJobId: cleanupLineageArtifactsByJobId, + outputFoldersByJobId: cleanupOutputFoldersByJobId + }; + } catch (error) { + logger.warn('job:delete:empty-dir-context-failed', { + jobId: normalizedJobId, + includeRelated: false, + error: error?.message || String(error) + }); + } + + let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); + let selectedRawPathsForDelete = normalizedSelectedRawPaths; + let selectedMoviePathsForDelete = normalizedSelectedMoviePaths; + let preview = null; + let fileSummary = null; + if (effectiveFileTarget === 'none') { + preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false }); + const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview); + const autoIncompleteRawPaths = collectIncompleteRawPathsFromPreview(preview, [existing]); + if (autoIncompleteRawPaths.length > 0) { + effectiveFileTarget = autoIncompleteMoviePaths.length > 0 ? 'both' : 'raw'; + if (!selectedRawPathsForDelete || selectedRawPathsForDelete.length === 0) { + selectedRawPathsForDelete = autoIncompleteRawPaths; + } + } + if (autoIncompleteMoviePaths.length > 0) { + effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie'; + if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) { + selectedMoviePathsForDelete = autoIncompleteMoviePaths; + } + } + } + if (effectiveFileTarget !== 'none') { + if (!preview) { + preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false }); + } + fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, { + selectedRawPaths: selectedRawPathsForDelete, + selectedMoviePaths: selectedMoviePathsForDelete + }); + if (fileSummary?.movie?.deleted) { + await this.removeMissingJobOutputFoldersFromJobs([normalizedJobId]); + } + } + + const db = await getDb(); + const pipelineRow = await db.get( + 'SELECT state, active_job_id FROM pipeline_state WHERE id = 1' + ); + + const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(normalizedJobId); + const runningStates = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); + + if (isActivePipelineJob && runningStates.has(String(pipelineRow?.state || ''))) { + const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.'); + error.statusCode = 409; + throw error; + } + + await db.exec('BEGIN'); + try { + if (isActivePipelineJob) { + await db.run( + ` + UPDATE pipeline_state + SET + state = 'IDLE', + active_job_id = NULL, + progress = 0, + eta = NULL, + status_text = 'Bereit', + context_json = '{}', + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + ` + ); + } else { + await db.run( + ` + UPDATE pipeline_state + SET + active_job_id = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 AND active_job_id = ? + `, + [normalizedJobId] + ); + } + + await db.run('DELETE FROM jobs WHERE id = ?', [normalizedJobId]); + const stillExists = await db.get('SELECT id FROM jobs WHERE id = ?', [normalizedJobId]); + if (stillExists) { + const error = new Error(`Job #${normalizedJobId} konnte nicht aus der Datenbank entfernt werden.`); + error.statusCode = 409; + throw error; + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + await this.closeProcessLog(normalizedJobId); + this._deleteProcessLogFile(normalizedJobId); + thumbnailService.deleteThumbnail(normalizedJobId); + const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both'; + let incompleteCleanupSummary = null; + if (includeMovieCleanup) { + incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]); + } + let emptyDirectoryCleanup = null; + try { + emptyDirectoryCleanup = await this._cleanupEmptyDirectoriesForDeletedJobs( + cleanupDeletedJobRows, + emptyDirectoryCleanupContext + ); + } catch (error) { + logger.warn('job:delete:empty-dir-cleanup-failed', { + jobId: normalizedJobId, + includeRelated: false, + error: error?.message || String(error) + }); + } + + logger.warn('job:deleted', { + jobId: normalizedJobId, + fileTarget: effectiveFileTarget, + requestedFileTarget: fileTarget, + includeRelated: false, + pipelineStateReset: isActivePipelineJob, + incompleteCleanup: incompleteCleanupSummary, + emptyDirectoryCleanup, + filesDeleted: fileSummary + ? { + raw: fileSummary.raw?.filesDeleted ?? 0, + movie: fileSummary.movie?.filesDeleted ?? 0 + } + : { raw: 0, movie: 0 } + }); + + return { + deleted: true, + jobId: normalizedJobId, + fileTarget: effectiveFileTarget, + requestedFileTarget: fileTarget, + includeRelated: false, + deletedJobIds: [Number(normalizedJobId)], + fileSummary, + incompleteCleanup: incompleteCleanupSummary, + emptyDirectoryCleanup + }; + } + + const resolved = await this.getJobByIdOrReplacement(jobId); + const existing = resolved?.job || null; + const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId); + const preview = await this.getJobDeletePreview(normalizedJobId, { + includeRelated: true, + selectedJobIds: normalizedSelectedJobIds + }); + let deleteJobIds = Array.isArray(preview?.selectedJobIds) + ? preview.selectedJobIds + .map((row) => normalizeJobIdValue(row)) + .filter(Boolean) + : []; + let rowById = new Map(); + const hasExplicitSelectedJobFilter = Array.isArray(normalizedSelectedJobIds); + if (!hasExplicitSelectedJobFilter && normalizedJobId && !deleteJobIds.includes(normalizedJobId)) { + deleteJobIds = [...deleteJobIds, normalizedJobId]; + } + if (deleteJobIds.length > 0) { + const db = await getDb(); + const placeholders = deleteJobIds.map(() => '?').join(', '); + const rows = await db.all( + `SELECT * FROM jobs WHERE id IN (${placeholders})`, + deleteJobIds + ); + rowById = new Map( + (Array.isArray(rows) ? rows : []) + .map((row) => [normalizeJobIdValue(row?.id), row]) + .filter(([id]) => Boolean(id)) + ); + const deleteSet = new Set(deleteJobIds); + // Safety net: never delete a series container while it would still have + // other children afterwards. + for (const candidateId of [...deleteJobIds]) { + const row = rowById.get(candidateId); + if (!row || !isDiskContainerRow(row)) { + continue; + } + const childRows = await db.all( + `SELECT id FROM jobs WHERE parent_job_id = ?`, + [candidateId] + ); + const hasRemainingChildOutsideDeleteSet = (Array.isArray(childRows) ? childRows : []).some((childRow) => { + const childId = normalizeJobIdValue(childRow?.id); + return Boolean(childId && !deleteSet.has(childId)); + }); + if (hasRemainingChildOutsideDeleteSet) { + deleteSet.delete(candidateId); + } + } + deleteJobIds = Array.from(deleteSet); + } + if (deleteJobIds.length === 0) { + const error = new Error('Keine löschbaren Historien-Einträge gefunden.'); + error.statusCode = 404; + throw error; + } + + const db = await getDb(); + const pipelineRow = await db.get('SELECT state, active_job_id FROM pipeline_state WHERE id = 1'); + const activePipelineJobId = normalizeJobIdValue(pipelineRow?.active_job_id); + const activeJobIncluded = Boolean(activePipelineJobId && deleteJobIds.includes(activePipelineJobId)); + const runningStates = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); + if (activeJobIncluded && runningStates.has(String(pipelineRow?.state || ''))) { + const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.'); + error.statusCode = 409; + throw error; + } + + const deletedJobRows = deleteJobIds + .map((id) => rowById.get(id)) + .filter(Boolean); + let emptyDirectoryCleanupContext = { + settings: null, + lineageArtifactsByJobId: new Map(), + outputFoldersByJobId: new Map() + }; + try { + const [cleanupSettings, cleanupLineageArtifactsByJobId, cleanupOutputFoldersByJobId] = await Promise.all([ + settingsService.getSettingsMap(), + this.listJobLineageArtifactsByJobIds(deleteJobIds), + this.listJobOutputFoldersByJobIds(deleteJobIds) + ]); + emptyDirectoryCleanupContext = { + settings: cleanupSettings, + lineageArtifactsByJobId: cleanupLineageArtifactsByJobId, + outputFoldersByJobId: cleanupOutputFoldersByJobId + }; + } catch (error) { + logger.warn('job:delete:empty-dir-context-failed', { + jobId: normalizedJobId, + includeRelated: true, + deleteJobIds, + error: error?.message || String(error) + }); + } + + let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); + let selectedRawPathsForDelete = normalizedSelectedRawPaths; + let selectedMoviePathsForDelete = normalizedSelectedMoviePaths; + if (effectiveFileTarget === 'none') { + const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview); + const selectedDeleteRows = deleteJobIds + .map((id) => rowById.get(id)) + .filter(Boolean); + const autoIncompleteRawPaths = collectIncompleteRawPathsFromPreview(preview, selectedDeleteRows); + if (autoIncompleteRawPaths.length > 0) { + effectiveFileTarget = autoIncompleteMoviePaths.length > 0 ? 'both' : 'raw'; + if (!selectedRawPathsForDelete || selectedRawPathsForDelete.length === 0) { + selectedRawPathsForDelete = autoIncompleteRawPaths; + } + } + if (autoIncompleteMoviePaths.length > 0) { + effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie'; + if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) { + selectedMoviePathsForDelete = autoIncompleteMoviePaths; + } + } + } + let fileSummary = null; + if (effectiveFileTarget !== 'none') { + fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, { + selectedRawPaths: selectedRawPathsForDelete, + selectedMoviePaths: selectedMoviePathsForDelete + }); + if (fileSummary?.movie?.deleted) { + await this.removeMissingJobOutputFoldersFromJobs(deleteJobIds); + } + } + + await db.exec('BEGIN'); + try { + if (activeJobIncluded) { + await db.run( + ` + UPDATE pipeline_state + SET + state = 'IDLE', + active_job_id = NULL, + progress = 0, + eta = NULL, + status_text = 'Bereit', + context_json = '{}', + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + ` + ); + } else { + const placeholders = deleteJobIds.map(() => '?').join(', '); + await db.run( + ` + UPDATE pipeline_state + SET + active_job_id = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 AND active_job_id IN (${placeholders}) + `, + deleteJobIds + ); + } + + const deletePlaceholders = deleteJobIds.map(() => '?').join(', '); + await db.run(`DELETE FROM jobs WHERE id IN (${deletePlaceholders})`, deleteJobIds); + const deletedVerificationRows = await db.all( + `SELECT id FROM jobs WHERE id IN (${deletePlaceholders})`, + deleteJobIds + ); + if (Array.isArray(deletedVerificationRows) && deletedVerificationRows.length > 0) { + const remainingIds = deletedVerificationRows + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean); + const error = new Error( + `Folgende Jobs konnten nicht gelöscht werden: ${remainingIds.join(', ')}` + ); + error.statusCode = 409; + throw error; + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + for (const deletedJobId of deleteJobIds) { + await this.closeProcessLog(deletedJobId); + this._deleteProcessLogFile(deletedJobId); + thumbnailService.deleteThumbnail(deletedJobId); + } + const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both'; + let incompleteCleanupSummary = null; + if (includeMovieCleanup) { + incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs(deletedJobRows, deleteJobIds); + } + let emptyDirectoryCleanup = null; + try { + emptyDirectoryCleanup = await this._cleanupEmptyDirectoriesForDeletedJobs( + deletedJobRows, + emptyDirectoryCleanupContext + ); + } catch (error) { + logger.warn('job:delete:empty-dir-cleanup-failed', { + jobId: normalizedJobId, + includeRelated: true, + deleteJobIds, + error: error?.message || String(error) + }); + } + + const deletedJobIdSet = new Set(deleteJobIds); + const deletedJobs = Array.isArray(preview?.relatedJobs) + ? preview.relatedJobs.filter((row) => deletedJobIdSet.has(normalizeJobIdValue(row?.id))) + : []; + + logger.warn('job:deleted', { + jobId: normalizedJobId, + fileTarget: effectiveFileTarget, + requestedFileTarget: fileTarget, + includeRelated: true, + deletedJobIds: deleteJobIds, + deletedJobCount: deleteJobIds.length, + pipelineStateReset: activeJobIncluded, + incompleteCleanup: incompleteCleanupSummary, + emptyDirectoryCleanup, + filesDeleted: fileSummary + ? { + raw: fileSummary.raw?.filesDeleted ?? 0, + movie: fileSummary.movie?.filesDeleted ?? 0 + } + : { raw: 0, movie: 0 } + }); + + return { + deleted: true, + jobId: normalizedJobId, + fileTarget: effectiveFileTarget, + requestedFileTarget: fileTarget, + includeRelated: true, + deletedJobIds: deleteJobIds, + deletedJobs, + fileSummary, + incompleteCleanup: incompleteCleanupSummary, + emptyDirectoryCleanup + }; + } +} + +module.exports = new HistoryService(); diff --git a/backend/src/services/logPathService.js b/backend/src/services/logPathService.js new file mode 100644 index 0000000..1f9a402 --- /dev/null +++ b/backend/src/services/logPathService.js @@ -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 +}; diff --git a/backend/src/services/logger.js b/backend/src/services/logger.js new file mode 100644 index 0000000..90454b0 --- /dev/null +++ b/backend/src/services/logger.js @@ -0,0 +1,239 @@ +const fs = require('fs'); +const path = require('path'); +const { logLevel } = require('../config'); +const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService'); +const { getServerTimestamp } = require('../utils/serverTime'); + +const LEVELS = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; + +const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info; +let consoleConfig = { + enabled: true, + levels: { + debug: true, + info: true, + warn: true, + error: true + }, + http: true +}; + +function normalizeBoolean(value, fallback = true) { + if (value === null || value === undefined) { + return fallback; + } + return Boolean(value); +} + +function ensureLogDir(logDirPath) { + try { + fs.mkdirSync(logDirPath, { recursive: true }); + return true; + } catch (_error) { + return false; + } +} + +function resolveWritableBackendLogDir() { + const preferred = getBackendLogDir(); + if (ensureLogDir(preferred)) { + return preferred; + } + + const fallback = path.join(getFallbackLogRootDir(), 'backend'); + if (fallback !== preferred && ensureLogDir(fallback)) { + return fallback; + } + + return null; +} + +function getDailyFileName() { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `backend-${y}-${m}-${day}.log`; +} + +function safeJson(value) { + try { + return JSON.stringify(value); + } catch (error) { + return JSON.stringify({ serializationError: error.message }); + } +} + +function truncateString(value, maxLen = 3000) { + const str = String(value); + if (str.length <= maxLen) { + return str; + } + return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`; +} + +function sanitizeMeta(meta) { + if (!meta || typeof meta !== 'object') { + return meta; + } + + const out = Array.isArray(meta) ? [] : {}; + + for (const [key, val] of Object.entries(meta)) { + if (val instanceof Error) { + out[key] = { + name: val.name, + message: val.message, + stack: val.stack + }; + continue; + } + + if (typeof val === 'string') { + out[key] = truncateString(val, 5000); + continue; + } + + out[key] = val; + } + + return out; +} + +function writeLine(line) { + const backendLogDir = resolveWritableBackendLogDir(); + if (!backendLogDir) { + return; + } + const daily = path.join(backendLogDir, getDailyFileName()); + const latest = path.join(backendLogDir, 'backend-latest.log'); + + fs.appendFile(daily, `${line}\n`, (_error) => null); + fs.appendFile(latest, `${line}\n`, (_error) => null); +} + +function emit(level, scope, message, meta = null) { + const normLevel = String(level || 'info').toLowerCase(); + const lvl = LEVELS[normLevel] || LEVELS.info; + if (lvl < ACTIVE_LEVEL) { + return; + } + + const timestamp = getServerTimestamp(); + const payload = { + timestamp, + level: normLevel, + scope, + message, + meta: sanitizeMeta(meta) + }; + + const line = safeJson(payload); + writeLine(line); + + if (!shouldEmitToConsole(normLevel, scope)) { + return; + } + + const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`; + if (normLevel === 'error') { + console.error(print, payload.meta ? payload.meta : ''); + } else if (normLevel === 'warn') { + console.warn(print, payload.meta ? payload.meta : ''); + } else { + console.log(print, payload.meta ? payload.meta : ''); + } +} + +function shouldEmitToConsole(level, scope) { + if (!consoleConfig.enabled) { + return false; + } + + const normalizedLevel = String(level || 'info').toLowerCase(); + if (consoleConfig.levels[normalizedLevel] === false) { + return false; + } + + const normalizedScope = String(scope || '').trim().toUpperCase(); + if (normalizedScope === 'HTTP' && !consoleConfig.http) { + return false; + } + + return true; +} + +function child(scope) { + return { + debug(message, meta) { + emit('debug', scope, message, meta); + }, + info(message, meta) { + emit('info', scope, message, meta); + }, + warn(message, meta) { + emit('warn', scope, message, meta); + }, + error(message, meta) { + emit('error', scope, message, meta); + } + }; +} + +function setConsoleOutputEnabled(enabled) { + consoleConfig.enabled = Boolean(enabled); + return consoleConfig.enabled; +} + +function isConsoleOutputEnabled() { + return consoleConfig.enabled; +} + +function configureConsoleOutput(options = {}) { + const opts = options && typeof options === 'object' ? options : {}; + if (Object.prototype.hasOwnProperty.call(opts, 'enabled')) { + consoleConfig.enabled = normalizeBoolean(opts.enabled, true); + } + + if (opts.levels && typeof opts.levels === 'object') { + const sourceLevels = opts.levels; + for (const level of Object.keys(LEVELS)) { + if (Object.prototype.hasOwnProperty.call(sourceLevels, level)) { + consoleConfig.levels[level] = normalizeBoolean(sourceLevels[level], true); + } + } + } + + if (Object.prototype.hasOwnProperty.call(opts, 'http')) { + consoleConfig.http = normalizeBoolean(opts.http, true); + } + + return getConsoleOutputConfig(); +} + +function getConsoleOutputConfig() { + return { + enabled: Boolean(consoleConfig.enabled), + levels: { + debug: Boolean(consoleConfig.levels.debug), + info: Boolean(consoleConfig.levels.info), + warn: Boolean(consoleConfig.levels.warn), + error: Boolean(consoleConfig.levels.error) + }, + http: Boolean(consoleConfig.http) + }; +} + +module.exports = { + child, + emit, + setConsoleOutputEnabled, + isConsoleOutputEnabled, + configureConsoleOutput, + getConsoleOutputConfig +}; diff --git a/backend/src/services/makemkvKeyService.js b/backend/src/services/makemkvKeyService.js new file mode 100644 index 0000000..bbd3ff1 --- /dev/null +++ b/backend/src/services/makemkvKeyService.js @@ -0,0 +1,423 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFile } = require('child_process'); +const { getDb } = require('../db/database'); +const logger = require('./logger').child('MAKEMKV_KEY'); +const backendPackage = require('../../package.json'); + +const MAKEMKV_BETA_KEY_API_URL = 'https://cable.ayra.ch/makemkv/api.php?json'; +const MAKEMKV_BETA_KEY_CACHE_PREF_KEY = 'makemkv_beta_key_cache_v1'; +const MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS = 1000; +const MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS = Math.max( + 1, + Number(process.env.MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS || 24) +); + +const RIPSTER_VERSION = String(backendPackage?.version || 'dev').trim() || 'dev'; +const DEFAULT_USER_AGENT = `Ripster/MakeMKVKey-${RIPSTER_VERSION} (linux/manual; Node/manual) +https://github.com/mboehmlaender/ripster`; +const MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT + || DEFAULT_USER_AGENT; +const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max( + 1000, + Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000) +); +const MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS = Math.max( + 30 * 1000, + Number(process.env.MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS || (15 * 60 * 1000)) +); + +let cachedBetaKeyEntry = null; +let blockedUntilMs = 0; + +function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ungültig.') { + const betaKey = String(value || '').trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{15,}$/.test(betaKey)) { + const error = new Error(invalidMessage); + error.statusCode = 502; + throw error; + } + return betaKey; +} + + +function normalizeCacheEntry(rawEntry) { + if (!rawEntry || typeof rawEntry !== 'object') { + return null; + } + + let key = ''; + try { + key = parseBetaKeyCandidate(rawEntry.key || rawEntry.betaKey || '', 'Betakey-Cache ist ungültig.'); + } catch (_error) { + return null; + } + + const sourceUrl = String(rawEntry.sourceUrl || MAKEMKV_BETA_KEY_API_URL).trim() || MAKEMKV_BETA_KEY_API_URL; + const validUntilIsoRaw = String(rawEntry.validUntil || rawEntry.validUntilIso || '').trim(); + const validUntilMs = Date.parse(validUntilIsoRaw); + + if (!Number.isFinite(validUntilMs)) { + return null; + } + + const fetchedAtIsoRaw = String(rawEntry.fetchedAt || rawEntry.fetchedAtIso || '').trim(); + const fetchedAtMs = Date.parse(fetchedAtIsoRaw); + + return { + key, + sourceUrl, + validUntil: new Date(validUntilMs).toISOString(), + validUntilMs, + fetchedAt: Number.isFinite(fetchedAtMs) ? new Date(fetchedAtMs).toISOString() : null, + fetchedAtMs: Number.isFinite(fetchedAtMs) ? fetchedAtMs : null + }; +} + +function isCacheEntryValid(entry, nowMs = Date.now()) { + return Boolean( + entry + && typeof entry === 'object' + && typeof entry.key === 'string' + && entry.key.length >= 16 + && Number.isFinite(entry.validUntilMs) + && entry.validUntilMs > (nowMs + MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS) + ); +} + +function buildPublicResult(entry, options = {}) { + return { + key: entry.key, + sourceUrl: entry.sourceUrl, + validUntil: entry.validUntil, + fetchedAt: entry.fetchedAt || null, + cached: options.cached === true, + stale: options.stale === true + }; +} + +function setInMemoryCache(entry) { + cachedBetaKeyEntry = normalizeCacheEntry(entry); +} + +async function readPersistentCacheEntry() { + try { + const db = await getDb(); + const row = await db.get('SELECT value FROM user_prefs WHERE key = ? LIMIT 1', [MAKEMKV_BETA_KEY_CACHE_PREF_KEY]); + if (!row?.value) { + return null; + } + const parsed = JSON.parse(String(row.value)); + return normalizeCacheEntry(parsed); + } catch (error) { + logger.warn('beta-key:cache-db-read-failed', { + error: error?.message || String(error) + }); + return null; + } +} + +async function persistCacheEntry(entry) { + const normalized = normalizeCacheEntry(entry); + if (!normalized) { + return; + } + + const payload = JSON.stringify({ + key: normalized.key, + sourceUrl: normalized.sourceUrl, + validUntil: normalized.validUntil, + fetchedAt: normalized.fetchedAt || new Date().toISOString() + }); + + try { + const db = await getDb(); + await db.run( + `INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + [MAKEMKV_BETA_KEY_CACHE_PREF_KEY, payload] + ); + } catch (error) { + logger.warn('beta-key:cache-db-write-failed', { + error: error?.message || String(error) + }); + } +} + +async function tryUsePersistentCache(nowMs = Date.now()) { + const persistent = await readPersistentCacheEntry(); + if (!isCacheEntryValid(persistent, nowMs)) { + return null; + } + setInMemoryCache(persistent); + return buildPublicResult(persistent, { cached: true, stale: false }); +} + +async function getCachedBetaKey(options = {}) { + const nowMs = Date.now(); + const allowExpired = options?.allowExpired !== false; + + if (cachedBetaKeyEntry) { + const stale = !isCacheEntryValid(cachedBetaKeyEntry, nowMs); + if (!stale || allowExpired) { + return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale }); + } + } + + const persistent = await readPersistentCacheEntry(); + if (!persistent) { + return null; + } + + setInMemoryCache(persistent); + const stale = !isCacheEntryValid(persistent, nowMs); + if (stale && !allowExpired) { + return null; + } + + return buildPublicResult(persistent, { cached: true, stale }); +} + +function createRateLimitError(retryAfterMs = 0) { + const parsedRetryMs = Number(retryAfterMs || 0); + const waitMs = parsedRetryMs > 0 ? parsedRetryMs : MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS; + blockedUntilMs = Math.max(blockedUntilMs, Date.now() + waitMs); + + const retryAtIso = new Date(blockedUntilMs).toISOString(); + const error = new Error(`Betakey-API limitiert Anfragen. Neuer Versuch nach ${retryAtIso}.`); + error.statusCode = 429; + error.retryAt = retryAtIso; + error.retryAfterMs = Math.max(1000, blockedUntilMs - Date.now()); + return error; +} + +function buildExistingBackoffError() { + const retryAtMs = Math.max(Date.now() + 1000, Number(blockedUntilMs || 0)); + const retryAtIso = new Date(retryAtMs).toISOString(); + const error = new Error(`Betakey-API limitiert Anfragen. Neuer Versuch nach ${retryAtIso}.`); + error.statusCode = 429; + error.retryAt = retryAtIso; + error.retryAfterMs = Math.max(1000, retryAtMs - Date.now()); + return error; +} + +function resolveValidityOrFallback(validUntilIso, sourceUrl) { + const parsedMs = Date.parse(String(validUntilIso || '').trim()); + if (Number.isFinite(parsedMs)) { + return new Date(parsedMs).toISOString(); + } + + const fallbackIso = new Date(Date.now() + MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS * 60 * 60 * 1000).toISOString(); + logger.warn('beta-key:validity-parse-fallback', { + sourceUrl: sourceUrl || null, + fallbackIso + }); + return fallbackIso; +} + +function normalizeRegistrationKey(rawValue) { + return String(rawValue || '').trim(); +} + +function getMakeMKVConfigDir(homeDir = os.homedir()) { + return path.join(String(homeDir || '').trim() || os.homedir(), '.MakeMKV'); +} + +function getMakeMKVSettingsFilePath(homeDir = os.homedir()) { + return path.join(getMakeMKVConfigDir(homeDir), 'settings.conf'); +} + +function escapeKeyForSettingsFile(value) { + return String(value || '') + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); +} + +function buildUpdatedSettingsContent(currentContent, registrationKey) { + const existingLines = String(currentContent || '') + .split(/\r?\n/) + .filter((line) => !/^\s*app_Key\s*=/.test(line)); + const normalizedKey = normalizeRegistrationKey(registrationKey); + + while (existingLines.length > 0 && existingLines[existingLines.length - 1] === '') { + existingLines.pop(); + } + + if (normalizedKey) { + existingLines.push(`app_Key = "${escapeKeyForSettingsFile(normalizedKey)}"`); + } + + return existingLines.length > 0 ? `${existingLines.join('\n')}\n` : ''; +} + +async function syncRegistrationKeyToConfig(rawValue, options = {}) { + const normalizedKey = normalizeRegistrationKey(rawValue); + const homeDir = String(options?.homeDir || '').trim() || os.homedir(); + const configDir = getMakeMKVConfigDir(homeDir); + const settingsFilePath = getMakeMKVSettingsFilePath(homeDir); + const fileExists = fs.existsSync(settingsFilePath); + const currentContent = fileExists + ? await fs.promises.readFile(settingsFilePath, 'utf8') + : ''; + const nextContent = buildUpdatedSettingsContent(currentContent, normalizedKey); + + if (!normalizedKey && !fileExists) { + return { + changed: false, + path: settingsFilePath, + hasKey: false + }; + } + + await fs.promises.mkdir(configDir, { recursive: true }); + + if (nextContent) { + await fs.promises.writeFile(settingsFilePath, nextContent, 'utf8'); + await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {}); + logger.info('settings-conf:key-synced', { + path: settingsFilePath, + hasKey: true + }); + } else { + await fs.promises.writeFile(settingsFilePath, '', 'utf8'); + await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {}); + logger.info('settings-conf:key-cleared', { + path: settingsFilePath, + hasKey: false + }); + } + + return { + changed: currentContent !== nextContent, + path: settingsFilePath, + hasKey: Boolean(normalizedKey) + }; +} + +async function fetchCurrentBetaKey(options = {}) { + const forceRefresh = options?.forceRefresh === true; + const now = Date.now(); + + if (!forceRefresh && isCacheEntryValid(cachedBetaKeyEntry, now)) { + return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale: false }); + } + + if (!forceRefresh) { + const persistentCacheHit = await tryUsePersistentCache(now); + if (persistentCacheHit) { + return persistentCacheHit; + } + } + + if (!forceRefresh && blockedUntilMs > now) { + throw buildExistingBackoffError(); + } + + try { + const apiResult = await fetchCurrentBetaKeyViaCurl(); + const normalized = normalizeCacheEntry({ + ...apiResult, + fetchedAt: new Date().toISOString() + }); + + if (!normalized) { + const error = new Error('API-curl-Betakey konnte nicht normalisiert werden.'); + error.statusCode = 502; + throw error; + } + + blockedUntilMs = 0; + setInMemoryCache(normalized); + await persistCacheEntry(normalized); + return buildPublicResult(normalized, { cached: false, stale: false }); + } catch (error) { + logger.warn('beta-key:fetch-failed', { + error: error?.message || String(error) + }); + if (Number(error?.httpStatus || error?.statusCode || error?.status || 0) === 429) { + throw createRateLimitError(Number(error?.retryAfterMs || 0)); + } + const resolved = new Error(`Betakey konnte nicht geladen werden. Details: ${error?.message || String(error)}`); + resolved.statusCode = Number(error?.httpStatus || error?.statusCode || error?.status || 502) || 502; + throw resolved; + } +} + +function parseBetaKeyResponse(rawBody) { + let payload = null; + try { + payload = JSON.parse(String(rawBody || '{}')); + } catch (_error) { + const error = new Error('Betakey-Antwort ist kein gültiges JSON.'); + error.statusCode = 502; + throw error; + } + + const betaKey = parseBetaKeyCandidate(String(payload?.key || '').trim(), 'Betakey-Antwort ist ungültig.'); + + let validUntil = null; + const unixExpiry = Number(payload?.keydate || payload?.expires || payload?.valid_until_unix || 0); + if (Number.isFinite(unixExpiry) && unixExpiry > 0) { + validUntil = new Date(unixExpiry * 1000).toISOString(); + } + + const resolvedValidUntil = resolveValidityOrFallback(validUntil, MAKEMKV_BETA_KEY_API_URL); + + return { + key: betaKey, + sourceUrl: MAKEMKV_BETA_KEY_API_URL, + validUntil: resolvedValidUntil + }; +} + +function fetchCurrentBetaKeyViaCurl() { + return new Promise((resolve, reject) => { + const curlArgs = [ + '-fsSL', + '--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)), + '-A', MAKEMKV_BETA_KEY_API_USER_AGENT, + MAKEMKV_BETA_KEY_API_URL + ]; + + execFile( + 'curl', + curlArgs, + { + timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS, + maxBuffer: 1024 * 1024 + }, + (error, stdout, stderr) => { + if (error) { + const resolvedError = new Error( + `curl fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}` + ); + resolvedError.statusCode = 502; + if (/\b429\b/.test(String(stderr || ''))) { + resolvedError.httpStatus = 429; + } + if (/\b403\b/.test(String(stderr || ''))) { + resolvedError.httpStatus = 403; + } + reject(resolvedError); + return; + } + + try { + resolve(parseBetaKeyResponse(stdout)); + } catch (parseError) { + reject(parseError); + } + } + ); + }); +} + +module.exports = { + MAKEMKV_BETA_KEY_API_URL, + fetchCurrentBetaKey, + getCachedBetaKey, + getMakeMKVConfigDir, + getMakeMKVSettingsFilePath, + normalizeRegistrationKey, + syncRegistrationKeyToConfig +}; diff --git a/backend/src/services/musicBrainzService.js b/backend/src/services/musicBrainzService.js new file mode 100644 index 0000000..5c59dab --- /dev/null +++ b/backend/src/services/musicBrainzService.js @@ -0,0 +1,180 @@ +const logger = require('./logger').child('MUSICBRAINZ'); + +const MB_BASE = 'https://musicbrainz.org/ws/2'; +const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)'; +const MB_TIMEOUT_MS = 10000; + +async function mbFetch(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS); + try { + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': MB_USER_AGENT + }, + signal: controller.signal + }); + clearTimeout(timer); + if (!response.ok) { + throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`); + } + return response.json(); + } catch (error) { + clearTimeout(timer); + throw error; + } +} + +function normalizeRelease(release) { + if (!release) { + return null; + } + const artistCredit = Array.isArray(release['artist-credit']) + ? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ') + : null; + const date = String(release.date || '').trim(); + const yearMatch = date.match(/\b(\d{4})\b/); + const year = yearMatch ? Number(yearMatch[1]) : null; + + const media = Array.isArray(release.media) ? release.media : []; + const normalizedTracks = media.flatMap((medium, mediumIdx) => { + const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : []; + return mediumTracks.map((track, trackIdx) => { + const rawPosition = String(track.position || track.number || '').trim(); + const parsedPosition = Number.parseInt(rawPosition, 10); + const fallbackPosition = mediumIdx * 100 + trackIdx + 1; + const position = Number.isFinite(parsedPosition) && parsedPosition > 0 + ? parsedPosition + : fallbackPosition; + return { + position, + number: String(track.number || track.position || ''), + title: String(track.title || ''), + durationMs: Number(track.length || 0) || null, + rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [], + rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : [] + }; + }); + }).map((track) => { + const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit) + ? track.rawTrackArtistCredit + : []; + const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit) + ? track.rawRecordingArtistCredit + : []; + const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', '); + const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', '); + return { + position: track.position, + number: track.number, + title: track.title, + durationMs: track.durationMs, + artist: artistFromTrack || artistFromRecording || artistCredit || null + }; + }); + const rawReleaseTrackCount = Number(release['track-count']); + const mediaTrackCount = media.reduce((sum, medium) => { + const mediumCount = Number(medium?.['track-count'] ?? medium?.trackCount); + if (!Number.isFinite(mediumCount) || mediumCount <= 0) { + return sum; + } + return sum + Math.trunc(mediumCount); + }, 0); + const trackCount = Number.isFinite(rawReleaseTrackCount) && rawReleaseTrackCount > 0 + ? Math.trunc(rawReleaseTrackCount) + : (mediaTrackCount > 0 ? mediaTrackCount : normalizedTracks.length); + + // Always generate the CAA URL when an id is present; the browser/onError + // handles 404s for releases that have no front cover. + const coverArtUrl = release.id + ? `https://coverartarchive.org/release/${release.id}/front-250` + : null; + + return { + mbId: String(release.id || ''), + title: String(release.title || ''), + artist: artistCredit || null, + year, + date, + country: String(release.country || '').trim() || null, + label: Array.isArray(release['label-info']) + ? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null + : null, + coverArtUrl, + tracks: normalizedTracks, + trackCount + }; +} + +class MusicBrainzService { + async searchByTitle(query, options = {}) { + const q = String(query || '').trim(); + if (!q) { + return []; + } + const expectedTrackCountRaw = Number(options?.trackCount); + const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0 + ? Math.trunc(expectedTrackCountRaw) + : null; + logger.info('search:start', { query: q }); + + const url = new URL(`${MB_BASE}/release`); + url.searchParams.set('query', q); + url.searchParams.set('fmt', 'json'); + url.searchParams.set('limit', '10'); + url.searchParams.set('inc', 'artist-credits+labels+recordings+media'); + + try { + const data = await mbFetch(url.toString()); + const releases = Array.isArray(data.releases) ? data.releases : []; + const normalizedReleases = releases.map(normalizeRelease).filter(Boolean); + const results = expectedTrackCount + ? normalizedReleases.filter((release) => Number(release?.trackCount || 0) === expectedTrackCount) + : normalizedReleases; + logger.info('search:done', { + query: q, + expectedTrackCount, + sourceCount: normalizedReleases.length, + count: results.length + }); + return results; + } catch (error) { + logger.warn('search:failed', { + query: q, + expectedTrackCount, + error: String(error?.message || error) + }); + return []; + } + } + + async searchByDiscLabel(discLabel) { + return this.searchByTitle(discLabel); + } + + async getReleaseById(mbId) { + const id = String(mbId || '').trim(); + if (!id) { + return null; + } + + logger.info('getById:start', { mbId: id }); + + const url = new URL(`${MB_BASE}/release/${id}`); + url.searchParams.set('fmt', 'json'); + url.searchParams.set('inc', 'artist-credits+labels+recordings+media'); + + try { + const data = await mbFetch(url.toString()); + const result = normalizeRelease(data); + logger.info('getById:done', { mbId: id, title: result?.title }); + return result; + } catch (error) { + logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) }); + return null; + } + } +} + +module.exports = new MusicBrainzService(); diff --git a/backend/src/services/notificationService.js b/backend/src/services/notificationService.js new file mode 100644 index 0000000..1ba662a --- /dev/null +++ b/backend/src/services/notificationService.js @@ -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(); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js new file mode 100644 index 0000000..2fdd1f3 --- /dev/null +++ b/backend/src/services/pipelineService.js @@ -0,0 +1,34075 @@ +const fs = require('fs'); +const path = require('path'); +const { EventEmitter } = require('events'); +const { execFile } = require('child_process'); +const { randomUUID } = require('crypto'); +const { getDb } = require('../db/database'); +const settingsService = require('./settingsService'); +const historyService = require('./historyService'); +const tmdbService = require('./tmdbService'); +const dvdSeriesScanService = require('./dvdSeriesScanService'); +const musicBrainzService = require('./musicBrainzService'); +const audnexService = require('./audnexService'); +const cdRipService = require('./cdRipService'); +const audiobookService = require('./audiobookService'); +const scriptService = require('./scriptService'); +const scriptChainService = require('./scriptChainService'); +const runtimeActivityService = require('./runtimeActivityService'); +const wsService = require('./websocketService'); +const diskDetectionService = require('./diskDetectionService'); +const notificationService = require('./notificationService'); +const logger = require('./logger').child('PIPELINE'); +const { spawnTrackedProcess } = require('./processRunner'); +const { parseMakeMkvProgress, parseHandBrakeProgress, parseMkvmergeProgress } = require('../utils/progressParsers'); +const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files'); +const { buildMediainfoReview, refreshAudioTrackActionsForPlanTitles } = require('../utils/encodePlan'); +const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); +const { errorToMeta } = require('../utils/errorMeta'); +const userPresetService = require('./userPresetService'); +const userPresetDefaultsService = require('./userPresetDefaultsService'); +const thumbnailService = require('./thumbnailService'); +const activationBytesService = require('./activationBytesService'); +const { toBoolean } = require('../utils/validators'); +const { tempDir } = require('../config'); + +const RUNNING_STATES = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); +const REVIEW_REFRESH_SETTING_PREFIXES = [ + 'handbrake_', + 'mediainfo_', + 'makemkv_rip_', + 'makemkv_analyze_', + 'output_extension_', + 'output_template_' +]; +const REVIEW_REFRESH_SETTING_KEYS = new Set([ + 'makemkv_min_length_minutes', + 'playlist_tmdb_runtime_subtract_percent', + 'handbrake_preset', + 'handbrake_extra_args', + 'mediainfo_extra_args', + 'makemkv_rip_mode', + 'makemkv_analyze_extra_args', + 'makemkv_rip_extra_args', + 'output_extension', + 'output_template' +]); +const QUEUE_ACTIONS = { + START_PREPARED: 'START_PREPARED', + START_SERIES_EPISODE: 'START_SERIES_EPISODE', + START_CD: 'START_CD', + RETRY: 'RETRY', + REENCODE: 'REENCODE', + RESTART_ENCODE: 'RESTART_ENCODE', + RESTART_REVIEW: 'RESTART_REVIEW' +}; +const QUEUE_ACTION_LABELS = { + [QUEUE_ACTIONS.START_PREPARED]: 'Start', + [QUEUE_ACTIONS.START_SERIES_EPISODE]: 'Serien-Episode starten', + [QUEUE_ACTIONS.RETRY]: 'Retry Rippen', + [QUEUE_ACTIONS.REENCODE]: 'RAW neu encodieren', + [QUEUE_ACTIONS.RESTART_ENCODE]: 'Encode neu starten', + [QUEUE_ACTIONS.RESTART_REVIEW]: 'Review neu berechnen', + [QUEUE_ACTIONS.START_CD]: 'Audio CD starten' +}; +const PRE_ENCODE_PROGRESS_RESERVE = 10; +const POST_ENCODE_PROGRESS_RESERVE = 10; +const POST_ENCODE_FINISH_BUFFER = 1; +const MIN_EXTENSIONLESS_DISC_IMAGE_BYTES = 256 * 1024 * 1024; +const MAKEMKV_BACKUP_FAILURE_MSG_CODES = new Set([5069, 5080]); +const RAW_INCOMPLETE_PREFIX = 'Incomplete_'; +const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_'; +const RAW_FOLDER_STATES = Object.freeze({ + INCOMPLETE: 'incomplete', + RIP_COMPLETE: 'rip_complete', + COMPLETE: 'complete' +}); +const RAW_STATE_PREFIX_CLEANUP_PATTERN = /^(?:(?:incomplete|rip[_-]?complete|rip[_-]?incomplete)[_-])+/i; +const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({ + low: 1, + medium: 2, + high: 3 +}); +const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35; +const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35; +const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220; +const FORCED_SUBTITLE_MIN_EVENT_GAP = 12; +const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024; +const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([ + 'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov', + 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus' +]); +const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS); +const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000; +const tmdbSeriesSearchCache = new Map(); +const PROCESS_LOG_RESET_TERMINAL_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']); +const PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS = 120; +const PLAYLIST_RUNTIME_SUBTRACT_PERCENT_SETTING_KEY = 'playlist_tmdb_runtime_subtract_percent'; + +function nowIso() { + return new Date().toISOString(); +} + +function appendLinesInPlace(target, lines) { + if (!Array.isArray(target) || !Array.isArray(lines) || lines.length === 0) { + return target; + } + for (const line of lines) { + target.push(line); + } + return target; +} + +function normalizeLifecycleJobState(value) { + return String(value || '').trim().toUpperCase(); +} + +function shouldResetProcessLogForLifecycle(job = null) { + const status = normalizeLifecycleJobState(job?.status); + const lastState = normalizeLifecycleJobState(job?.last_state); + return PROCESS_LOG_RESET_TERMINAL_STATES.has(status) || PROCESS_LOG_RESET_TERMINAL_STATES.has(lastState); +} + +async function resetProcessLogIfLifecycleAllows(jobId, job = null) { + if (!shouldResetProcessLogForLifecycle(job)) { + return false; + } + await historyService.resetProcessLog(jobId); + return true; +} + +function cloneTmdbSeriesSearchResults(rows = []) { + if (!Array.isArray(rows)) { + return []; + } + return rows.map((row) => ({ + ...(row && typeof row === 'object' ? row : {}), + episodes: Array.isArray(row?.episodes) + ? row.episodes.map((episode) => ( + episode && typeof episode === 'object' + ? { ...episode } + : episode + )) + : [] + })); +} + +function normalizeCdTrackText(value) { + return String(value || '') + .normalize('NFC') + // Keep umlauts/special letters, but strip heart symbols from imported metadata. + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizePositiveInteger(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeDvdMetadataWorkflowKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (['film', 'movie', 'feature'].includes(raw)) { + return 'film'; + } + if (['series', 'tv', 'season', 'episode'].includes(raw)) { + return 'series'; + } + return null; +} + +function isSeriesDiscMediaProfile(mediaProfile) { + const normalized = normalizeMediaProfile(mediaProfile); + return normalized === 'dvd' || normalized === 'bluray'; +} + +function normalizeSeriesDetectionClassification(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'series' || normalized === 'film' || normalized === 'ambiguous') { + return normalized; + } + return null; +} + +function hasStrongSeriesLookupHint(seriesLookupHint = null) { + const hint = seriesLookupHint && typeof seriesLookupHint === 'object' + ? seriesLookupHint + : {}; + return Number(hint.seasonNumber || 0) > 0 || Number(hint.discNumber || 0) > 0; +} + +function normalizePlayAllTolerancePercent(rawValue, fallback = 10) { + const parsed = Number(rawValue); + if (!Number.isFinite(parsed)) { + return fallback; + } + if (parsed < 0) { + return 0; + } + if (parsed > 100) { + return 100; + } + return parsed; +} + +function resolveSeriesDiscLabel(mediaProfile) { + const normalized = normalizeMediaProfile(mediaProfile); + if (normalized === 'bluray') { + return 'Blu-ray'; + } + if (normalized === 'dvd') { + return 'DVD'; + } + return 'Disc'; +} + +function resolveReviewTrackLanguageSettings(settings = {}, mediaProfile = null, isSeriesSelection = false) { + const source = settings && typeof settings === 'object' ? settings : {}; + const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); + if (normalizedMediaProfile !== 'bluray' && normalizedMediaProfile !== 'dvd') { + return source; + } + + const variant = isSeriesSelection ? 'series' : 'movie'; + const audioKey = `handbrake_review_audio_languages_${normalizedMediaProfile}_${variant}`; + const subtitleKey = `handbrake_review_subtitle_languages_${normalizedMediaProfile}_${variant}`; + + return { + ...source, + handbrake_review_audio_languages: source[audioKey] ?? null, + handbrake_review_subtitle_languages: source[subtitleKey] ?? null + }; +} + +function normalizeMovieFingerprintLabel(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +function buildMovieMetadataFingerprint(mediaProfile, metadata = {}) { + const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); + if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') { + return null; + } + const imdbId = String(metadata?.imdbId || '').trim().toLowerCase(); + if (imdbId) { + return `${normalizedMediaProfile}|imdb|${imdbId}`; + } + const title = normalizeMovieFingerprintLabel(metadata?.title || ''); + const year = normalizePositiveInteger(metadata?.year); + if (!title) { + return null; + } + if (year) { + return `${normalizedMediaProfile}|title_year|${title}|${year}`; + } + return `${normalizedMediaProfile}|title|${title}`; +} + +function toMetadataSearchTitleCase(value) { + const source = String(value || '').trim(); + if (!source) { + return ''; + } + return source + .toLowerCase() + .replace(/\b([a-z\u00c0-\u024f])([a-z\u00c0-\u024f0-9']*)/g, (_match, first, rest) => ( + `${first.toUpperCase()}${rest}` + )); +} + +function pushUniqueMetadataSearchVariant(target, candidate) { + const normalized = String(candidate || '').replace(/\s+/g, ' ').trim(); + if (!normalized) { + return; + } + if (!target.some((entry) => entry.toLowerCase() === normalized.toLowerCase())) { + target.push(normalized); + } +} + +function stripTrailingDiscSearchHint(value) { + let working = String(value || '').trim(); + if (!working) { + return ''; + } + const patterns = [ + /\b(?:disc|disk|dvd|bd|br|cd|part|pt|vol|volume|season|staffel|episode|ep)\s*(?:\d{1,3}|[ivxlcdm]{1,8})\b$/i, + /\b(?:[a-z]{1,3}\d{1,4}|\d{1,4}[a-z]{1,3})\b$/i + ]; + for (let i = 0; i < 4; i += 1) { + const before = working; + for (const pattern of patterns) { + working = working.replace(pattern, '').trim(); + } + working = working.replace(/[-_.,;:]+$/g, '').trim(); + if (working === before || !working) { + break; + } + } + return working; +} + +function buildMetadataSearchQueryVariants(query) { + const rawQuery = String(query || '').trim(); + if (!rawQuery) { + return []; + } + + if (/^tt\d{6,12}$/i.test(rawQuery)) { + return [rawQuery.toLowerCase()]; + } + + const variants = []; + const separatorNormalized = rawQuery + .replace(/[_]+/g, ' ') + .replace(/[.]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const punctuationReduced = separatorNormalized + .replace(/[|/\\]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const stripped = stripTrailingDiscSearchHint(punctuationReduced); + + pushUniqueMetadataSearchVariant(variants, rawQuery); + pushUniqueMetadataSearchVariant(variants, separatorNormalized); + pushUniqueMetadataSearchVariant(variants, punctuationReduced); + pushUniqueMetadataSearchVariant(variants, toMetadataSearchTitleCase(separatorNormalized)); + pushUniqueMetadataSearchVariant(variants, stripped); + pushUniqueMetadataSearchVariant(variants, toMetadataSearchTitleCase(stripped)); + + return variants.slice(0, 6); +} + +function normalizeMetadataCandidateWorkflowKind(value) { + return normalizeDvdMetadataWorkflowKind(value); +} + +function normalizeMetadataCandidateMetadataKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (['movie', 'film', 'feature'].includes(raw)) { + return 'movie'; + } + if (['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) { + return raw === 'tv' ? 'series' : raw; + } + return raw; +} + +function normalizeMetadataCandidateType(row = null, fallbackType = null) { + const source = row && typeof row === 'object' ? row : {}; + const metadataKind = normalizeMetadataCandidateMetadataKind(source?.metadataKind); + const workflowKind = normalizeMetadataCandidateWorkflowKind( + source?.workflowKind + || source?.kind + || metadataKind + || null + ); + if (workflowKind === 'series') { + return 'series'; + } + if (workflowKind === 'film') { + return 'movie'; + } + if (metadataKind === 'series' || metadataKind === 'season') { + return 'series'; + } + if (metadataKind === 'movie') { + return 'movie'; + } + const normalizedFallback = String(fallbackType || '').trim().toLowerCase(); + if (normalizedFallback === 'series' || normalizedFallback === 'movie') { + return normalizedFallback; + } + if (Number(source?.seasonNumber || 0) > 0) { + return 'series'; + } + return 'movie'; +} + +function normalizeMetadataCandidateRow(row = null, fallbackType = null) { + const source = row && typeof row === 'object' ? row : {}; + const resultType = normalizeMetadataCandidateType(source, fallbackType); + const workflowKind = resultType === 'series' ? 'series' : 'film'; + const metadataKind = normalizeMetadataCandidateMetadataKind(source?.metadataKind) + || (resultType === 'series' ? 'series' : 'movie'); + return { + ...source, + workflowKind, + metadataKind, + resultType + }; +} + +function deriveWorkflowKindFromMetadataCandidates(metadataCandidates = []) { + const rows = Array.isArray(metadataCandidates) ? metadataCandidates : []; + let hasSeries = false; + let hasMovies = false; + for (const row of rows) { + const resultType = normalizeMetadataCandidateType(row, null); + if (resultType === 'series') { + hasSeries = true; + } else { + hasMovies = true; + } + if (hasSeries && hasMovies) { + return null; + } + } + if (hasSeries) { + return 'series'; + } + if (hasMovies) { + return 'film'; + } + return null; +} + +function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = null) { + const mkInfo = makemkvInfo && typeof makemkvInfo === 'object' + ? makemkvInfo + : {}; + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const analyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const topLevelSelected = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' + ? mkInfo.selectedMetadata + : {}; + return { + ...topLevelSelected, + ...analyzeSelected + }; +} + +function resolveDiscNumberFromJobLike(job = null) { + const direct = normalizePositiveInteger(job?.disc_number); + if (direct) { + return direct; + } + let parsedMakemkvInfo = {}; + if (typeof job?.makemkv_info_json === 'string') { + try { + parsedMakemkvInfo = JSON.parse(job.makemkv_info_json) || {}; + } catch (_error) { + parsedMakemkvInfo = {}; + } + } else if (job?.makemkv_info_json && typeof job.makemkv_info_json === 'object') { + parsedMakemkvInfo = job.makemkv_info_json; + } else if (job?.makemkvInfo && typeof job.makemkvInfo === 'object') { + parsedMakemkvInfo = job.makemkvInfo; + } + const mkInfo = parsedMakemkvInfo; + const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo); + return normalizePositiveInteger( + selectedMetadata?.discNumber + ?? mkInfo?.analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); +} + +function normalizeContextJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) { + const jobId = normalizeContextJobId(job?.id ?? job?.jobId ?? null); + const activeContextJobId = normalizeContextJobId( + activeContext?.jobId + ?? activeContext?.activeJobId + ?? null + ); + const allowActiveContextMetadata = ( + jobId === null + ? true + : (activeContextJobId !== null && activeContextJobId === jobId) + ); + const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const activeSelectedMetadata = allowActiveContextMetadata + && activeContext?.selectedMetadata + && typeof activeContext.selectedMetadata === 'object' + ? activeContext.selectedMetadata + : {}; + const merged = { + ...analyzeSelectedMetadata, + ...activeSelectedMetadata + }; + const workflowKind = normalizeDvdMetadataWorkflowKind( + merged.workflowKind + || analyzeContext?.workflowKind + || (allowActiveContextMetadata ? activeContext?.workflowKind : null) + || null + ); + + return { + ...merged, + title: String(merged.title || job?.title || job?.detected_title || '').trim() || null, + year: Number(merged.year ?? job?.year ?? 0) || null, + imdbId: String(merged.imdbId || job?.imdb_id || '').trim() || null, + poster: String(merged.poster || job?.poster_url || '').trim() || null, + ...(workflowKind ? { workflowKind } : {}), + metadataProvider: String( + merged.metadataProvider + || analyzeContext?.metadataProvider + || (allowActiveContextMetadata ? activeContext?.metadataProvider : null) + || 'tmdb' + ).trim().toLowerCase() || 'tmdb' + }; +} + +function isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata = {}, analyzeContext = null) { + if (!isSeriesDiscMediaProfile(mediaProfile)) { + return false; + } + + const metadata = selectedMetadata && typeof selectedMetadata === 'object' + ? selectedMetadata + : {}; + const workflowKind = normalizeDvdMetadataWorkflowKind( + metadata.workflowKind + || analyzeContext?.workflowKind + || null + ); + if (workflowKind === 'film') { + return false; + } + if (workflowKind === 'series') { + return true; + } + const provider = String( + metadata.metadataProvider + || analyzeContext?.metadataProvider + || '' + ).trim().toLowerCase(); + + const metadataKind = String(metadata.metadataKind || '').trim().toLowerCase(); + if (['film', 'movie', 'feature'].includes(metadataKind)) { + return false; + } + const seasonRaw = String(metadata.seasonNumber ?? '').trim(); + const seasonNumber = Number(seasonRaw.replace(',', '.')); + const hasSeasonNumber = seasonRaw + ? (Number.isFinite(seasonNumber) ? seasonNumber > 0 : true) + : false; + const hasSeasonName = Boolean(String(metadata.seasonName || '').trim()); + const episodeCount = Number(metadata.episodeCount || 0) || 0; + const hasEpisodeList = Array.isArray(metadata.episodes) && metadata.episodes.length > 0; + const hasSeriesLookupHint = hasStrongSeriesLookupHint(analyzeContext?.seriesLookupHint); + const seriesDecisionClassification = normalizeSeriesDetectionClassification( + analyzeContext?.seriesDecision?.classification + ); + const seriesLikeByAnalysis = ( + seriesDecisionClassification === 'series' + || ( + !seriesDecisionClassification + && Boolean(analyzeContext?.seriesAnalysis?.summary?.seriesLike) + ) + ); + const providerIsTmdb = provider === 'tmdb' || provider === 'themoviedb'; + const metadataSignalsSeries = ( + ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind) + || hasSeasonNumber + || hasSeasonName + || episodeCount > 0 + || hasEpisodeList + ); + + if (metadataSignalsSeries) { + return true; + } + + // Fallback: wenn die DVD-Serienanalyse bereits positiv war, behandeln wir den + // Job weiterhin als Serie (z.B. bei manueller Eingabe ohne explizite Staffel). + if (seriesLikeByAnalysis || hasSeriesLookupHint) { + return true; + } + + // Zusätzlicher Sicherheitsanker: TMDb + vorhandener Serien-Hint => Serie. + return providerIsTmdb && (seriesLikeByAnalysis || hasSeriesLookupHint); +} + +function resolveSeriesAwareRawStorage(settings = {}, mediaProfile = null, selectedMetadata = {}, analyzeContext = null) { + const fallbackRawDir = String( + settings?.raw_dir + || settingsService.DEFAULT_RAW_DIR + || '' + ).trim(); + const fallbackRawOwner = String(settings?.raw_dir_owner || '').trim(); + const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); + const normalizedSeriesMediaProfile = normalizeMediaProfile(mediaProfile); + const seriesRawCandidate = String( + settings?.series_raw_dir + || (normalizedSeriesMediaProfile === 'bluray' ? settings?.raw_dir_bluray_series : null) + || (normalizedSeriesMediaProfile === 'dvd' ? settings?.raw_dir_dvd_series : null) + || settings?.raw_dir_bluray_series + || settings?.raw_dir_dvd_series + || '' + ).trim(); + const seriesRawOwnerCandidate = String( + settings?.series_raw_dir_owner + || (normalizedSeriesMediaProfile === 'bluray' ? settings?.raw_dir_bluray_series_owner : null) + || (normalizedSeriesMediaProfile === 'dvd' ? settings?.raw_dir_dvd_series_owner : null) + || settings?.raw_dir_bluray_series_owner + || settings?.raw_dir_dvd_series_owner + || '' + ).trim(); + const seriesDetected = isSeriesDvdMetadataSelection(normalizedMediaProfile, selectedMetadata, analyzeContext); + + if (!seriesDetected) { + return { + rawBaseDir: fallbackRawDir, + rawOwner: fallbackRawOwner, + usingSeriesRawPath: false + }; + } + + const effectiveSeriesRawDir = seriesRawCandidate || fallbackRawDir; + const effectiveSeriesRawOwner = seriesRawOwnerCandidate || fallbackRawOwner; + return { + rawBaseDir: effectiveSeriesRawDir, + rawOwner: effectiveSeriesRawOwner, + usingSeriesRawPath: Boolean(effectiveSeriesRawDir && fallbackRawDir && effectiveSeriesRawDir !== fallbackRawDir) + }; +} + +function parseConverterScanExtensions(rawValue) { + const raw = String(rawValue || '').trim(); + if (!raw) { + return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS]; + } + const seen = new Set(); + const parsed = raw + .split(',') + .map((item) => String(item || '').trim().toLowerCase()) + .filter((item) => { + if (!item || !CONVERTER_SUPPORTED_SCAN_EXTENSION_SET.has(item) || seen.has(item)) { + return false; + } + seen.add(item); + return true; + }); + if (parsed.length === 0) { + return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS]; + } + return parsed; +} + +function getFileExtensionWithoutDot(fileName) { + const ext = path.extname(String(fileName || '')).trim().toLowerCase(); + if (!ext.startsWith('.')) { + return ''; + } + return ext.slice(1); +} + +function cleanupTempUploads(files = []) { + for (const file of Array.isArray(files) ? files : []) { + const tempPath = String(file?.path || '').trim(); + if (!tempPath || !fs.existsSync(tempPath)) { + continue; + } + try { + fs.unlinkSync(tempPath); + } catch (_error) { + // Best-effort cleanup only. + } + } +} + +function normalizeCdTrackPositionList(values = []) { + const source = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of source) { + const normalized = normalizePositiveInteger(value); + if (!normalized) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function isCdPlaceholderTrackTitle(value) { + const normalized = normalizeCdTrackText(value).toLowerCase(); + if (!normalized) { + return true; + } + return /^track\s*\d+$/i.test(normalized); +} + +function evaluateCdDirectReencodeEligibility({ job = {}, encodePlan = {}, mkInfo = {}, handbrakeInfo = {} } = {}) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const info = mkInfo && typeof mkInfo === 'object' ? mkInfo : {}; + const hbInfo = handbrakeInfo && typeof handbrakeInfo === 'object' ? handbrakeInfo : {}; + const selectedMetadata = info?.selectedMetadata && typeof info.selectedMetadata === 'object' + ? info.selectedMetadata + : {}; + const tracks = Array.isArray(info?.tracks) && info.tracks.length > 0 + ? info.tracks + : (Array.isArray(plan?.tracks) ? plan.tracks : []); + const selectedTrackPositions = normalizeCdTrackPositionList( + Array.isArray(plan?.selectedTracks) && plan.selectedTracks.length > 0 + ? plan.selectedTracks + : tracks + .filter((track) => track?.selected !== false) + .map((track) => normalizePositiveInteger(track?.position)) + ); + const trackByPosition = new Map( + tracks + .map((track) => { + const position = normalizePositiveInteger(track?.position); + if (!position) { + return null; + } + return [position, track]; + }) + .filter(Boolean) + ); + + const normalizedFormat = String(plan?.format || '').trim().toLowerCase(); + const hasSupportedFormat = Boolean(normalizedFormat && cdRipService.SUPPORTED_FORMATS.has(normalizedFormat)); + const hasTracks = tracks.length > 0; + const hasSelectedTracks = selectedTrackPositions.length > 0; + const albumTitle = normalizeCdTrackText( + selectedMetadata?.title + || selectedMetadata?.album + || job?.title + || job?.detected_title + || '' + ); + const albumArtist = normalizeCdTrackText(selectedMetadata?.artist || ''); + const hasCoreMetadata = Boolean(albumTitle && albumArtist); + const hasMeaningfulTrackTitles = selectedTrackPositions.some((position) => { + const track = trackByPosition.get(position) || null; + const title = normalizeCdTrackText(track?.title || ''); + return Boolean(title) && !isCdPlaceholderTrackTitle(title); + }); + const hasPriorRunEvidence = Boolean( + plan?.directReencodeReady + || (Array.isArray(hbInfo?.tracks) && hbInfo.tracks.length > 0) + || String(hbInfo?.status || '').trim().toUpperCase() === 'SUCCESS' + ); + + const reasons = []; + if (!hasSupportedFormat) { + reasons.push('Kein valides Ausgabeformat aus einem vorherigen Lauf gefunden.'); + } + if (!hasTracks || !hasSelectedTracks) { + reasons.push('Keine verwertbare Trackauswahl aus einem vorherigen Lauf vorhanden.'); + } + if (!hasCoreMetadata) { + reasons.push('Album-Metadaten sind unvollständig (Titel/Interpret fehlen).'); + } + if (!hasMeaningfulTrackTitles) { + reasons.push('Tracktitel sind nicht aus einem bestätigten Metadaten-Lauf übernommen.'); + } + if (!hasPriorRunEvidence) { + reasons.push('Kein bestätigter Vorlauf mit gültigen CD-Metadaten gefunden.'); + } + + return { + eligible: reasons.length === 0, + reasons + }; +} + +function parseCdTrackDurationSec(track = null) { + const durationSec = Number(track?.durationSec); + if (Number.isFinite(durationSec) && durationSec > 0) { + return Math.max(0, Math.trunc(durationSec)); + } + const durationMs = Number(track?.durationMs); + if (Number.isFinite(durationMs) && durationMs > 0) { + return Math.max(0, Math.round(durationMs / 1000)); + } + return 0; +} + +function buildCdLiveTrackRows(selectedTrackPositions = [], tocTracks = [], fallbackArtist = null) { + const orderedPositions = normalizeCdTrackPositionList(selectedTrackPositions); + const byPosition = new Map( + (Array.isArray(tocTracks) ? tocTracks : []) + .map((track) => { + const position = normalizePositiveInteger(track?.position); + if (!position) { + return null; + } + return [position, track]; + }) + .filter(Boolean) + ); + + return orderedPositions.map((position, index) => { + const track = byPosition.get(position) || {}; + return { + order: index + 1, + position, + title: normalizeCdTrackText(track?.title) || `Track ${position}`, + artist: normalizeCdTrackText(track?.artist) || normalizeCdTrackText(fallbackArtist) || '', + durationSec: parseCdTrackDurationSec(track) + }; + }); +} + +function buildCdLiveProgressSnapshot({ + trackRows = [], + phase = 'rip', + trackIndex = 0, + trackTotal = null, + trackPosition = null, + ripCompletedCount = 0, + encodeCompletedCount = 0, + failedTrackPosition = null +}) { + const rows = Array.isArray(trackRows) ? trackRows : []; + const total = rows.length; + const normalizedPhase = String(phase || '').trim().toLowerCase() === 'encode' + ? 'encode' + : 'rip'; + const normalizedTrackTotal = normalizePositiveInteger(trackTotal) || total; + const normalizedTrackIndex = normalizePositiveInteger(trackIndex); + const normalizedTrackPosition = normalizePositiveInteger(trackPosition); + const normalizedFailedTrackPosition = normalizePositiveInteger(failedTrackPosition); + const safeRipCompleted = Math.max(0, Math.min(total, Math.trunc(Number(ripCompletedCount) || 0))); + const safeEncodeCompleted = Math.max(0, Math.min(total, Math.trunc(Number(encodeCompletedCount) || 0))); + const selectedTrackPositions = rows.map((row) => row.position); + const ripCompletedTrackPositions = selectedTrackPositions.slice(0, safeRipCompleted); + const encodeCompletedTrackPositions = selectedTrackPositions.slice(0, safeEncodeCompleted); + + const trackStates = rows.map((row, index) => { + const ripDone = index < safeRipCompleted; + const encodeDone = index < safeEncodeCompleted; + let ripStatus = ripDone ? 'done' : 'pending'; + let encodeStatus = encodeDone ? 'done' : 'pending'; + + if (!ripDone && normalizedPhase === 'rip' && normalizedTrackPosition && row.position === normalizedTrackPosition) { + ripStatus = 'in_progress'; + } else if (!ripDone && normalizedPhase === 'rip' && normalizedFailedTrackPosition && row.position === normalizedFailedTrackPosition) { + ripStatus = 'error'; + } + + if (!encodeDone && normalizedPhase === 'encode' && normalizedTrackPosition && row.position === normalizedTrackPosition) { + encodeStatus = 'in_progress'; + } else if (!encodeDone && normalizedPhase === 'encode' && normalizedFailedTrackPosition && row.position === normalizedFailedTrackPosition) { + encodeStatus = 'error'; + } + + return { + ...row, + selected: true, + ripStatus, + encodeStatus + }; + }); + + return { + phase: normalizedPhase, + trackIndex: normalizedTrackIndex || 0, + trackTotal: normalizedTrackTotal, + trackPosition: normalizedTrackPosition || null, + ripCompleted: safeRipCompleted, + encodeCompleted: safeEncodeCompleted, + selectedTrackPositions, + ripCompletedTrackPositions, + encodeCompletedTrackPositions, + trackStates, + updatedAt: nowIso() + }; +} + +function normalizeMediaProfile(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if ( + raw === 'bluray' + || raw === 'blu-ray' + || raw === 'blu_ray' + || raw === 'bd' + || raw === 'bdmv' + || raw === 'bdrom' + || raw === 'bd-rom' + || raw === 'bd-r' + || raw === 'bd-re' + ) { + return 'bluray'; + } + if ( + raw === 'dvd' + || raw === 'dvdvideo' + || raw === 'dvd-video' + || raw === 'dvdrom' + || raw === 'dvd-rom' + || raw === 'video_ts' + || raw === 'iso9660' + ) { + return 'dvd'; + } + if (raw === 'cd' || raw === 'audio_cd') { + return 'cd'; + } + if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') { + return 'audiobook'; + } + if (raw === 'converter') { + return 'converter'; + } + return null; +} + +function normalizeJobKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if ( + raw === 'audiobook' + || raw === 'cd' + || raw === 'dvd' + || raw === 'bluray' + || raw === 'dvd_series_container' + || raw === 'dvd_series_child' + || raw === 'multipart_movie_container' + || raw === 'multipart_movie_child' + || raw === 'multipart_movie_merge' + ) { + return raw; + } + if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') { + return raw; + } + return null; +} + +function resolveConverterJobKind(converterMediaType = null) { + const normalized = String(converterMediaType || '').trim().toLowerCase(); + if (normalized === 'audio') { + return 'converter_audio'; + } + if (normalized === 'iso') { + return 'converter_iso'; + } + return 'converter_video'; +} + +function resolveJobKindForMediaProfile(mediaProfile, options = {}) { + const explicit = normalizeJobKind(options?.jobKind); + if (explicit) { + return explicit; + } + const normalizedProfile = normalizeMediaProfile(mediaProfile); + if (!normalizedProfile) { + return null; + } + if (normalizedProfile === 'converter') { + return resolveConverterJobKind(options?.converterMediaType); + } + if (normalizedProfile === 'audiobook' || normalizedProfile === 'cd' || normalizedProfile === 'dvd' || normalizedProfile === 'bluray') { + return normalizedProfile; + } + return null; +} + +function inferMediaProfileFromJobKind(rawJobKind) { + const normalized = normalizeJobKind(rawJobKind); + if (normalized === 'converter_audio' || normalized === 'converter_video' || normalized === 'converter_iso') { + return 'converter'; + } + if (normalized === 'audiobook' || normalized === 'cd' || normalized === 'dvd' || normalized === 'bluray') { + return normalized; + } + const legacy = String(rawJobKind || '').trim().toLowerCase(); + if (legacy === 'converter') { + return 'converter'; + } + return null; +} + +function isConverterJobRecord(job = null, encodePlan = null) { + const profileFromJobKind = inferMediaProfileFromJobKind(job?.job_kind); + if (profileFromJobKind === 'converter') { + return true; + } + const plan = encodePlan && typeof encodePlan === 'object' + ? encodePlan + : null; + const profileFromPlanJobKind = inferMediaProfileFromJobKind(plan?.jobKind); + if (profileFromPlanJobKind === 'converter') { + return true; + } + const mediaType = String(job?.media_type || '').trim().toLowerCase(); + if (mediaType === 'converter') { + return true; + } + const planProfile = String(plan?.mediaProfile || '').trim().toLowerCase(); + return planProfile === 'converter'; +} + +function isSpecificMediaProfile(value) { + return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter'; +} + +function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { + const fstype = String(rawFsType || '').trim().toLowerCase(); + 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.02) and Blu-rays (UDF 2.5/2.6). + // 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 the mountpoint BDMV/VIDEO_TS check 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.5/2.6). + // Ignore hasBlurayModelMarker here – it only reflects drive capability. + if (hasCdOnlyModelMarker) { + return 'other'; + } + return 'dvd'; + } + + return null; +} + +function isLikelyExtensionlessDvdImageFile(filePath, knownSize = null) { + const ext = path.extname(String(filePath || '')).toLowerCase(); + // Only treat as having a real extension if it looks like one (e.g. ".mkv", not ". 1") + if (ext !== '' && /^\.[a-z0-9]+$/.test(ext)) { + return false; + } + + let size = Number(knownSize); + if (!Number.isFinite(size) || size < 0) { + try { + size = Number(fs.statSync(filePath).size || 0); + } catch (_error) { + return false; + } + } + + return size >= MIN_EXTENSIONLESS_DISC_IMAGE_BYTES; +} + +function listTopLevelExtensionlessDvdImages(dirPath) { + const sourceDir = String(dirPath || '').trim(); + if (!sourceDir) { + return []; + } + + let entries; + try { + entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + } catch (_error) { + return []; + } + + const results = []; + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + const absPath = path.join(sourceDir, entry.name); + let stat; + try { + stat = fs.statSync(absPath); + } catch (_error) { + continue; + } + + if (!isLikelyExtensionlessDvdImageFile(absPath, stat.size)) { + continue; + } + + results.push({ + path: absPath, + size: Number(stat.size || 0) + }); + } + + results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path)); + return results; +} + +function hasConverterPathSegment(value) { + const normalized = String(value || '') + .trim() + .replace(/\\/g, '/') + .toLowerCase(); + if (!normalized) { + return false; + } + return /(^|\/)converter(\/|$)/.test(normalized); +} + +function inferMediaProfileFromRawPath(rawPath) { + const source = String(rawPath || '').trim(); + if (!source) { + return null; + } + if (hasConverterPathSegment(source)) { + return 'converter'; + } + try { + const sourceStat = fs.statSync(source); + if (sourceStat.isFile()) { + if (path.extname(source).toLowerCase() === '.aax') { + return 'audiobook'; + } + if (isLikelyExtensionlessDvdImageFile(source, sourceStat.size)) { + return 'dvd'; + } + return null; + } + } catch (_error) { + // ignore fs errors + } + + const bdmvPath = path.join(source, 'BDMV'); + const bdmvStreamPath = path.join(bdmvPath, 'STREAM'); + try { + if (fs.existsSync(bdmvStreamPath) || fs.existsSync(bdmvPath)) { + return 'bluray'; + } + } catch (_error) { + // ignore fs errors + } + + const videoTsPath = path.join(source, 'VIDEO_TS'); + try { + if (fs.existsSync(videoTsPath)) { + return 'dvd'; + } + } catch (_error) { + // ignore fs errors + } + + try { + const audiobookFiles = findMediaFiles(source, ['.aax']); + if (audiobookFiles.length > 0) { + return 'audiobook'; + } + } catch (_error) { + // ignore fs errors + } + + if (listTopLevelExtensionlessDvdImages(source).length > 0) { + return 'dvd'; + } + + return null; +} + +function inferMediaProfileFromDeviceInfo(deviceInfo = null) { + const device = deviceInfo && typeof deviceInfo === 'object' + ? deviceInfo + : null; + if (!device) { + return null; + } + + const explicit = normalizeMediaProfile( + device.mediaProfile || device.profile || device.type || null + ); + if (explicit) { + return explicit; + } + + // Only use disc-specific fields for keyword detection, NOT device.model. + // The drive model describes drive capability (e.g. "BD-ROM"), not disc type. + // A BD-ROM drive with a DVD inserted would otherwise be misdetected as Blu-ray. + const discMarkerText = [ + device.discLabel, + device.label, + device.fstype, + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); + + if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(discMarkerText)) { + return 'bluray'; + } + if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(discMarkerText)) { + return 'dvd'; + } + + const byFsTypeAndModel = inferMediaProfileFromFsTypeAndModel(device.fstype, device.model); + if (byFsTypeAndModel) { + return byFsTypeAndModel; + } + + const mountpoint = String(device.mountpoint || '').trim(); + if (mountpoint) { + try { + if (fs.existsSync(path.join(mountpoint, 'BDMV'))) { + return 'bluray'; + } + } catch (_error) { + // ignore fs errors + } + try { + if (fs.existsSync(path.join(mountpoint, 'VIDEO_TS'))) { + return 'dvd'; + } + } catch (_error) { + // ignore fs errors + } + } + + return null; +} + +function fileTimestamp() { + 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'); + const h = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + const s = String(d.getSeconds()).padStart(2, '0'); + return `${y}${m}${day}-${h}${min}${s}`; +} + +function withTimestampBeforeExtension(targetPath, suffix) { + const dir = path.dirname(targetPath); + const ext = path.extname(targetPath); + const base = path.basename(targetPath, ext); + return path.join(dir, `${base}_${suffix}${ext}`); +} + +function withTimestampSuffix(targetPath, suffix) { + const dir = path.dirname(targetPath); + const base = path.basename(targetPath); + return path.join(dir, `${base}_${suffix}`); +} + +function resolveOutputTemplateValues(job, fallbackJobId = null) { + return { + title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'), + year: job.year || new Date().getFullYear(), + imdbId: job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb') + }; +} + +const DEFAULT_OUTPUT_TEMPLATE = '${title} (${year})/${title} (${year})'; +const DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; +const DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; + +function parseJsonObjectSafe(raw) { + if (!raw) { + return {}; + } + if (typeof raw === 'object' && !Array.isArray(raw)) { + return raw; + } + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch (_error) { + // ignore parse errors for path rendering fallback + } + return {}; +} + +function normalizeTemplateNumberValue(rawValue, fallbackValue = 1) { + const fallback = Number.isFinite(Number(fallbackValue)) && Number(fallbackValue) > 0 + ? Number(fallbackValue) + : 1; + const normalizedEpisode = normalizeEpisodeNumberValue(rawValue); + if (normalizedEpisode !== null) { + return normalizedEpisode; + } + return fallback; +} + +function formatTemplateTwoDigit(rawValue) { + const numeric = Number(rawValue); + if (!Number.isFinite(numeric)) { + return String(rawValue || '').trim() || '00'; + } + if (Number.isInteger(numeric)) { + return String(Math.trunc(numeric)).padStart(2, '0'); + } + return String(rawValue || '').trim() || String(numeric); +} + +function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1, options = {}) { + const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {}; + const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {}; + const allowSelectedTitleEpisodeNumber = options?.allowSelectedTitleEpisodeNumber !== false; + const episodeStart = normalizeTemplateNumberValue( + assignmentSource?.episodeNumberStart + ?? assignmentSource?.episodeNoStart + ?? assignmentSource?.episodeNumber + ?? assignmentSource?.number + ?? (allowSelectedTitleEpisodeNumber ? selectedTitleSource?.episodeNumber : null) + ?? null, + fallbackEpisodeNumber + ); + const explicitEpisodeEnd = normalizeEpisodeNumberValue( + assignmentSource?.episodeNumberEnd + ?? assignmentSource?.episodeNoEnd + ?? null + ); + const explicitSpan = normalizePositiveInteger( + assignmentSource?.episodeSpan + ?? assignmentSource?.episodeCount + ?? selectedTitleSource?.episodeSpan + ?? null + ); + + let episodeEnd = explicitEpisodeEnd; + if (episodeEnd === null && explicitSpan && explicitSpan > 1) { + episodeEnd = episodeStart + explicitSpan - 1; + } + if (episodeEnd !== null && episodeEnd < episodeStart) { + episodeEnd = episodeStart; + } + const normalizedEpisodeEnd = episodeEnd === null ? episodeStart : episodeEnd; + return { + start: episodeStart, + end: normalizedEpisodeEnd, + isMulti: normalizedEpisodeEnd > episodeStart + }; +} + +function hasExplicitSeriesEpisodeAssignmentRange(assignment = null) { + const source = assignment && typeof assignment === 'object' ? assignment : null; + if (!source) { + return false; + } + const hasStart = normalizeEpisodeNumberValue( + source?.episodeNumberStart + ?? source?.episodeNoStart + ?? source?.episodeNumber + ?? source?.number + ?? null + ) !== null; + const hasEnd = normalizeEpisodeNumberValue( + source?.episodeNumberEnd + ?? source?.episodeNoEnd + ?? null + ) !== null; + const hasSpan = normalizePositiveInteger( + source?.episodeSpan + ?? source?.episodeCount + ?? null + ) !== null; + const hasRangeToken = String(source?.episodeRange || '').trim().length > 0; + return Boolean(hasStart || hasEnd || hasSpan || hasRangeToken); +} + +function resolveSeriesSelectedTitleIdOrder(encodePlan = null, titles = []) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const explicitSelected = normalizeReviewTitleIdList( + Array.isArray(plan?.selectedTitleIds) + ? plan.selectedTitleIds + : [plan?.encodeInputTitleId] + ); + if (explicitSelected.length > 0) { + return explicitSelected; + } + return normalizeReviewTitleIdList( + (Array.isArray(titles) ? titles : []) + .filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) + .map((title) => title?.id) + ); +} + +function resolveSeriesEpisodeRangeForPlanTitle(encodePlan = null, titleId = null, fallbackEpisodeNumber = 1) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const titlesById = new Map(); + for (const title of titles) { + const normalizedId = normalizeReviewTitleId(title?.id); + if (!normalizedId) { + continue; + } + titlesById.set(Number(normalizedId), title); + } + + const normalizedTitleId = normalizeReviewTitleId(titleId) + || normalizeReviewTitleId(plan?.encodeInputTitleId) + || null; + let orderedTitleIds = resolveSeriesSelectedTitleIdOrder(plan, titles); + if (normalizedTitleId && !orderedTitleIds.some((id) => Number(id) === Number(normalizedTitleId))) { + orderedTitleIds = [...orderedTitleIds, Number(normalizedTitleId)]; + } + if (orderedTitleIds.length === 0 && normalizedTitleId) { + orderedTitleIds = [Number(normalizedTitleId)]; + } + + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const fallbackStart = normalizePositiveInteger(fallbackEpisodeNumber) || 1; + let cursor = fallbackStart; + let targetRange = null; + let targetAssignment = null; + let targetTitle = null; + + for (const rawId of orderedTitleIds) { + const currentId = normalizeReviewTitleId(rawId); + if (!currentId) { + continue; + } + const assignment = assignmentMap[String(currentId)] || assignmentMap[currentId] || null; + const selectedTitle = titlesById.get(Number(currentId)) || null; + const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(assignment); + const range = resolveSeriesEpisodeRangeFromAssignment( + assignment, + selectedTitle, + cursor, + { allowSelectedTitleEpisodeNumber: hasExplicitRange } + ); + const normalizedEnd = normalizeEpisodeNumberValue(range?.end); + if (normalizedEnd !== null) { + cursor = normalizedEnd + 1; + } + if (normalizedTitleId && Number(currentId) === Number(normalizedTitleId)) { + targetRange = range; + targetAssignment = assignment; + targetTitle = selectedTitle; + break; + } + } + + if (!targetRange) { + const fallbackTitle = normalizedTitleId ? (titlesById.get(Number(normalizedTitleId)) || null) : null; + const fallbackAssignment = normalizedTitleId + ? (assignmentMap[String(normalizedTitleId)] || assignmentMap[normalizedTitleId] || null) + : null; + const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(fallbackAssignment); + targetRange = resolveSeriesEpisodeRangeFromAssignment( + fallbackAssignment, + fallbackTitle, + cursor, + { allowSelectedTitleEpisodeNumber: hasExplicitRange } + ); + targetAssignment = fallbackAssignment; + targetTitle = fallbackTitle; + } + + return { + range: targetRange, + assignment: targetAssignment, + title: targetTitle + }; +} + +function templateUsesLeadingZeroForToken(template, token) { + const source = String(template || '').trim(); + const normalizedToken = String(token || '').trim(); + if (!source || !normalizedToken) { + return false; + } + const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i'); + return pattern.test(source); +} + +function formatSeriesEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') { + const numeric = normalizeEpisodeNumberValue(rawValue); + if (numeric === null) { + return String(rawValue || '').trim() || '0'; + } + if (Number.isInteger(numeric)) { + const normalized = String(Math.trunc(numeric)); + if (templateUsesLeadingZeroForToken(template, token)) { + return normalized.padStart(2, '0'); + } + return normalized; + } + return String(numeric); +} + +function buildSeriesFallbackEpisodeTitle(episodeRangeInfo = null, template = '') { + const start = normalizeEpisodeNumberValue(episodeRangeInfo?.start) ?? 1; + const end = normalizeEpisodeNumberValue(episodeRangeInfo?.end) ?? start; + if (end > start) { + const startToken = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr'); + const endToken = formatSeriesEpisodeNumberByTemplate(end, template, 'episodeNr'); + return `Folge ${startToken}-${endToken}`; + } + const token = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr'); + return `Folge ${token}`; +} + +function buildSeriesEpisodeRangeToken(start, end) { + const startToken = formatTemplateTwoDigit(start); + const endToken = formatTemplateTwoDigit(end); + if (Number(end) > Number(start)) { + return `${startToken}-${endToken}`; + } + return startToken; +} + +function buildSeriesEpisodePartsToken(episodeRangeInfo = null) { + const info = episodeRangeInfo && typeof episodeRangeInfo === 'object' + ? episodeRangeInfo + : { start: 1, end: 1 }; + const start = normalizePositiveInteger(info.start) || 1; + const end = normalizePositiveInteger(info.end) || start; + const span = Math.max(1, end - start + 1); + if (span <= 1) { + return '1'; + } + if (span === 2) { + return '1+2'; + } + return `1-${span}`; +} + +function normalizeSeriesEpisodeTitleTokenForCompare(value) { + return String(value || '') + .toLowerCase() + .normalize('NFD') + .replace(/\p{M}+/gu, '') + .replace(/[^a-z0-9]+/g, '') + .trim(); +} + +function findCommonSeriesEpisodeTitlePrefix(values = []) { + const rows = (Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim()) + .filter(Boolean); + if (rows.length === 0) { + return ''; + } + if (rows.length === 1) { + return rows[0]; + } + const tokenRows = rows.map((value) => value.split(/\s+/).filter(Boolean)); + const firstTokens = tokenRows[0]; + if (firstTokens.length === 0) { + return ''; + } + let prefixLength = 0; + for (let index = 0; index < firstTokens.length; index += 1) { + const tokenKey = normalizeSeriesEpisodeTitleTokenForCompare(firstTokens[index]); + if (!tokenKey) { + break; + } + const matchesAll = tokenRows.slice(1).every((tokens) => { + const current = tokens[index]; + return normalizeSeriesEpisodeTitleTokenForCompare(current) === tokenKey; + }); + if (!matchesAll) { + break; + } + prefixLength += 1; + } + if (prefixLength <= 0) { + return ''; + } + return firstTokens + .slice(0, prefixLength) + .join(' ') + .replace(/[-–—:.,\s]+$/g, '') + .trim(); +} + +function stripSeriesEpisodePartSuffix(value) { + const source = String(value || '').replace(/\s+/g, ' ').trim(); + if (!source) { + return ''; + } + // Output cleanup for multi-episode labels: + // remove classic "Teil/Part" markers and pure numeric/roman suffixes in + // parentheses like "(1)" / "(2)" without affecting assignment logic. + const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)|\(\s*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i; + let normalized = source; + let changed = false; + for (let i = 0; i < 2; i += 1) { + const next = normalized.replace(suffixPattern, '').replace(/[-–—:.,\s]+$/g, '').trim(); + if (!next || next === normalized) { + break; + } + normalized = next; + changed = true; + } + if (!changed) { + return source; + } + return normalized || source; +} + +function normalizeSeriesEpisodeTitleForOutput(value, episodeRangeInfo = null) { + const source = String(value || '').replace(/\s+/g, ' ').trim(); + if (!source) { + return ''; + } + if (!Boolean(episodeRangeInfo?.isMulti)) { + return source; + } + const stripped = stripSeriesEpisodePartSuffix(source) || source; + const rawSegments = source + .split(/\s+(?:\+|\/|\||&)\s+/) + .map((part) => String(part || '').trim()) + .filter(Boolean); + if (rawSegments.length < 2) { + return stripped; + } + const cleanedSegments = rawSegments + .map((part) => stripSeriesEpisodePartSuffix(part) || part) + .map((part) => String(part || '').replace(/[-–—:.,\s]+$/g, '').trim()) + .filter(Boolean); + if (cleanedSegments.length === 0) { + return stripped; + } + const uniqueSegments = []; + const seen = new Set(); + for (const segment of cleanedSegments) { + const key = normalizeSeriesEpisodeTitleTokenForCompare(segment); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + uniqueSegments.push(segment); + } + if (uniqueSegments.length === 1) { + return uniqueSegments[0]; + } + const allSegmentsLookLikePartTitles = rawSegments.every((segment) => /\b(?:teil|part|pt)\b/i.test(segment)); + if (allSegmentsLookLikePartTitles) { + const commonPrefix = findCommonSeriesEpisodeTitlePrefix(cleanedSegments); + if (commonPrefix && commonPrefix.length >= 6) { + return commonPrefix; + } + return uniqueSegments[0] || cleanedSegments[0] || stripped; + } + return stripped; +} + +function normalizeSeriesTitleForOutputPath(value, fallback = 'series') { + const fallbackTitle = normalizeCdTrackText(fallback) || 'series'; + const raw = normalizeCdTrackText(value); + if (!raw) { + return fallbackTitle; + } + const stripped = String(raw) + .replace(/\s*-\s*S\d{1,2}E\d{1,3}(?:-\d{1,3})?\b.*$/i, '') + .trim(); + return stripped || raw || fallbackTitle; +} + +function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { + const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); + const makemkvInfo = parseJsonObjectSafe(job?.makemkv_info_json); + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, null); + const mediaProfile = normalizeMediaProfile( + job?.media_type + || encodePlan?.mediaProfile + || analyzeContext?.mediaProfile + || null + ); + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(encodePlan?.selectedTitleIds) + ? encodePlan.selectedTitleIds + : [encodePlan?.encodeInputTitleId] + ); + const primaryTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId) + || selectedTitleIds[0] + || null; + const seriesByPlan = Boolean( + encodePlan?.seriesBatchParent + || encodePlan?.seriesBatchChild + || encodePlan?.seriesBatchParentJobId + ); + const isSeries = isSeriesDiscMediaProfile(mediaProfile) + && ( + seriesByPlan + || isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext) + ); + if (!isSeries) { + return null; + } + + const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : []; + const selectedTitle = primaryTitleId + ? (titles.find((title) => Number(title?.id) === Number(primaryTitleId)) || null) + : (titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null); + const episodeAssignments = encodePlan?.episodeAssignments && typeof encodePlan.episodeAssignments === 'object' + ? encodePlan.episodeAssignments + : {}; + const assignment = primaryTitleId + ? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null) + : null; + const selectedTitlePosition = primaryTitleId + ? selectedTitleIds.findIndex((id) => Number(id) === Number(primaryTitleId)) + : -1; + const fallbackEpisodeNumber = normalizePositiveInteger( + encodePlan?.seriesBatchChildIndex + ?? null + ) || (selectedTitlePosition >= 0 ? selectedTitlePosition + 1 : 1); + const episodeResolution = resolveSeriesEpisodeRangeForPlanTitle( + encodePlan, + primaryTitleId, + fallbackEpisodeNumber + ); + const resolvedAssignment = episodeResolution?.assignment || assignment || null; + const resolvedTitle = episodeResolution?.title || selectedTitle || null; + const episodeRangeInfo = ( + episodeResolution?.range + && typeof episodeResolution.range === 'object' + ) + ? episodeResolution.range + : resolveSeriesEpisodeRangeFromAssignment( + resolvedAssignment, + resolvedTitle, + fallbackEpisodeNumber, + { allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(resolvedAssignment) } + ); + + const seasonNumber = normalizePositiveInteger( + resolvedAssignment?.seasonNumber + ?? resolvedTitle?.seasonNumber + ?? selectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ) || 1; + const episodeTemplateValue = episodeRangeInfo.start; + const episodeRangeToken = buildSeriesEpisodeRangeToken( + episodeRangeInfo.start, + episodeRangeInfo.end + ); + const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo); + const discNumber = normalizePositiveInteger( + selectedMetadata?.discNumber + ?? resolvedAssignment?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + const seriesTitle = normalizeSeriesTitleForOutputPath( + selectedMetadata?.seriesTitle + || selectedMetadata?.title + || job?.detected_title + || job?.title + || (fallbackJobId ? `job-${fallbackJobId}` : 'series'), + fallbackJobId ? `job-${fallbackJobId}` : 'series' + ); + const singleTemplateRaw = String( + (mediaProfile === 'bluray' + ? settings?.output_template_bluray_series_episode + : settings?.output_template_dvd_series_episode) + || settings?.output_template_dvd_series_episode + || settings?.output_template_bluray_series_episode + || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE + ).trim(); + const multiTemplateRaw = String( + (mediaProfile === 'bluray' + ? settings?.output_template_bluray_series_multi_episode + : settings?.output_template_dvd_series_multi_episode) + || settings?.output_template_dvd_series_multi_episode + || settings?.output_template_bluray_series_multi_episode + || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE + ).trim(); + const singleTemplate = singleTemplateRaw || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE; + const multiTemplate = multiTemplateRaw || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE; + const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate; + const fallbackEpisodeTitle = buildSeriesFallbackEpisodeTitle(episodeRangeInfo, template); + const rawEpisodeTitle = normalizeCdTrackText( + resolvedAssignment?.episodeTitle + || resolvedTitle?.episodeTitle + || fallbackEpisodeTitle + ) || fallbackEpisodeTitle; + const episodeTitle = normalizeSeriesEpisodeTitleForOutput( + rawEpisodeTitle, + episodeRangeInfo + ) || fallbackEpisodeTitle; + const language = String( + settings?.dvd_series_language + || selectedMetadata?.language + || 'unknown' + ).trim() || 'unknown'; + const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear(); + + const rendered = renderTemplate(template, { + seriesTitle, + seasonNr: seasonNumber, + episodeNr: episodeTemplateValue, + episodeNoStart: episodeRangeInfo.start, + episodeNoEnd: episodeRangeInfo.end, + episodeNumberStart: episodeRangeInfo.start, + episodeNumberEnd: episodeRangeInfo.end, + episodeSpan: Math.max(1, Number(episodeRangeInfo.end || 0) - Number(episodeRangeInfo.start || 0) + 1), + episodeRange: episodeRangeToken, + parts: episodePartsToken, + episodeTitle, + discNr: discNumber || '', + year, + language + }); + const segments = rendered + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((seg) => sanitizeFileName(seg)) + .filter(Boolean); + const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; + const folderParts = segments.slice(0, -1); + const folderPath = folderParts.length > 0 ? path.join(...folderParts) : ''; + const rootDir = String( + settings?.series_dir + || settings?.movie_dir + || settingsService.DEFAULT_MOVIE_DIR + || '' + ).trim(); + const rootOwner = String( + settings?.series_dir_owner + || settings?.movie_dir_owner + || '' + ).trim(); + const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv'; + + return { + rootDir, + rootOwner, + folderPath, + baseName, + ext + }; +} + +function resolveOutputPathParts(settings, values) { + const template = String(settings.output_template || DEFAULT_OUTPUT_TEMPLATE).trim() + || DEFAULT_OUTPUT_TEMPLATE; + const rendered = renderTemplate(template, values); + const segments = rendered + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((seg) => sanitizeFileName(seg)) + .filter(Boolean); + + if (segments.length === 0) { + return { folderPath: '', baseName: 'untitled' }; + } + const baseName = segments[segments.length - 1]; + const folderParts = segments.slice(0, -1); + return { + folderPath: folderParts.length > 0 ? path.join(...folderParts) : '', + baseName + }; +} + +function appendSuffixToBaseName(baseName, suffix) { + const rawBase = String(baseName || '').trim() || 'untitled'; + const rawSuffix = String(suffix || '').trim(); + if (!rawSuffix) { + return rawBase; + } + return `${rawBase}${rawSuffix}`; +} + +function withPathBaseNameSuffix(targetPath, suffix) { + const normalizedPath = String(targetPath || '').trim(); + const normalizedSuffix = String(suffix || '').trim(); + if (!normalizedPath || !normalizedSuffix) { + return normalizedPath; + } + const parsed = path.parse(normalizedPath); + const nextBase = appendSuffixToBaseName(parsed.name || 'untitled', normalizedSuffix); + return path.join(parsed.dir || '', `${nextBase}${parsed.ext || ''}`); +} + +function buildMultipartDiscOutputSuffix(job = null) { + const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); + const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const encodePlanJobKind = String(encodePlan?.jobKind || '').trim().toLowerCase(); + if (jobKind === 'multipart_movie_merge' || encodePlanJobKind === 'multipart_movie_merge') { + return ''; + } + const hasMultipartFlag = Number(job?.is_multipart_movie || 0) === 1 + || Number(encodePlan?.isMultipartMovie || 0) === 1; + const isMultipartChild = jobKind === 'multipart_movie_child' + || encodePlanJobKind === 'multipart_movie_child' + || ( + hasMultipartFlag + && ( + normalizePositiveInteger(job?.parent_job_id) + || normalizePositiveInteger(encodePlan?.containerJobId) + || normalizePositiveInteger(job?.disc_number) + ) + ); + if (!isMultipartChild) { + return ''; + } + const discNumber = resolveDiscNumberFromJobLike(job); + if (!discNumber) { + return ''; + } + return `_Disc${discNumber}`; +} + +function parseDecimalSecondsToNanoseconds(value) { + const raw = String(value ?? '').trim(); + if (!raw) { + return null; + } + const normalized = raw.replace(',', '.'); + if (/^-?\d+(?:\.\d+)?$/.test(normalized)) { + const sign = normalized.startsWith('-') ? -1 : 1; + const unsigned = sign < 0 ? normalized.slice(1) : normalized; + const parts = unsigned.split('.'); + const secondsPart = Number.parseInt(parts[0] || '0', 10); + if (!Number.isFinite(secondsPart)) { + return null; + } + const fractionRaw = parts[1] || ''; + const fractionPadded = `${fractionRaw}000000000`.slice(0, 9); + const fractionPart = Number.parseInt(fractionPadded, 10); + if (!Number.isFinite(fractionPart)) { + return null; + } + return sign * ((secondsPart * 1_000_000_000) + fractionPart); + } + if (/^\d+:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) { + const [hourPart, minutePart, secondPart] = normalized.split(':'); + const secondsNs = parseDecimalSecondsToNanoseconds(secondPart); + if (secondsNs == null) { + return null; + } + const hours = Number.parseInt(hourPart, 10); + const minutes = Number.parseInt(minutePart, 10); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) { + return null; + } + return ((hours * 3600) + (minutes * 60)) * 1_000_000_000 + secondsNs; + } + return null; +} + +function formatNanosecondsAsClockTimestamp(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '00:00:00'; + } + const totalSeconds = Math.max(0, Math.floor(numeric / 1_000_000_000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +function formatNanosecondsAsMkvSimpleChapterTimestamp(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return '00:00:00.000000000'; + } + const totalNanoseconds = Math.max(0, Math.trunc(numeric)); + const hours = Math.floor(totalNanoseconds / 3_600_000_000_000); + const remainingAfterHours = totalNanoseconds - (hours * 3_600_000_000_000); + const minutes = Math.floor(remainingAfterHours / 60_000_000_000); + const remainingAfterMinutes = remainingAfterHours - (minutes * 60_000_000_000); + const seconds = Math.floor(remainingAfterMinutes / 1_000_000_000); + const nanos = remainingAfterMinutes - (seconds * 1_000_000_000); + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(nanos).padStart(9, '0')}`; +} + +function normalizeMultipartChapterName(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function buildMultipartMergedChapterPlan(sourceChapterGroups = []) { + const mergedEntries = []; + let offsetNanoseconds = 0; + for (const group of sourceChapterGroups) { + if (!group || typeof group !== 'object') { + continue; + } + const discLabel = normalizePositiveInteger(group.discNumber) + ? `Disc ${normalizePositiveInteger(group.discNumber)}` + : 'Disc ?'; + const chapters = Array.isArray(group.chapters) ? group.chapters : []; + const normalizedChapters = chapters + .map((chapter, chapterIndex) => { + const directStartNanoseconds = Number(chapter?.startNs); + const startNanoseconds = Number.isFinite(directStartNanoseconds) && directStartNanoseconds >= 0 + ? Math.trunc(directStartNanoseconds) + : parseDecimalSecondsToNanoseconds(chapter?.start_time); + if (startNanoseconds == null || startNanoseconds < 0) { + return null; + } + const existingTitle = normalizeMultipartChapterName( + chapter?.title + || chapter?.name + || chapter?.tags?.title + || '' + ); + const fallbackTitle = `Chapter ${String(chapterIndex + 1).padStart(2, '0')}`; + return { + startNanoseconds, + title: existingTitle || fallbackTitle + }; + }) + .filter(Boolean) + .sort((left, right) => left.startNanoseconds - right.startNanoseconds); + + if (normalizedChapters.length > 0) { + for (const chapter of normalizedChapters) { + mergedEntries.push({ + startNanoseconds: offsetNanoseconds + chapter.startNanoseconds, + discNumber: normalizePositiveInteger(group.discNumber) || null, + title: `${discLabel}: ${chapter.title}` + }); + } + } else { + mergedEntries.push({ + startNanoseconds: offsetNanoseconds, + discNumber: normalizePositiveInteger(group.discNumber) || null, + title: `${discLabel}` + }); + } + + const parsedDurationNanoseconds = parseDecimalSecondsToNanoseconds(group.duration ?? group.durationSeconds ?? null); + if (parsedDurationNanoseconds != null && parsedDurationNanoseconds > 0) { + offsetNanoseconds += parsedDurationNanoseconds; + } else if (normalizedChapters.length > 0) { + const highestStart = normalizedChapters[normalizedChapters.length - 1]?.startNanoseconds || 0; + offsetNanoseconds += highestStart + 1_000_000_000; + } + } + + if (mergedEntries.length === 0) { + return null; + } + + const entries = mergedEntries.map((entry, index) => ({ + index: index + 1, + discNumber: entry.discNumber || null, + startNanoseconds: Math.max(0, Math.trunc(Number(entry.startNanoseconds) || 0)), + start: formatNanosecondsAsMkvSimpleChapterTimestamp(entry.startNanoseconds), + startClock: formatNanosecondsAsClockTimestamp(entry.startNanoseconds), + title: normalizeMultipartChapterName(entry.title) || `Chapter ${String(index + 1).padStart(2, '0')}` + })); + + const lines = []; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const chapterNumber = String(index + 1).padStart(2, '0'); + lines.push(`CHAPTER${chapterNumber}=${entry.start}`); + lines.push(`CHAPTER${chapterNumber}NAME=${entry.title}`); + } + return { + entries, + content: lines.join('\n') + }; +} + +function buildMultipartMergedSimpleChapterFileContent(sourceChapterGroups = []) { + const plan = buildMultipartMergedChapterPlan(sourceChapterGroups); + return plan?.content || null; +} + +function normalizeMergeTrackLanguage(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized || normalized === 'und') { + return 'und'; + } + return normalized; +} + +function normalizeMergeTrackTitle(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function buildComparableMergeTrackSignature(track = null) { + const source = track && typeof track === 'object' ? track : {}; + const type = String(source.type || '').trim().toLowerCase(); + const language = normalizeMergeTrackLanguage(source.language); + const codec = String(source.codec || '').trim().toLowerCase() || '?'; + const channels = Number(source.channels); + const channelsLabel = Number.isFinite(channels) && channels > 0 ? String(Math.trunc(channels)) : '-'; + return `${type}|${codec}|${language}|${channelsLabel}`; +} + +function extractMergeTracksFromFfprobeJson(probeJson = null) { + const streams = Array.isArray(probeJson?.streams) ? probeJson.streams : []; + const tracks = []; + streams.forEach((stream, streamIndex) => { + const type = String(stream?.codec_type || '').trim().toLowerCase(); + if (type !== 'audio' && type !== 'subtitle') { + return; + } + const channelsRaw = Number(stream?.channels); + const channels = Number.isFinite(channelsRaw) && channelsRaw > 0 ? Math.trunc(channelsRaw) : null; + const indexRaw = Number(stream?.index); + tracks.push({ + type, + streamIndex: Number.isFinite(indexRaw) ? Math.trunc(indexRaw) : streamIndex, + codec: String(stream?.codec_name || stream?.codec_tag_string || '').trim() || '?', + language: normalizeMergeTrackLanguage(stream?.tags?.language), + title: normalizeMergeTrackTitle(stream?.tags?.title), + channels, + channelLayout: String(stream?.channel_layout || '').trim() || null, + default: Number(stream?.disposition?.default || 0) === 1, + forced: Number(stream?.disposition?.forced || 0) === 1 + }); + }); + const audioTracks = tracks.filter((track) => track.type === 'audio'); + const subtitleTracks = tracks.filter((track) => track.type === 'subtitle'); + return { + tracks, + audioTracks, + subtitleTracks, + layoutSignature: tracks.map((track) => buildComparableMergeTrackSignature(track)) + }; +} + +function buildMultipartMergeStreamSummary(sourceAnalyses = []) { + const analyses = Array.isArray(sourceAnalyses) + ? sourceAnalyses.filter((entry) => entry && typeof entry === 'object') + : []; + const comparable = analyses + .filter((entry) => entry.probeJson && !entry.error) + .map((entry) => ({ + ...entry, + ...extractMergeTracksFromFfprobeJson(entry.probeJson) + })); + if (comparable.length === 0) { + return { + available: false, + sourceCount: analyses.length, + comparableSourceCount: 0, + allSourcesAligned: false, + audioTracks: [], + subtitleTracks: [], + mismatches: [] + }; + } + const reference = comparable[0]; + const mismatchEntries = []; + for (const entry of comparable.slice(1)) { + const sameLength = reference.layoutSignature.length === entry.layoutSignature.length; + const sameSignature = sameLength + && reference.layoutSignature.every((signature, index) => signature === entry.layoutSignature[index]); + if (!sameSignature) { + mismatchEntries.push({ + sourceJobId: entry.sourceJobId || null, + discNumber: entry.discNumber || null, + expectedSignature: reference.layoutSignature, + actualSignature: entry.layoutSignature + }); + } + } + return { + available: true, + sourceCount: analyses.length, + comparableSourceCount: comparable.length, + allSourcesAligned: mismatchEntries.length === 0 && comparable.length === analyses.length, + audioTracks: reference.audioTracks.map((track, index) => ({ + ...track, + order: index + 1 + })), + subtitleTracks: reference.subtitleTracks.map((track, index) => ({ + ...track, + order: index + 1 + })), + mismatches: mismatchEntries + }; +} + +function quoteShellArgument(value) { + const raw = String(value ?? ''); + if (raw.length === 0) { + return "''"; + } + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(raw)) { + return raw; + } + return `'${raw.replace(/'/g, `'\"'\"'`)}'`; +} + +function buildShellCommandPreview(cmd, args = []) { + const command = quoteShellArgument(cmd); + const argv = Array.isArray(args) ? args : []; + if (argv.length === 0) { + return command; + } + return `${command} ${argv.map((arg) => quoteShellArgument(arg)).join(' ')}`; +} + +function normalizeMultipartMergeSourceItemsFromPlan(plan = null) { + const sourceItems = Array.isArray(plan?.sourceItems) ? plan.sourceItems : []; + const normalizedItems = sourceItems + .map((item) => { + if (!item || typeof item !== 'object') { + return null; + } + const jobId = normalizePositiveInteger(item?.jobId || item?.sourceJobId || null); + const discNumber = normalizePositiveInteger(item?.discNumber || null); + const outputPath = String(item?.outputPath || item?.path || '').trim(); + if (!jobId || !outputPath) { + return null; + } + return { + jobId, + discNumber, + outputPath + }; + }) + .filter(Boolean); + + if (normalizedItems.length === 0) { + return []; + } + + const orderedSourceJobIds = normalizeReviewTitleIdList( + Array.isArray(plan?.orderedSourceJobIds) + ? plan.orderedSourceJobIds + : normalizedItems.map((item) => item.jobId) + ); + const byJobId = new Map(normalizedItems.map((item) => [Number(item.jobId), item])); + const ordered = []; + const seen = new Set(); + for (const sourceJobId of orderedSourceJobIds) { + const item = byJobId.get(Number(sourceJobId)); + if (!item) { + continue; + } + const key = Number(item.jobId); + if (seen.has(key)) { + continue; + } + seen.add(key); + ordered.push(item); + } + for (const item of normalizedItems) { + const key = Number(item.jobId); + if (seen.has(key)) { + continue; + } + seen.add(key); + ordered.push(item); + } + return ordered; +} + +function normalizeIncompleteMergeTitleToken(value, fallback = 'movie') { + const normalizedFallback = sanitizeFileName(String(fallback || '').trim() || 'movie') || 'movie'; + const normalized = sanitizeFileName(String(value || '').trim()); + if (!normalized) { + return normalizedFallback; + } + const compacted = normalized.replace(/\s+/g, '_'); + return compacted || normalizedFallback; +} + +function resolveMultipartContainerJobIdFromJob(job = null, fallbackContainerJobId = null) { + const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); + const directContainerJobId = normalizePositiveInteger( + fallbackContainerJobId + || ( + String(job?.job_kind || job?.jobKind || '').trim().toLowerCase() === 'multipart_movie_container' + ? job?.id + : null + ) + || job?.parent_job_id + || encodePlan?.containerJobId + ); + return directContainerJobId || null; +} + +function buildIncompleteMergeFolderNameFromJob(job = null, options = {}) { + const fallbackJobId = normalizePositiveInteger(options?.fallbackJobId); + const containerJobId = resolveMultipartContainerJobIdFromJob( + job, + normalizePositiveInteger(options?.containerJobId) + ); + const titleFromContainer = String(options?.containerTitle || '').trim(); + const titleFromJob = String(job?.title || job?.detected_title || '').trim(); + const titleValue = titleFromContainer || titleFromJob || (fallbackJobId ? `job-${fallbackJobId}` : 'movie'); + const titleToken = normalizeIncompleteMergeTitleToken(titleValue, fallbackJobId ? `job-${fallbackJobId}` : 'movie'); + const containerToken = containerJobId || 'unknown'; + return `Incomplete_merge_${titleToken}_job_${containerToken}`; +} + +function buildIncompleteMergeOutputPathFromJob( + settings, + job, + preferredFinalOutputPath, + fallbackJobId = null, + options = {} +) { + const normalizedFinalPath = String(preferredFinalOutputPath || '').trim(); + const fallbackFinalPath = normalizedFinalPath || buildFinalOutputPathFromJob(settings, job, fallbackJobId); + const fallbackFileName = String(path.basename(fallbackFinalPath || '') || '').trim(); + const finalFileName = fallbackFileName || ( + normalizePositiveInteger(fallbackJobId) + ? `job-${normalizePositiveInteger(fallbackJobId)}.${String(settings?.output_extension || 'mkv').trim() || 'mkv'}` + : `job-unknown.${String(settings?.output_extension || 'mkv').trim() || 'mkv'}` + ); + const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); + const movieDir = seriesParts?.rootDir || settings.movie_dir; + const incompleteMergeFolderName = buildIncompleteMergeFolderNameFromJob(job, { + fallbackJobId, + containerJobId: options?.containerJobId, + containerTitle: options?.containerTitle + }); + return path.join(movieDir, incompleteMergeFolderName, finalFileName); +} + +function areMultipartMergeSourceItemsEquivalent(leftItems = [], rightItems = []) { + const left = Array.isArray(leftItems) ? leftItems : []; + const right = Array.isArray(rightItems) ? rightItems : []; + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + const leftItem = left[index] || {}; + const rightItem = right[index] || {}; + const leftJobId = normalizePositiveInteger(leftItem.jobId || leftItem.sourceJobId); + const rightJobId = normalizePositiveInteger(rightItem.jobId || rightItem.sourceJobId); + if (leftJobId !== rightJobId) { + return false; + } + const leftDiscNumber = normalizePositiveInteger(leftItem.discNumber); + const rightDiscNumber = normalizePositiveInteger(rightItem.discNumber); + if (leftDiscNumber !== rightDiscNumber) { + return false; + } + const leftOutputPath = normalizeComparablePath(leftItem.outputPath || leftItem.path || null); + const rightOutputPath = normalizeComparablePath(rightItem.outputPath || rightItem.path || null); + if (leftOutputPath !== rightOutputPath) { + return false; + } + } + return true; +} + +function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { + const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const outputSuffix = jobKind === 'multipart_movie_merge' + ? '_merged' + : buildMultipartDiscOutputSuffix(job); + const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); + if (seriesParts?.rootDir) { + const seriesBaseName = appendSuffixToBaseName(seriesParts.baseName, outputSuffix); + if (seriesParts.folderPath) { + return path.join(seriesParts.rootDir, seriesParts.folderPath, `${seriesBaseName}.${seriesParts.ext}`); + } + return path.join(seriesParts.rootDir, `${seriesBaseName}.${seriesParts.ext}`); + } + const movieDir = settings.movie_dir; + const values = resolveOutputTemplateValues(job, fallbackJobId); + const { folderPath, baseName } = resolveOutputPathParts(settings, values); + const outputBaseName = appendSuffixToBaseName(baseName, outputSuffix); + const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; + if (folderPath) { + return path.join(movieDir, folderPath, `${outputBaseName}.${ext}`); + } + return path.join(movieDir, `${outputBaseName}.${ext}`); +} + +function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { + const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const outputSuffix = jobKind === 'multipart_movie_merge' + ? '_merged' + : buildMultipartDiscOutputSuffix(job); + const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); + const movieDir = seriesParts?.rootDir || settings.movie_dir; + const values = resolveOutputTemplateValues(job, fallbackJobId); + const { baseName: defaultBaseName } = resolveOutputPathParts(settings, values); + const baseName = appendSuffixToBaseName(seriesParts?.baseName || defaultBaseName, outputSuffix); + const ext = String(seriesParts?.ext || settings.output_extension || 'mkv').trim() || 'mkv'; + const numericJobId = Number(fallbackJobId || job?.id || 0); + const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 + ? `Incomplete_job-${numericJobId}` + : 'Incomplete_job-unknown'; + return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`); +} + +function buildMultipartMergeOutputPath(templateOutputPath, sourceItems = [], options = {}) { + const normalizedTemplateOutputPath = String(templateOutputPath || '').trim(); + if (!normalizedTemplateOutputPath) { + return null; + } + + const preferSourceDirectory = options?.preferSourceDirectory === true; + const templateParsed = path.parse(normalizedTemplateOutputPath); + const fallbackDir = String(templateParsed.dir || '').trim() || process.cwd(); + const sourceDirs = preferSourceDirectory + ? Array.from(new Set( + (Array.isArray(sourceItems) ? sourceItems : []) + .map((item) => String(path.dirname(String(item?.outputPath || '').trim()) || '').trim()) + .filter(Boolean) + )) + : []; + const preferredDir = (preferSourceDirectory ? (sourceDirs[0] || fallbackDir) : fallbackDir); + const preferredExt = String(templateParsed.ext || '').trim() || '.mkv'; + const preferredName = String(templateParsed.name || '').trim() || 'merged_output'; + return path.join(preferredDir, `${preferredName}${preferredExt}`); +} + +function ensureUniqueOutputPath(outputPath) { + if (!fs.existsSync(outputPath)) { + return outputPath; + } + + let stat = null; + try { + stat = fs.statSync(outputPath); + } catch (_error) { + stat = null; + } + const isDirectory = Boolean(stat?.isDirectory?.()); + + // Use sequential numbering (_2, _3, ...) for directories and files. + if (isDirectory) { + for (let i = 2; i < 200; i++) { + const attempt = `${outputPath}_${i}`; + if (!fs.existsSync(attempt)) return attempt; + } + } else { + const parentDir = path.dirname(outputPath); + const parsed = path.parse(outputPath); + for (let i = 2; i < 200; i++) { + const attempt = path.join(parentDir, `${parsed.name}_${i}${parsed.ext}`); + if (!fs.existsSync(attempt)) return attempt; + } + } + + // Fallback to timestamp if sequential limit exceeded + const ts = fileTimestamp(); + return isDirectory + ? withTimestampSuffix(outputPath, ts) + : withTimestampBeforeExtension(outputPath, ts); +} + +function chownRecursive(targetPath, ownerSpec) { + const spec = String(ownerSpec || '').trim(); + if (!spec || !targetPath) { + return; + } + try { + const { spawnSync } = require('child_process'); + const result = spawnSync('chown', ['-R', spec, targetPath], { timeout: 15000 }); + if (result.status !== 0) { + logger.warn('chown:failed', { targetPath, spec, stderr: String(result.stderr || '') }); + } + } catch (error) { + logger.warn('chown:error', { targetPath, spec, error: error?.message }); + } +} + +function moveFileWithFallback(sourcePath, targetPath) { + try { + fs.renameSync(sourcePath, targetPath); + } catch (error) { + if (error?.code !== 'EXDEV') { + throw error; + } + fs.copyFileSync(sourcePath, targetPath); + fs.unlinkSync(sourcePath); + } +} + +function movePathWithFallback(sourcePath, targetPath) { + try { + fs.renameSync(sourcePath, targetPath); + } catch (error) { + if (error?.code !== 'EXDEV') { + throw error; + } + const stat = fs.statSync(sourcePath); + if (stat.isDirectory()) { + fs.cpSync(sourcePath, targetPath, { recursive: true }); + fs.rmSync(sourcePath, { recursive: true, force: true }); + return; + } + fs.copyFileSync(sourcePath, targetPath); + fs.unlinkSync(sourcePath); + } +} + +function removeDirectoryIfEmpty(directoryPath) { + try { + const entries = fs.readdirSync(directoryPath); + if (entries.length === 0) { + fs.rmdirSync(directoryPath); + } + } catch (_error) { + // Best effort cleanup. + } +} + +function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFinalOutputPath) { + const sourcePath = String(incompleteOutputPath || '').trim(); + if (!sourcePath) { + throw new Error('Encode-Finalisierung fehlgeschlagen: temporärer Output-Pfad fehlt.'); + } + if (!fs.existsSync(sourcePath)) { + throw new Error(`Encode-Finalisierung fehlgeschlagen: temporäre Datei fehlt (${sourcePath}).`); + } + + const plannedTargetPath = String(preferredFinalOutputPath || '').trim(); + if (!plannedTargetPath) { + throw new Error('Encode-Finalisierung fehlgeschlagen: finaler Output-Pfad fehlt.'); + } + + const sourceResolved = path.resolve(sourcePath); + const targetPath = ensureUniqueOutputPath(plannedTargetPath); + const targetResolved = path.resolve(targetPath); + const outputPathWithTimestamp = targetPath !== plannedTargetPath; + + if (sourceResolved === targetResolved) { + return { + outputPath: targetPath, + outputPathWithTimestamp + }; + } + + ensureDir(path.dirname(targetPath)); + movePathWithFallback(sourcePath, targetPath); + removeDirectoryIfEmpty(path.dirname(sourcePath)); + + return { + outputPath: targetPath, + outputPathWithTimestamp + }; +} + +function buildAudiobookMetadataForJob(job, makemkvInfo = null, encodePlan = null) { + const mkInfo = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const metadataSource = plan?.metadata && typeof plan.metadata === 'object' + ? plan.metadata + : ( + mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' + ? mkInfo.selectedMetadata + : (mkInfo?.detectedMetadata && typeof mkInfo.detectedMetadata === 'object' ? mkInfo.detectedMetadata : {}) + ); + const title = String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook'; + const durationMs = Number.isFinite(Number(metadataSource?.durationMs)) + ? Number(metadataSource.durationMs) + : 0; + const chaptersSource = Array.isArray(metadataSource?.chapters) + ? metadataSource.chapters + : (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : []); + const chapters = audiobookService.normalizeChapterList(chaptersSource, { + durationMs, + fallbackTitle: title, + createFallback: false + }); + + return { + title, + author: String(metadataSource?.author || metadataSource?.artist || '').trim() || null, + asin: String(metadataSource?.asin || '').trim() || null, + chapterSource: String(metadataSource?.chapterSource || '').trim() || null, + narrator: String(metadataSource?.narrator || '').trim() || null, + description: String(metadataSource?.description || '').trim() || null, + series: String(metadataSource?.series || '').trim() || null, + part: String(metadataSource?.part || '').trim() || null, + year: Number.isFinite(Number(metadataSource?.year)) + ? Math.trunc(Number(metadataSource.year)) + : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null), + durationMs, + chapters, + poster: String(metadataSource?.poster || job?.poster_url || '').trim() || null + }; +} + +function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePlan = null, fallbackJobId = null) { + const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan); + const movieDir = String( + settings?.movie_dir + || settings?.raw_dir + || settingsService.DEFAULT_AUDIOBOOK_DIR + || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR + || '' + ).trim(); + const outputTemplate = String( + settings?.output_template + || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE; + const chapterOutputTemplate = String( + settings?.output_chapter_template_audiobook + || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE; + const outputFormat = audiobookService.normalizeOutputFormat( + encodePlan?.format || 'm4b' + ); + const numericJobId = Number(fallbackJobId || job?.id || 0); + const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 + ? `Incomplete_job-${numericJobId}` + : 'Incomplete_job-unknown'; + const incompleteBaseDir = path.join(movieDir, incompleteFolder); + + if (outputFormat === 'm4b') { + const preferredFinalOutputPath = audiobookService.buildOutputPath( + metadata, + movieDir, + outputTemplate, + outputFormat + ); + const incompleteOutputPath = path.join(incompleteBaseDir, path.basename(preferredFinalOutputPath)); + return { + metadata, + outputFormat, + preferredFinalOutputPath, + incompleteOutputPath, + preferredChapterPlan: null, + incompleteChapterPlan: null + }; + } + + const preferredChapterPlan = audiobookService.buildChapterOutputPlan( + metadata, + metadata.chapters, + movieDir, + chapterOutputTemplate, + outputFormat + ); + const incompleteChapterPlan = audiobookService.buildChapterOutputPlan( + metadata, + preferredChapterPlan.chapters, + incompleteBaseDir, + chapterOutputTemplate, + outputFormat + ); + + return { + metadata: { + ...metadata, + chapters: preferredChapterPlan.chapters + }, + outputFormat, + preferredFinalOutputPath: preferredChapterPlan.outputDir, + incompleteOutputPath: incompleteChapterPlan.outputDir, + preferredChapterPlan, + incompleteChapterPlan + }; +} + +function truncateLine(value, max = 180) { + const raw = String(value || '').replace(/\s+/g, ' ').trim(); + if (raw.length <= max) { + return raw; + } + return `${raw.slice(0, max)}...`; +} + +function appendTailText(currentValue, nextChunk, maxChars = 12000) { + const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (!chunk) { + return { + value: currentValue || '', + truncated: false + }; + } + const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`; + const combined = `${String(currentValue || '')}${normalizedChunk}`; + if (combined.length <= maxChars) { + return { + value: combined, + truncated: false + }; + } + return { + value: combined.slice(-maxChars), + truncated: true + }; +} + +function extractProgressDetail(source, line) { + const text = truncateLine(line, 220); + if (!text) { + return null; + } + + if (source.startsWith('MAKEMKV')) { + const prgc = text.match(/^PRGC:\d+,\d+,\"([^\"]+)\"/i); + if (prgc) { + return truncateLine(prgc[1], 160); + } + if (/Title\s+#?\d+/i.test(text)) { + return text; + } + if (/copying|saving|writing|decrypt/i.test(text)) { + return text; + } + if (/operation|progress|processing/i.test(text)) { + return text; + } + } + + if (source === 'HANDBRAKE') { + if (/Encoding:\s*task/i.test(text)) { + return text; + } + if (/Muxing|work result|subtitle scan|frame/i.test(text)) { + return text; + } + } + + return null; +} + +function composeStatusText(stage, percent, detail) { + const normalizedStage = String(stage || '').trim().toUpperCase(); + const baseLabel = normalizedStage === 'ENCODING' ? 'Fortschritt' : stage; + const parsedPercent = Number(percent); + const wholePercent = Number.isFinite(parsedPercent) + ? Math.trunc(Math.max(0, Math.min(100, parsedPercent))) + : null; + const base = percent !== null && percent !== undefined + ? `${baseLabel} ${wholePercent ?? 0}%` + : baseLabel; + + if (detail) { + return `${base} - ${detail}`; + } + + return base; +} + +function clampProgressPercent(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return null; + } + return Math.max(0, Math.min(100, parsed)); +} + +function composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, statusWord = null) { + const phaseLabel = phase === 'pre' ? 'Pre-Encode' : 'Post-Encode'; + const itemLabel = itemType === 'chain' ? 'Kette' : 'Skript'; + const parsedPercent = Number(percent); + const wholePercent = Number.isFinite(parsedPercent) + ? Math.trunc(Math.max(0, Math.min(100, parsedPercent))) + : 0; + const position = Number.isFinite(index) && Number.isFinite(total) && total > 0 + ? ` ${index}/${total}` + : ''; + const status = statusWord ? ` ${statusWord}` : ''; + const detail = String(label || '').trim(); + return `ENCODING ${wholePercent}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`; +} + +function parseMakeMkvMessageCode(line) { + const match = String(line || '').match(/\bMSG:(\d+),/i); + if (!match) { + return null; + } + const code = Number(match[1]); + if (!Number.isFinite(code)) { + return null; + } + return Math.trunc(code); +} + +function isMakeMkvBackupFailureMarker(line) { + const text = String(line || '').trim(); + if (!text) { + return false; + } + const code = parseMakeMkvMessageCode(text); + if (code !== null && MAKEMKV_BACKUP_FAILURE_MSG_CODES.has(code)) { + return true; + } + return /backup\s+failed/i.test(text) || /backup\s+fehlgeschlagen/i.test(text); +} + +function findMakeMkvBackupFailureMarker(lines) { + if (!Array.isArray(lines)) { + return null; + } + return lines.find((line) => isMakeMkvBackupFailureMarker(line)) || null; +} + +function parseMakeMkvDriveLine(line) { + const match = String(line || '').match(/^DRV:\d+,\d+,\d+,\d+,\"([^\"]*)\",\"([^\"]*)\",\"([^\"]*)\"/i); + if (!match) { + return null; + } + return { + driveName: String(match[1] || '').trim(), + discName: String(match[2] || '').trim(), + devicePath: String(match[3] || '').trim() + }; +} + +function parseMakeMkvReadTarget(line) { + const match = String(line || '').match(/occurred while reading '([^']+)'/i); + if (!match) { + return null; + } + return String(match[1] || '').trim() || null; +} + +function createMakeMkvForeignDriveLineFilter(targetDevicePath) { + const normalizedTargetDevicePath = String(targetDevicePath || '').trim(); + if (!normalizedTargetDevicePath) { + return null; + } + + const foreignReadTargets = new Set(); + + return (line) => { + const text = String(line || '').trim(); + if (!text) { + return true; + } + + const driveInfo = parseMakeMkvDriveLine(text); + if (driveInfo) { + const drivePath = String(driveInfo.devicePath || '').trim(); + const isForeignDrive = Boolean(drivePath && drivePath !== normalizedTargetDevicePath); + if (isForeignDrive) { + if (driveInfo.driveName) { + foreignReadTargets.add(driveInfo.driveName); + } + if (driveInfo.discName) { + foreignReadTargets.add(driveInfo.discName); + } + return false; + } + return true; + } + + const readTarget = parseMakeMkvReadTarget(text); + if (!readTarget) { + return true; + } + if (!foreignReadTargets.has(readTarget)) { + return true; + } + + const msgCode = parseMakeMkvMessageCode(text); + if (msgCode === 2003 || /scsi error/i.test(text)) { + return false; + } + return true; + }; +} + +function createEncodeScriptProgressTracker({ + jobId, + preSteps = 0, + postSteps = 0, + updateProgress +}) { + const preTotal = Math.max(0, Math.trunc(Number(preSteps) || 0)); + const postTotal = Math.max(0, Math.trunc(Number(postSteps) || 0)); + const hasPre = preTotal > 0; + const hasPost = postTotal > 0; + const preReserve = hasPre ? PRE_ENCODE_PROGRESS_RESERVE : 0; + const postReserve = hasPost ? POST_ENCODE_PROGRESS_RESERVE : 0; + const finalPercentBeforeFinish = hasPost ? (100 - POST_ENCODE_FINISH_BUFFER) : 100; + const handBrakeStart = preReserve; + const handBrakeEnd = Math.max(handBrakeStart, finalPercentBeforeFinish - postReserve); + + let preCompleted = 0; + let postCompleted = 0; + + const clampPhasePercent = (value) => { + const clamped = clampProgressPercent(value); + if (clamped === null) { + return 0; + } + return Number(clamped.toFixed(2)); + }; + + const calculatePrePercent = () => { + if (preTotal <= 0) { + return clampPhasePercent(handBrakeStart); + } + return clampPhasePercent((preCompleted / preTotal) * preReserve); + }; + + const calculatePostPercent = () => { + if (postTotal <= 0) { + return clampPhasePercent(handBrakeEnd); + } + return clampPhasePercent(handBrakeEnd + ((postCompleted / postTotal) * postReserve)); + }; + + const callProgress = async (percent, statusText) => { + if (typeof updateProgress !== 'function') { + return; + } + await updateProgress('ENCODING', percent, null, statusText, jobId); + }; + + return { + hasScriptSteps: hasPre || hasPost, + handBrakeStart, + handBrakeEnd, + + mapHandBrakePercent(percent) { + if (!this.hasScriptSteps) { + return percent; + } + const normalized = clampProgressPercent(percent); + if (normalized === null) { + return percent; + } + const ratio = normalized / 100; + return clampPhasePercent(handBrakeStart + ((handBrakeEnd - handBrakeStart) * ratio)); + }, + + async onStepStart(phase, itemType, index, total, label) { + if (phase === 'pre' && preTotal <= 0) { + return; + } + if (phase === 'post' && postTotal <= 0) { + return; + } + const percent = phase === 'pre' + ? calculatePrePercent() + : calculatePostPercent(); + await callProgress(percent, composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, 'startet')); + }, + + async onStepComplete(phase, itemType, index, total, label, success = true) { + if (phase === 'pre' && preTotal <= 0) { + return; + } + if (phase === 'post' && postTotal <= 0) { + return; + } + + if (phase === 'pre') { + preCompleted = Math.min(preTotal, preCompleted + 1); + } else { + postCompleted = Math.min(postTotal, postCompleted + 1); + } + + const percent = phase === 'pre' + ? calculatePrePercent() + : calculatePostPercent(); + await callProgress( + percent, + composeEncodeScriptStatusText( + percent, + phase, + itemType, + index, + total, + label, + success ? 'OK' : 'Fehler' + ) + ); + } + }; +} + +function shouldKeepHighlight(line) { + return /error|fail|warn|fehl|title\s+#|saving|encoding:|muxing|copying|decrypt/i.test(line) + || isMakeMkvBackupFailureMarker(line); +} + +function normalizeNonNegativeInteger(rawValue) { + if (rawValue === null || rawValue === undefined) { + return null; + } + if (typeof rawValue === 'string' && rawValue.trim() === '') { + return null; + } + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return Math.trunc(parsed); +} + +function parseDetectedTitle(lines) { + const candidates = []; + const blockedPatterns = [ + /evaluierungsversion/i, + /evaluation version/i, + /es verbleiben noch/i, + /days remaining/i, + /makemkv/i, + /www\./i, + /beta/i + ]; + + const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + + for (const line of lines) { + const cinfoMatch = line.match(/CINFO:2,0,"([^"]+)"/i); + if (cinfoMatch) { + candidates.push(cinfoMatch[1]); + } + + const tinfoMatch = line.match(/TINFO:\d+,2,\d+,"([^"]+)"/i); + if (tinfoMatch) { + candidates.push(tinfoMatch[1]); + } + } + + const clean = candidates + .map(normalize) + .filter((value) => value.length > 2 && !value.startsWith('/')) + .filter((value) => !blockedPatterns.some((pattern) => pattern.test(value))) + .filter((value) => !/^disc\s*\d*$/i.test(value)) + .filter((value) => !/^unknown/i.test(value)); + + if (clean.length === 0) { + return null; + } + + clean.sort((a, b) => b.length - a.length); + return clean[0]; +} + +function parseMediainfoJsonOutput(rawOutput) { + const text = String(rawOutput || '').trim(); + if (!text) { + return null; + } + + const extractJsonObjects = (value) => { + const source = String(value || ''); + const objects = []; + let start = -1; + let depth = 0; + let inString = false; + let escaped = false; + for (let i = 0; i < source.length; i += 1) { + const ch = source[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') { + if (depth === 0) { + start = i; + } + depth += 1; + continue; + } + if (ch === '}' && depth > 0) { + depth -= 1; + if (depth === 0 && start >= 0) { + objects.push(source.slice(start, i + 1)); + start = -1; + } + } + } + return objects; + }; + + const parsedObjects = []; + const rawObjects = extractJsonObjects(text); + for (const candidate of rawObjects) { + try { + parsedObjects.push(JSON.parse(candidate)); + } catch (_error) { + // ignore malformed blocks and continue + } + } + + if (parsedObjects.length === 0) { + try { + return JSON.parse(text); + } catch (_error) { + return null; + } + } + + const hasTitleList = (entry) => + Array.isArray(entry?.TitleList) + || Array.isArray(entry?.Scan?.TitleList) + || Array.isArray(entry?.title_list); + + const hasMediaTrack = (entry) => + Array.isArray(entry?.media?.track) + || Array.isArray(entry?.Media?.track); + + const getTitleList = (entry) => { + if (Array.isArray(entry?.TitleList)) { + return entry.TitleList; + } + if (Array.isArray(entry?.Scan?.TitleList)) { + return entry.Scan.TitleList; + } + if (Array.isArray(entry?.title_list)) { + return entry.title_list; + } + return []; + }; + + const titleSets = parsedObjects + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => hasTitleList(entry)) + .map(({ entry, index }) => { + const titles = getTitleList(entry); + let audioTracks = 0; + let subtitleTracks = 0; + let validAudioTracks = 0; + let validSubtitleTracks = 0; + + for (const title of titles) { + const audioList = Array.isArray(title?.AudioList) ? title.AudioList : []; + const subtitleList = Array.isArray(title?.SubtitleList) ? title.SubtitleList : []; + audioTracks += audioList.length; + subtitleTracks += subtitleList.length; + validAudioTracks += audioList.filter((track) => Number.isFinite(Number(track?.TrackNumber)) && Number(track.TrackNumber) > 0).length; + validSubtitleTracks += subtitleList.filter((track) => Number.isFinite(Number(track?.TrackNumber)) && Number(track.TrackNumber) > 0).length; + } + + return { + entry, + index, + titleCount: titles.length, + audioTracks, + subtitleTracks, + validAudioTracks, + validSubtitleTracks + }; + }); + + if (titleSets.length > 0) { + titleSets.sort((a, b) => + b.validAudioTracks - a.validAudioTracks + || b.validSubtitleTracks - a.validSubtitleTracks + || b.audioTracks - a.audioTracks + || b.subtitleTracks - a.subtitleTracks + || b.titleCount - a.titleCount + || b.index - a.index + ); + return titleSets[0].entry; + } + + const mediaSets = parsedObjects + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => hasMediaTrack(entry)) + .map(({ entry, index }) => { + const tracks = Array.isArray(entry?.media?.track) + ? entry.media.track + : (Array.isArray(entry?.Media?.track) ? entry.Media.track : []); + return { + entry, + index, + trackCount: tracks.length + }; + }); + + if (mediaSets.length > 0) { + mediaSets.sort((a, b) => b.trackCount - a.trackCount || b.index - a.index); + return mediaSets[0].entry; + } + + return parsedObjects[parsedObjects.length - 1] || null; +} + +function getMediaInfoTrackList(mediaInfoJson) { + if (Array.isArray(mediaInfoJson?.media?.track)) { + return mediaInfoJson.media.track; + } + if (Array.isArray(mediaInfoJson?.Media?.track)) { + return mediaInfoJson.Media.track; + } + return []; +} + +function countMediaInfoTrackTypes(mediaInfoJson) { + const tracks = getMediaInfoTrackList(mediaInfoJson); + let audioCount = 0; + let subtitleCount = 0; + for (const track of tracks) { + const type = String(track?.['@type'] || '').trim().toLowerCase(); + if (type === 'audio') { + audioCount += 1; + continue; + } + if (type === 'text' || type === 'subtitle') { + subtitleCount += 1; + } + } + return { + audioCount, + subtitleCount + }; +} + +function shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, inputPath) { + if (normalizeMediaProfile(mediaProfile) !== 'dvd') { + return false; + } + if (path.extname(String(inputPath || '')).toLowerCase() !== '') { + return false; + } + const counts = countMediaInfoTrackTypes(parsedMediaInfo); + return counts.audioCount === 0 && counts.subtitleCount === 0; +} + +function parseHmsDurationToSeconds(raw) { + const value = String(raw || '').trim(); + if (!value) { + return 0; + } + const match = value.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/); + if (!match) { + return 0; + } + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) { + return 0; + } + return (hours * 3600) + (minutes * 60) + seconds; +} + +function parseHandBrakeDurationSeconds(rawDuration) { + if (rawDuration && typeof rawDuration === 'object') { + const hours = Number(rawDuration.Hours ?? rawDuration.hours ?? 0); + const minutes = Number(rawDuration.Minutes ?? rawDuration.minutes ?? 0); + const seconds = Number(rawDuration.Seconds ?? rawDuration.seconds ?? 0); + if (Number.isFinite(hours) && Number.isFinite(minutes) && Number.isFinite(seconds)) { + return Math.max(0, Math.trunc((hours * 3600) + (minutes * 60) + seconds)); + } + } + + const parsedHms = parseHmsDurationToSeconds(rawDuration); + if (parsedHms > 0) { + return parsedHms; + } + + const asNumber = Number(rawDuration); + if (Number.isFinite(asNumber) && asNumber > 0) { + return Math.max(0, Math.trunc(asNumber)); + } + + return 0; +} + +function formatDurationClock(seconds) { + const total = Number(seconds || 0); + if (!Number.isFinite(total) || total <= 0) { + return null; + } + const rounded = Math.max(0, Math.trunc(total)); + const h = Math.floor(rounded / 3600); + const m = Math.floor((rounded % 3600) / 60); + const s = rounded % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +function parseRuntimeMinutesValue(rawValue) { + if (Number.isFinite(Number(rawValue)) && Number(rawValue) > 0) { + return Math.trunc(Number(rawValue)); + } + const text = String(rawValue || '').trim(); + if (!text) { + return null; + } + const normalized = text.toLowerCase(); + + const minMatch = normalized.match(/(\d+(?:[.,]\d+)?)\s*min\b/); + if (minMatch) { + const parsed = Number(String(minMatch[1]).replace(',', '.')); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.max(1, Math.round(parsed)); + } + } + + const hmsMatch = normalized.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); + if (hmsMatch) { + const hours = Number(hmsMatch[1] || 0); + const minutes = Number(hmsMatch[2] || 0); + const seconds = Number(hmsMatch[3] || 0); + if (Number.isFinite(hours) && Number.isFinite(minutes) && Number.isFinite(seconds)) { + const totalMinutes = Math.round(((hours * 3600) + (minutes * 60) + seconds) / 60); + return totalMinutes > 0 ? totalMinutes : null; + } + } + + const hourMatch = normalized.match(/(\d+)\s*h(?:our|hours)?\s*(\d{1,2})?\s*m?/); + if (hourMatch) { + const hours = Number(hourMatch[1] || 0); + const minutes = Number(hourMatch[2] || 0); + if (Number.isFinite(hours) && Number.isFinite(minutes)) { + const totalMinutes = (hours * 60) + minutes; + return totalMinutes > 0 ? totalMinutes : null; + } + } + + const numeric = Number(normalized.replace(',', '.')); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.max(1, Math.round(numeric)); + } + + return null; +} + +function extractMovieRuntimeMinutesFromMetadata(selectedMetadata = null) { + const metadata = selectedMetadata && typeof selectedMetadata === 'object' + ? selectedMetadata + : {}; + const runtimeSources = [ + metadata?.runtime, + metadata?.runtimeLabel, + metadata?.tmdbDetails?.runtime, + metadata?.tmdbDetails?.runtimeLabel + ]; + for (const source of runtimeSources) { + const parsed = parseRuntimeMinutesValue(source); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return null; +} + +function uniqueNormalizedPlaylistIds(values = []) { + const seen = new Set(); + const normalized = []; + for (const value of (Array.isArray(values) ? values : [])) { + const playlistId = normalizePlaylistId(value); + if (!playlistId || seen.has(playlistId)) { + continue; + } + seen.add(playlistId); + normalized.push(playlistId); + } + return normalized; +} + +function extractPlaylistAnalysisPlaylistId(item = null) { + return normalizePlaylistId(item?.playlistId || item?.playlistFile || null); +} + +function extractPlaylistAnalysisDurationSeconds(item = null) { + const rawSeconds = Number(item?.durationSeconds ?? item?.duration ?? 0); + return Number.isFinite(rawSeconds) && rawSeconds > 0 + ? Math.trunc(rawSeconds) + : 0; +} + +function applyTmdbRuntimeFilterToPlaylistAnalysis( + playlistAnalysis, + { + selectedMetadata = null, + settings = null, + disableMinLengthFilter = false + } = {} +) { + const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' + ? playlistAnalysis + : null; + if (!analysis) { + return { + playlistAnalysis: analysis, + applied: false, + reason: 'missing_analysis' + }; + } + + const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata); + if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) { + return { + playlistAnalysis: analysis, + applied: false, + reason: 'missing_runtime' + }; + } + + const minLengthMinutes = disableMinLengthFilter + ? 0 + : Number(settings?.makemkv_min_length_minutes ?? analysis?.minLengthMinutes ?? 0); + const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0 + ? Math.round(minLengthMinutes * 60) + : 0; + const configuredPercentRaw = Number(settings?.[PLAYLIST_RUNTIME_SUBTRACT_PERCENT_SETTING_KEY] ?? 0); + const configuredPercent = Number.isFinite(configuredPercentRaw) && configuredPercentRaw > 0 + ? configuredPercentRaw + : 0; + const expectedSeconds = Math.round(runtimeMinutes * 60); + const configuredDeductionSeconds = configuredPercent > 0 + ? Math.round(expectedSeconds * (configuredPercent / 100)) + : 0; + const deductionSeconds = Math.max( + PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS, + configuredDeductionSeconds + ); + const lowerBoundSeconds = Math.max(minLengthSeconds, expectedSeconds - deductionSeconds); + + const keepPlaylistIds = new Set( + [ + ...(Array.isArray(analysis?.evaluatedCandidates) ? analysis.evaluatedCandidates : []), + ...(Array.isArray(analysis?.candidates) ? analysis.candidates : []), + ...(Array.isArray(analysis?.titles) ? analysis.titles : []) + ] + .filter((item) => extractPlaylistAnalysisDurationSeconds(item) >= lowerBoundSeconds) + .map((item) => extractPlaylistAnalysisPlaylistId(item)) + .filter(Boolean) + ); + + const filterRows = (rows, { keepWithoutPlaylist = false } = {}) => ( + (Array.isArray(rows) ? rows : []).filter((item) => { + const playlistId = extractPlaylistAnalysisPlaylistId(item); + if (!playlistId) { + return keepWithoutPlaylist; + } + return keepPlaylistIds.has(playlistId); + }) + ); + + const filteredTitles = filterRows(analysis?.titles, { keepWithoutPlaylist: true }); + const filteredCandidates = filterRows(analysis?.candidates); + const filteredEvaluatedCandidates = filterRows(analysis?.evaluatedCandidates); + const filteredCandidatePlaylistsAll = uniqueNormalizedPlaylistIds( + filteredCandidates.map((item) => extractPlaylistAnalysisPlaylistId(item)) + ); + const filteredCandidatePlaylistsRanked = uniqueNormalizedPlaylistIds( + filteredEvaluatedCandidates.map((item) => extractPlaylistAnalysisPlaylistId(item)) + ); + const filteredCandidatePlaylists = filteredCandidatePlaylistsAll.length > 1 + ? uniqueNormalizedPlaylistIds([...filteredCandidatePlaylistsRanked, ...filteredCandidatePlaylistsAll]) + : []; + const filteredDuplicateDurationGroups = (Array.isArray(analysis?.duplicateDurationGroups) ? analysis.duplicateDurationGroups : []) + .map((group) => filterRows(group)) + .filter((group) => group.length > 1); + const recommendationPlaylistId = normalizePlaylistId(analysis?.recommendation?.playlistId); + const recommendationSurvives = recommendationPlaylistId && keepPlaylistIds.has(recommendationPlaylistId); + const fallbackRecommendation = filteredEvaluatedCandidates[0] || filteredCandidates[0] || null; + const nextRecommendation = recommendationSurvives + ? analysis.recommendation + : (fallbackRecommendation + ? { + titleId: Number.isFinite(Number(fallbackRecommendation?.titleId)) + ? Math.trunc(Number(fallbackRecommendation.titleId)) + : null, + playlistId: extractPlaylistAnalysisPlaylistId(fallbackRecommendation), + score: Number.isFinite(Number(fallbackRecommendation?.score)) + ? Number(fallbackRecommendation.score) + : 0, + reason: Array.isArray(fallbackRecommendation?.reasons) && fallbackRecommendation.reasons.length > 0 + ? fallbackRecommendation.reasons.join('; ') + : (String(fallbackRecommendation?.evaluationLabel || '').trim() || 'höchster Struktur-Score') + } + : null); + + const nextPlaylistToTitleId = Object.fromEntries( + Object.entries( + analysis?.playlistToTitleId && typeof analysis.playlistToTitleId === 'object' + ? analysis.playlistToTitleId + : {} + ).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key))) + ); + const nextPlaylistAliasMap = Object.fromEntries( + Object.entries( + analysis?.playlistAliasMap && typeof analysis.playlistAliasMap === 'object' + ? analysis.playlistAliasMap + : {} + ).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key))) + ); + const nextPlaylistSegments = Object.fromEntries( + Object.entries( + analysis?.playlistSegments && typeof analysis.playlistSegments === 'object' + ? analysis.playlistSegments + : {} + ).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key))) + ); + + const filteredAnalysis = { + ...analysis, + titles: filteredTitles, + candidates: filteredCandidates, + duplicateDurationGroups: filteredDuplicateDurationGroups, + obfuscationDetected: filteredDuplicateDurationGroups.length > 0, + manualDecisionRequired: filteredCandidatePlaylists.length > 1, + manualDecisionReason: filteredCandidatePlaylists.length > 1 + ? (filteredDuplicateDurationGroups.length > 0 + ? 'multiple_similar_candidates' + : 'multiple_candidates_after_min_length') + : null, + candidatePlaylists: filteredCandidatePlaylists, + candidatePlaylistFiles: filteredCandidatePlaylists.map((item) => `${item}.mpls`), + playlistToTitleId: nextPlaylistToTitleId, + playlistAliasMap: nextPlaylistAliasMap, + recommendation: nextRecommendation, + evaluatedCandidates: filteredEvaluatedCandidates, + playlistSegments: nextPlaylistSegments, + structuralAnalysis: { + ...(analysis?.structuralAnalysis && typeof analysis.structuralAnalysis === 'object' + ? analysis.structuralAnalysis + : {}), + analyzedPlaylists: Object.keys(nextPlaylistSegments).length + } + }; + + return { + playlistAnalysis: filteredAnalysis, + applied: true, + runtimeMinutes, + expectedSeconds, + minLengthSeconds, + configuredPercent, + configuredDeductionSeconds, + deductionSeconds, + lowerBoundSeconds, + sourceCount: uniqueNormalizedPlaylistIds( + (Array.isArray(analysis?.candidates) ? analysis.candidates : []) + .map((item) => extractPlaylistAnalysisPlaylistId(item)) + ).length, + resultCount: filteredCandidatePlaylistsAll.length + }; +} + +function resolvePlaylistByRuntimeAutoMatch({ playlistCandidates = [], selectedMetadata = null } = {}) { + const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata); + if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) { + return { + playlistId: null, + runtimeMinutes: null, + reason: 'missing_runtime' + }; + } + const expectedSeconds = runtimeMinutes * 60; + const toleranceSeconds = PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS; + const rows = (Array.isArray(playlistCandidates) ? playlistCandidates : []) + .map((item) => ({ + playlistId: normalizePlaylistId(item?.playlistId), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => item.playlistId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + if (rows.length < 2) { + return { + playlistId: null, + runtimeMinutes, + reason: 'insufficient_candidates' + }; + } + const withinTolerance = rows + .map((item) => ({ + ...item, + deltaSeconds: Math.abs(Math.trunc(item.durationSeconds) - expectedSeconds) + })) + .filter((item) => item.deltaSeconds <= toleranceSeconds) + .sort((a, b) => a.deltaSeconds - b.deltaSeconds || a.playlistId.localeCompare(b.playlistId)); + + if (withinTolerance.length !== 1) { + return { + playlistId: null, + runtimeMinutes, + reason: withinTolerance.length === 0 ? 'no_match_in_tolerance' : 'ambiguous_match', + matchCount: withinTolerance.length + }; + } + + return { + playlistId: withinTolerance[0].playlistId, + runtimeMinutes, + expectedSeconds, + durationSeconds: withinTolerance[0].durationSeconds, + deltaSeconds: withinTolerance[0].deltaSeconds, + toleranceSeconds + }; +} + +function normalizeTrackLanguage(raw) { + const value = String(raw || '').trim(); + if (!value) { + return 'und'; + } + const normalized = value.toLowerCase().slice(0, 3); + if (!normalized || normalized === 'un' || normalized === 'unk' || normalized === 'und') { + return 'und'; + } + return normalized; +} + +function isUnknownTrackLanguage(raw) { + const value = String(raw || '').trim().toLowerCase(); + return !value || value === 'und' || value === 'unknown' || value === 'unk' || value === 'un'; +} + +function normalizeTrackLanguageLabel(raw, fallbackLanguage = 'und') { + const value = String(raw || '').trim(); + if (value) { + return value; + } + return String(fallbackLanguage || 'und').trim() || 'und'; +} + +function buildSeriesBackupLanguageEnrichmentCandidatesFromPlaylistCache(scanCache) { + const normalizedCache = normalizeHandBrakePlaylistScanCache(scanCache); + if (!normalizedCache || !normalizedCache.byPlaylist || typeof normalizedCache.byPlaylist !== 'object') { + return []; + } + return Object.values(normalizedCache.byPlaylist) + .map((entry) => { + const titleInfo = entry?.titleInfo && typeof entry.titleInfo === 'object' + ? entry.titleInfo + : null; + if (!titleInfo) { + return null; + } + const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; + const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []; + return { + handBrakeTitleId: Number.isFinite(Number(entry?.handBrakeTitleId)) + ? Math.trunc(Number(entry.handBrakeTitleId)) + : null, + fileName: String(titleInfo?.fileName || '').trim() || null, + durationSeconds: Number(titleInfo?.durationSeconds || 0) || 0, + sizeBytes: Number(titleInfo?.sizeBytes || 0) || 0, + audioTracks, + subtitleTracks + }; + }) + .filter((title) => + title + && Number.isFinite(title.durationSeconds) + && title.durationSeconds > 0 + && (title.audioTracks.length > 0 || title.subtitleTracks.length > 0) + ); +} + +function buildSeriesBackupLanguageEnrichmentCandidatesFromHandBrakeScan(scanJson) { + const titleList = parseHandBrakeTitleList(scanJson); + if (!Array.isArray(titleList) || titleList.length === 0) { + return []; + } + + return titleList + .map((title) => { + const handBrakeTitleId = Number.isFinite(Number(title?.handBrakeTitleId)) + ? Math.trunc(Number(title.handBrakeTitleId)) + : null; + if (!handBrakeTitleId) { + return null; + } + const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { + handBrakeTitleId, + strictHandBrakeTitleId: true + }); + if (!titleInfo) { + return null; + } + const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; + const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []; + return { + handBrakeTitleId, + fileName: String(titleInfo?.fileName || '').trim() || null, + durationSeconds: Number(titleInfo?.durationSeconds || title?.durationSeconds || 0) || 0, + sizeBytes: Number(titleInfo?.sizeBytes || title?.sizeBytes || 0) || 0, + audioTracks, + subtitleTracks + }; + }) + .filter((title) => + title + && Number.isFinite(title.durationSeconds) + && title.durationSeconds > 0 + && (title.audioTracks.length > 0 || title.subtitleTracks.length > 0) + ); +} + +function mapSeriesBackupReviewTitlesToLanguageCandidates(reviewTitles, candidates) { + const normalizedTitles = Array.isArray(reviewTitles) ? reviewTitles : []; + const normalizedCandidates = Array.isArray(candidates) ? candidates : []; + const mapping = new Map(); + if (normalizedTitles.length === 0 || normalizedCandidates.length === 0) { + return mapping; + } + + const usedCandidateIndexes = new Set(); + const normalizeFileName = (value) => String(value || '').trim().toLowerCase(); + + // 1) Prefer deterministic filename mapping when possible. + for (const title of normalizedTitles) { + const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; + if (!titleId || mapping.has(titleId)) { + continue; + } + const titleFileName = normalizeFileName(title?.fileName || title?.name || null); + if (!titleFileName) { + continue; + } + let matchedIndex = null; + for (let index = 0; index < normalizedCandidates.length; index += 1) { + if (usedCandidateIndexes.has(index)) { + continue; + } + const candidateFileName = normalizeFileName(normalizedCandidates[index]?.fileName || null); + if (!candidateFileName || candidateFileName !== titleFileName) { + continue; + } + matchedIndex = index; + break; + } + if (matchedIndex === null) { + continue; + } + usedCandidateIndexes.add(matchedIndex); + mapping.set(titleId, normalizedCandidates[matchedIndex]); + } + + // 2) Fallback: duration/size score mapping. + for (const title of normalizedTitles) { + const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; + if (!titleId || mapping.has(titleId)) { + continue; + } + const durationSeconds = Number(title?.durationSeconds || 0) || 0; + const sizeBytes = Number(title?.sizeBytes || 0) || 0; + + let bestIndex = null; + let bestScore = Number.POSITIVE_INFINITY; + for (let index = 0; index < normalizedCandidates.length; index += 1) { + if (usedCandidateIndexes.has(index)) { + continue; + } + const candidate = normalizedCandidates[index]; + const durationDelta = Math.abs(Number(candidate?.durationSeconds || 0) - durationSeconds); + const sizeDelta = ( + sizeBytes > 0 && Number(candidate?.sizeBytes || 0) > 0 + ? Math.abs(Number(candidate.sizeBytes) - sizeBytes) + : 0 + ); + const score = (durationDelta * 1024 * 1024) + sizeDelta; + if (score < bestScore) { + bestScore = score; + bestIndex = index; + } + } + + if (bestIndex === null) { + continue; + } + usedCandidateIndexes.add(bestIndex); + mapping.set(titleId, normalizedCandidates[bestIndex]); + } + + return mapping; +} + +function enrichSeriesBackupReviewLanguagesFromCandidates(reviewPlan, candidates = []) { + if (!reviewPlan || !Array.isArray(reviewPlan?.titles) || reviewPlan.titles.length === 0) { + return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; + } + + const normalizedCandidates = Array.isArray(candidates) ? candidates : []; + if (normalizedCandidates.length === 0) { + return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; + } + + const mapping = mapSeriesBackupReviewTitlesToLanguageCandidates(reviewPlan.titles, normalizedCandidates); + if (mapping.size === 0) { + return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; + } + + let enrichedAudioTracks = 0; + let enrichedSubtitleTracks = 0; + + const enrichedTitles = reviewPlan.titles.map((title) => { + const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; + if (!titleId || !mapping.has(titleId)) { + return title; + } + const candidate = mapping.get(titleId); + const candidateAudioTracks = Array.isArray(candidate?.audioTracks) ? candidate.audioTracks : []; + const candidateSubtitleTracks = Array.isArray(candidate?.subtitleTracks) ? candidate.subtitleTracks : []; + + const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track, index) => { + const sourceTrack = candidateAudioTracks[index] || null; + if (!sourceTrack) { + return track; + } + const currentLanguage = String(track?.language || '').trim().toLowerCase(); + if (!isUnknownTrackLanguage(currentLanguage)) { + return track; + } + const sourceLanguage = normalizeTrackLanguage(sourceTrack?.language || sourceTrack?.languageLabel || 'und'); + if (isUnknownTrackLanguage(sourceLanguage)) { + return track; + } + enrichedAudioTracks += 1; + return { + ...track, + language: sourceLanguage, + languageLabel: normalizeTrackLanguageLabel(sourceTrack?.languageLabel || sourceTrack?.language, sourceLanguage), + title: track?.title || sourceTrack?.title || sourceTrack?.description || null, + format: track?.format || sourceTrack?.format || null, + channels: track?.channels || sourceTrack?.channels || null + }; + }); + + const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track, index) => { + const sourceTrack = candidateSubtitleTracks[index] || null; + if (!sourceTrack) { + return track; + } + const currentLanguage = String(track?.language || '').trim().toLowerCase(); + if (!isUnknownTrackLanguage(currentLanguage)) { + return track; + } + const sourceLanguage = normalizeTrackLanguage(sourceTrack?.language || sourceTrack?.languageLabel || 'und'); + if (isUnknownTrackLanguage(sourceLanguage)) { + return track; + } + enrichedSubtitleTracks += 1; + return { + ...track, + language: sourceLanguage, + languageLabel: normalizeTrackLanguageLabel(sourceTrack?.languageLabel || sourceTrack?.language, sourceLanguage), + title: track?.title || sourceTrack?.title || sourceTrack?.description || null, + format: track?.format || sourceTrack?.format || null, + forcedFlag: Boolean(track?.forcedFlag ?? sourceTrack?.forcedFlag), + sdhFlag: Boolean(track?.sdhFlag ?? sourceTrack?.sdhFlag), + defaultFlag: Boolean(track?.defaultFlag ?? sourceTrack?.defaultFlag), + eventCount: Number.isFinite(Number(track?.eventCount)) + ? Math.trunc(Number(track.eventCount)) + : (Number.isFinite(Number(sourceTrack?.eventCount)) ? Math.trunc(Number(sourceTrack.eventCount)) : null), + streamSizeBytes: Number.isFinite(Number(track?.streamSizeBytes)) + ? Math.trunc(Number(track.streamSizeBytes)) + : (Number.isFinite(Number(sourceTrack?.streamSizeBytes)) ? Math.trunc(Number(sourceTrack.streamSizeBytes)) : null) + }; + }); + + return { + ...title, + audioTracks, + subtitleTracks + }; + }); + + const applied = enrichedAudioTracks > 0 || enrichedSubtitleTracks > 0; + if (!applied) { + return { plan: reviewPlan, applied: false, matchedTitles: mapping.size, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; + } + + return { + plan: { + ...reviewPlan, + titles: enrichedTitles + }, + applied: true, + matchedTitles: mapping.size, + enrichedAudioTracks, + enrichedSubtitleTracks + }; +} + +function normalizePositiveTrackId(rawValue) { + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeSubtitleConfidence(raw) { + const value = String(raw || '').trim().toLowerCase(); + if (value === 'high' || value === 'medium' || value === 'low') { + return value; + } + return 'low'; +} + +function subtitleConfidenceScore(raw) { + const normalized = normalizeSubtitleConfidence(raw); + return SUBTITLE_CONFIDENCE_SCORES[normalized] || 0; +} + +function parseSubtitleEventCount(track) { + const candidates = [ + track?.eventCount, + track?.EventCount, + track?.countOfEvents, + track?.CountOfEvents, + track?.elementCount, + track?.ElementCount + ]; + for (const candidate of candidates) { + const numeric = Number(candidate); + if (Number.isFinite(numeric) && numeric >= 0) { + return Math.trunc(numeric); + } + } + return null; +} + +function parseSubtitleStreamSizeBytes(track) { + const numericCandidates = [ + track?.streamSizeBytes, + track?.streamSize, + track?.StreamSize, + track?.sizeBytes, + track?.bytes, + track?.Bytes + ]; + for (const candidate of numericCandidates) { + const numeric = Number(candidate); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.trunc(numeric); + } + } + + const textCandidates = [ + track?.streamSizeString, + track?.StreamSize_String, + track?.sizeString, + track?.Size_String + ]; + for (const candidate of textCandidates) { + const parsed = parseSizeToBytes(candidate); + if (parsed > 0) { + return parsed; + } + } + return null; +} + +function parseSubtitleDefaultFlag(track) { + if (typeof track?.defaultFlag === 'boolean') { + return track.defaultFlag; + } + const candidates = [ + track?.default, + track?.Default, + track?.isDefault, + track?.IsDefault, + track?.subtitlePreviewDefaultTrack, + track?.defaultTrack + ]; + for (const candidate of candidates) { + if (typeof candidate === 'boolean') { + return candidate; + } + const raw = String(candidate || '').trim().toLowerCase(); + if (!raw) { + continue; + } + if (raw === 'yes' || raw === 'true' || raw === '1') { + return true; + } + if (raw === 'no' || raw === 'false' || raw === '0') { + return false; + } + } + return false; +} + +function parseLooseBoolean(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (value === 1) { + return true; + } + if (value === 0) { + return false; + } + } + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'yes' || raw === 'true' || raw === '1' || raw === 'y') { + return true; + } + if (raw === 'no' || raw === 'false' || raw === '0' || raw === 'n') { + return false; + } + return null; +} + +function parseSubtitleForcedFlag(track) { + if (!track || typeof track !== 'object') { + return null; + } + const candidates = [ + track?.forcedFlag, + track?.forced, + track?.Forced, + track?.isForced, + track?.IsForced, + track?.forced_only, + track?.forcedOnly, + track?.Attributes?.Forced + ]; + for (const candidate of candidates) { + const parsed = parseLooseBoolean(candidate); + if (parsed === true || parsed === false) { + return parsed; + } + } + return null; +} + +function parseSubtitleSdhFlag(track) { + if (!track || typeof track !== 'object') { + return null; + } + const candidates = [ + track?.sdhFlag, + track?.hearingImpaired, + track?.HearingImpaired, + track?.isHearingImpaired, + track?.IsHearingImpaired, + track?.Hearing_Impaired, + track?.closedCaptions, + track?.ClosedCaptions, + track?.Attributes?.HearingImpaired, + track?.Attributes?.ClosedCaptions + ]; + for (const candidate of candidates) { + const parsed = parseLooseBoolean(candidate); + if (parsed === true || parsed === false) { + return parsed; + } + } + return null; +} + +function collectSubtitleText(track) { + return [ + track?.title, + track?.description, + track?.name, + track?.format, + track?.label + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); +} + +function isLikelyBitmapSubtitleFormat(track) { + const text = [ + track?.format, + track?.codec, + track?.codecName, + track?.title, + track?.description, + track?.name, + track?.label + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); + if (!text) { + return false; + } + return ( + /\bpgs\b/.test(text) + || /\bhdmv\b/.test(text) + || /\bsup\b/.test(text) + || /\bvobsub\b/.test(text) + || /\bdvd[-_\s]?sub/.test(text) + || /\bdvb[-_\s]?sub/.test(text) + ); +} + +function isLikelySdhSubtitleTrack(track) { + const text = collectSubtitleText(track); + if (!text) { + return false; + } + return ( + /\bsdh\b/.test(text) + || /\bcc\b/.test(text) + || /\bhoh\b/.test(text) + || /\bcaptions?\b/.test(text) + || /hard[-\s]?of[-\s]?hearing/.test(text) + || /hearing\s+impaired/.test(text) + || /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text) + ); +} + +function isLikelyForcedSubtitleTrack(track) { + const text = collectSubtitleText(track); + if (!text) { + return false; + } + if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) { + return false; + } + return ( + /\bforced(?:\s+only)?\b/.test(text) + || /nur\s+erzwungen/.test(text) + || /\berzwungen\b/.test(text) + ); +} + +function compareSubtitleTracksByForcedHeuristic(a, b) { + const aSdh = Number(Boolean(a?.sdhLikely)); + const bSdh = Number(Boolean(b?.sdhLikely)); + if (aSdh !== bSdh) { + return aSdh - bSdh; + } + + const aDefault = Number(Boolean(a?.defaultFlag)); + const bDefault = Number(Boolean(b?.defaultFlag)); + if (aDefault !== bDefault) { + return aDefault - bDefault; + } + + const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1; + const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1; + if (aEventKnown !== bEventKnown) { + return aEventKnown - bEventKnown; + } + if (aEventKnown === 0 && a.eventCount !== b.eventCount) { + return a.eventCount - b.eventCount; + } + + const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1; + const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1; + if (aSizeKnown !== bSizeKnown) { + return aSizeKnown - bSizeKnown; + } + if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) { + return a.streamSizeBytes - b.streamSizeBytes; + } + + const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER; + const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER; + if (aTrackId !== bTrackId) { + return aTrackId - bTrackId; + } + return a.originalIndex - b.originalIndex; +} + +function isHeuristicForcedSubtitleCandidate(entries, candidate) { + if (!candidate) { + return false; + } + if (!isLikelyBitmapSubtitleFormat(candidate)) { + return false; + } + const comparable = (Array.isArray(entries) ? entries : []) + .filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry)); + if (comparable.length === 0) { + return false; + } + + const candidateEventCount = Number(candidate?.eventCount); + const comparableEventCounts = comparable + .map((entry) => Number(entry?.eventCount)) + .filter((value) => Number.isFinite(value) && value > 0); + const maxComparableEventCount = comparableEventCounts.length > 0 + ? Math.max(...comparableEventCounts) + : null; + const hasEventSignal = Number.isFinite(candidateEventCount) + && candidateEventCount >= 0 + && Number.isFinite(maxComparableEventCount) + && maxComparableEventCount > 0 + && candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT + && (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD + && (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP; + + const candidateStreamSize = Number(candidate?.streamSizeBytes); + const comparableSizes = comparable + .map((entry) => Number(entry?.streamSizeBytes)) + .filter((value) => Number.isFinite(value) && value > 0); + const maxComparableSize = comparableSizes.length > 0 + ? Math.max(...comparableSizes) + : null; + const hasSizeSignal = Number.isFinite(candidateStreamSize) + && candidateStreamSize > 0 + && Number.isFinite(maxComparableSize) + && maxComparableSize > 0 + && (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD + && (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES; + + const signalCount = Number(hasEventSignal) + Number(hasSizeSignal); + if (signalCount === 0) { + return false; + } + if (candidate.defaultFlag && signalCount < 2) { + return false; + } + return true; +} + +function compareSubtitleTracksForDedup(a, b) { + const aSdh = Number(Boolean(a?.sdhLikely)); + const bSdh = Number(Boolean(b?.sdhLikely)); + if (aSdh !== bSdh) { + return aSdh - bSdh; + } + + const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); + if (defaultDiff !== 0) { + return defaultDiff; + } + + const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence); + if (confidenceDiff !== 0) { + return confidenceDiff; + } + + const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER; + const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER; + if (aTrackId !== bTrackId) { + return aTrackId - bTrackId; + } + return a.originalIndex - b.originalIndex; +} + +function annotateSubtitleForcedAvailability(handBrakeSubtitleTracks, makeMkvSubtitleTracks) { + const hbTracks = Array.isArray(handBrakeSubtitleTracks) ? handBrakeSubtitleTracks : []; + if (hbTracks.length === 0) { + return []; + } + + const mkTracks = Array.isArray(makeMkvSubtitleTracks) ? makeMkvSubtitleTracks : []; + const normalizedMkTracks = mkTracks.map((track, index) => { + const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); + const sourceTrackId = normalizePositiveTrackId(track?.sourceTrackId ?? track?.id); + return { + ...track, + language, + sourceTrackId, + originalIndex: index + }; + }); + + const mkBySourceTrackId = new Map(); + const mkByLanguage = new Map(); + for (const track of normalizedMkTracks) { + if (track.sourceTrackId) { + const key = String(track.sourceTrackId); + if (!mkBySourceTrackId.has(key)) { + mkBySourceTrackId.set(key, track); + } + } + if (!mkByLanguage.has(track.language)) { + mkByLanguage.set(track.language, []); + } + mkByLanguage.get(track.language).push(track); + } + + const normalizedHbTracks = hbTracks.map((track, index) => { + const id = normalizePositiveTrackId(track?.id) || normalizeScanTrackId(track?.id, index); + const forcedFlag = parseSubtitleForcedFlag(track); + const sdhFlag = parseSubtitleSdhFlag(track); + return { + track, + originalIndex: index, + id, + sourceTrackId: normalizePositiveTrackId(track?.sourceTrackId ?? track?.id), + language: normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'), + defaultFlag: parseSubtitleDefaultFlag(track), + forcedFlag, + sdhFlag, + eventCount: parseSubtitleEventCount(track), + streamSizeBytes: parseSubtitleStreamSizeBytes(track), + mkTrack: null, + sdhLikely: (sdhFlag === true) || isLikelySdhSubtitleTrack(track), + forced: false, + confidence: null, + confidenceSource: 'heuristic', + duplicate: false, + selected: false + }; + }); + + const hbByLanguage = new Map(); + for (const entry of normalizedHbTracks) { + if (!hbByLanguage.has(entry.language)) { + hbByLanguage.set(entry.language, []); + } + hbByLanguage.get(entry.language).push(entry); + } + + for (const [language, entries] of hbByLanguage.entries()) { + const sortedEntries = [...entries].sort((a, b) => ( + (a.sourceTrackId || Number.MAX_SAFE_INTEGER) - (b.sourceTrackId || Number.MAX_SAFE_INTEGER) + || a.originalIndex - b.originalIndex + )); + const mkLanguageTracks = [...(mkByLanguage.get(language) || [])].sort((a, b) => ( + (a.sourceTrackId || Number.MAX_SAFE_INTEGER) - (b.sourceTrackId || Number.MAX_SAFE_INTEGER) + || a.originalIndex - b.originalIndex + )); + + for (let index = 0; index < sortedEntries.length; index += 1) { + const entry = sortedEntries[index]; + let mkMatch = null; + if (entry.sourceTrackId) { + mkMatch = mkBySourceTrackId.get(String(entry.sourceTrackId)) || null; + if (mkMatch && mkMatch.language !== language) { + mkMatch = null; + } + } + if (!mkMatch) { + if (mkLanguageTracks.length === sortedEntries.length) { + mkMatch = mkLanguageTracks[index] || null; + } else if (mkLanguageTracks.length === 1) { + mkMatch = mkLanguageTracks[0]; + } + } + entry.mkTrack = mkMatch; + if (mkMatch && ((parseSubtitleSdhFlag(mkMatch) === true) || isLikelySdhSubtitleTrack(mkMatch))) { + entry.sdhLikely = true; + } + } + } + + for (const entry of normalizedHbTracks) { + const forcedByExplicitFlag = entry.forcedFlag === true; + const forcedBlockedByExplicitFlag = entry.forcedFlag === false; + const forcedFromTitle = !entry.sdhLikely + && !forcedBlockedByExplicitFlag + && (isLikelyForcedSubtitleTrack(entry.track) || isLikelyForcedSubtitleTrack(entry.mkTrack)); + + if (forcedByExplicitFlag) { + entry.forced = true; + entry.confidence = 'high'; + entry.confidenceSource = 'explicit_flag'; + } else if (forcedFromTitle) { + entry.forced = true; + entry.confidence = 'medium'; + entry.confidenceSource = 'title'; + } else { + entry.forced = false; + entry.confidence = null; + entry.confidenceSource = 'heuristic'; + } + } + + for (const [language, entries] of hbByLanguage.entries()) { + if (!entries.some((entry) => entry.forced)) { + const heuristicCandidate = [...entries] + .filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false) + .sort(compareSubtitleTracksByForcedHeuristic)[0] || null; + + if (!entries.some((entry) => entry.forced) && heuristicCandidate + && isHeuristicForcedSubtitleCandidate(entries, heuristicCandidate)) { + // Last-resort: infer forced by comparatively tiny event/size profile. + // Avoid auto-detection for SDH/CC tracks and weak one-track cases. + heuristicCandidate.forced = true; + heuristicCandidate.confidence = 'low'; + heuristicCandidate.confidenceSource = 'heuristic'; + } + } + + for (const entry of entries) { + if (!entry.forced) { + continue; + } + if (entry.confidenceSource === 'explicit_flag') { + entry.confidence = 'high'; + } else if (entry.confidenceSource === 'title') { + entry.confidence = 'medium'; + } else { + entry.confidence = 'low'; + } + } + + const forcedEntries = entries.filter((entry) => entry.forced); + const fullEntries = entries.filter((entry) => !entry.forced && !entry.sdhLikely); + const sdhEntries = entries.filter((entry) => !entry.forced && entry.sdhLikely); + const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null; + const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null; + const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null; + const forcedSourceTrackIds = normalizeTrackIdList([ + ...(forcedWinner?.sourceTrackId ? [forcedWinner.sourceTrackId] : []) + ]); + const forcedAvailable = Boolean(forcedWinner); + + for (const entry of entries) { + const typeWinner = entry.forced + ? forcedWinner + : (entry.sdhLikely ? sdhWinner : fullWinner); + entry.duplicate = Boolean(typeWinner && typeWinner !== entry); + if (entry.forced) { + entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); + } else if (entry.sdhLikely) { + // Keep SDH as an available variant, but auto-select it only when no regular full track exists. + entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate); + } else { + entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); + } + const subtitleType = entry.forced ? 'forced' : 'full'; + const confidence = entry.forced + ? normalizeSubtitleConfidence(entry.confidence || 'low') + : null; + const burned = Boolean(entry.track?.subtitlePreviewBurnIn || entry.track?.burnIn); + const defaultTrack = Boolean(entry.defaultFlag); + const forcedOnly = Boolean( + entry.track?.isForcedOnly + ?? entry.track?.forcedOnly + ?? entry.track?.subtitlePreviewForcedOnly + ?? (entry.forced && !entry.sdhLikely) + ); + const fullHasForced = !forcedOnly && Boolean( + entry.track?.fullHasForced + ?? entry.track?.subtitleFullHasForced + ?? entry.track?.hasForcedVariant + ); + const flags = []; + if (burned) { + flags.push('burned'); + } + if (entry.selected && entry.forced) { + flags.push('forced'); + } + if (forcedOnly) { + flags.push('forced-only'); + } + if (entry.selected && defaultTrack) { + flags.push('default'); + } + + entry.track = { + ...entry.track, + id: entry.id, + sourceTrackId: entry.sourceTrackId || entry.id, + language: entry.language, + forcedTrack: entry.forced, + forcedAvailable, + forcedSourceTrackIds, + isForcedOnly: forcedOnly, + fullHasForced, + subtitleType, + sourceConfidence: confidence, + confidenceSource: entry.confidenceSource, + sdhLikely: Boolean(entry.sdhLikely), + duplicate: entry.duplicate, + selected: entry.selected, + selectedByRule: entry.selected, + subtitlePreviewSummary: entry.selected ? 'Übernehmen' : (entry.duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'), + subtitlePreviewFlags: entry.selected ? flags : [], + subtitlePreviewBurnIn: entry.selected ? burned : false, + subtitlePreviewForced: entry.selected ? entry.forced : false, + subtitlePreviewForcedOnly: entry.selected ? forcedOnly : false, + subtitlePreviewDefaultTrack: entry.selected ? defaultTrack : false + }; + } + } + + return normalizedHbTracks + .sort((a, b) => a.originalIndex - b.originalIndex) + .map((entry) => entry.track); +} + +function enrichTitleInfoWithForcedSubtitleAvailability(titleInfo, makeMkvSubtitleTracks) { + if (!titleInfo || typeof titleInfo !== 'object') { + return titleInfo; + } + return { + ...titleInfo, + subtitleTracks: annotateSubtitleForcedAvailability( + Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : [], + makeMkvSubtitleTracks + ) + }; +} + +function pickScanTitleList(scanJson) { + if (!scanJson || typeof scanJson !== 'object') { + return []; + } + + const direct = Array.isArray(scanJson.TitleList) ? scanJson.TitleList : null; + if (direct) { + return direct; + } + + const scanNode = scanJson.Scan && typeof scanJson.Scan === 'object' ? scanJson.Scan : null; + if (scanNode) { + const scanTitles = Array.isArray(scanNode.TitleList) ? scanNode.TitleList : null; + if (scanTitles) { + return scanTitles; + } + } + + const alt = Array.isArray(scanJson.title_list) ? scanJson.title_list : null; + return alt || []; +} + +// Returns the HandBrake-selected main feature title ID (the "1 valid title" HandBrake reports). +// Present in the JSON as MainFeature at the top level or inside the Scan node. +function pickScanMainFeatureTitleId(scanJson) { + if (!scanJson || typeof scanJson !== 'object') { + return null; + } + const direct = scanJson.MainFeature ?? scanJson.mainFeature ?? null; + if (direct != null) { + const id = Number(direct); + return Number.isFinite(id) && id > 0 ? id : null; + } + const scanNode = scanJson.Scan && typeof scanJson.Scan === 'object' ? scanJson.Scan : null; + if (scanNode) { + const nested = scanNode.MainFeature ?? scanNode.mainFeature ?? null; + if (nested != null) { + const id = Number(nested); + return Number.isFinite(id) && id > 0 ? id : null; + } + } + return null; +} + +function normalizePlaylistAliasIds(values, excludePlaylistId = null) { + const exclude = normalizePlaylistId(excludePlaylistId); + const rows = Array.isArray(values) ? values : []; + const seen = new Set(); + const aliases = []; + for (const value of rows) { + const playlistId = normalizePlaylistId(value); + if (!playlistId || playlistId === exclude || seen.has(playlistId)) { + continue; + } + seen.add(playlistId); + aliases.push(playlistId); + } + return aliases; +} + +function getPlaylistAliasesForPlaylist(playlistAnalysis, playlistIdRaw) { + const playlistId = normalizePlaylistId(playlistIdRaw); + if (!playlistId || !playlistAnalysis || typeof playlistAnalysis !== 'object') { + return []; + } + const aliasMap = playlistAnalysis?.playlistAliasMap + && typeof playlistAnalysis.playlistAliasMap === 'object' + ? playlistAnalysis.playlistAliasMap + : null; + if (!aliasMap) { + return []; + } + return normalizePlaylistAliasIds(aliasMap[playlistId], playlistId); +} + +function resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistIdRaw) { + const playlistId = normalizePlaylistId(playlistIdRaw); + const playlistAliases = getPlaylistAliasesForPlaylist(playlistAnalysis, playlistId); + if (!playlistId || !playlistAnalysis) { + return { + playlistId: playlistId || null, + playlistFile: playlistId ? `${playlistId}.mpls` : null, + playlistAliases, + recommended: false, + evaluationLabel: null, + segmentCommand: playlistId ? `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts` : null, + segmentFiles: [] + }; + } + + const recommended = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId) === playlistId; + const evaluated = (Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []) + .find((item) => normalizePlaylistId(item?.playlistId) === playlistId) || null; + const segmentMap = playlistAnalysis.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object' + ? playlistAnalysis.playlistSegments + : {}; + const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; + const segmentFiles = Array.isArray(segmentEntry?.segmentFiles) + ? segmentEntry.segmentFiles.filter((item) => String(item || '').trim().length > 0) + : []; + + return { + playlistId, + playlistFile: `${playlistId}.mpls`, + playlistAliases, + recommended, + evaluationLabel: evaluated?.evaluationLabel || (recommended ? 'wahrscheinlich korrekt (Heuristik)' : null), + segmentCommand: segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, + segmentFiles + }; +} + +function normalizeScanTrackId(rawValue, fallbackIndex) { + const parsed = Number(rawValue); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.trunc(parsed); + } + return Math.max(1, Math.trunc(fallbackIndex) + 1); +} + +function parseSizeToBytes(rawValue) { + const text = String(rawValue || '').trim(); + if (!text) { + return 0; + } + + if (/^\d+$/.test(text)) { + const numeric = Number(text); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.trunc(numeric); + } + } + + const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i); + if (!match) { + return 0; + } + + const value = Number(String(match[1]).replace(',', '.')); + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + + const unit = String(match[2] || 'b').toLowerCase(); + const factorMap = { + b: 1, + kb: 1024, + mb: 1024 ** 2, + gb: 1024 ** 3, + tb: 1024 ** 4 + }; + const factor = factorMap[unit] || 1; + return Math.max(0, Math.trunc(value * factor)); +} + +function parseMakeMkvDurationSeconds(rawValue) { + const hms = parseHmsDurationToSeconds(rawValue); + if (hms > 0) { + return hms; + } + + const text = String(rawValue || '').trim(); + if (!text) { + return 0; + } + + const hours = Number((text.match(/(\d+)\s*h/i) || [])[1] || 0); + const minutes = Number((text.match(/(\d+)\s*m/i) || [])[1] || 0); + const seconds = Number((text.match(/(\d+)\s*s/i) || [])[1] || 0); + if (hours || minutes || seconds) { + return (hours * 3600) + (minutes * 60) + seconds; + } + + const numeric = Number(text); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.trunc(numeric); + } + + return 0; +} + +function buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo) { + const durationSeconds = Math.max(0, Math.trunc(Number(titleInfo?.durationSeconds || 0))); + const tracks = []; + tracks.push({ + '@type': 'General', + // MediaInfo reports numeric Duration as milliseconds. Keep this format so + // parseDurationSeconds() does not misinterpret long titles. + Duration: String(durationSeconds * 1000), + Duration_String3: formatDurationClock(durationSeconds) || null + }); + + const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; + const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []; + + for (const track of audioTracks) { + tracks.push({ + '@type': 'Audio', + ID: String(track?.sourceTrackId ?? track?.id ?? ''), + Language: track?.language || 'und', + Language_String3: track?.language || 'und', + Title: track?.title || null, + Format: track?.codecName || track?.format || null, + Channels: track?.channels || null + }); + } + + for (const track of subtitleTracks) { + tracks.push({ + '@type': 'Text', + ID: String(track?.sourceTrackId ?? track?.id ?? ''), + Language: track?.language || 'und', + Language_String3: track?.language || 'und', + Title: track?.title || null, + Format: track?.format || null, + Default: track?.defaultFlag ? 'Yes' : 'No', + CountOfEvents: Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null, + StreamSize: Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null + }); + } + + return { + media: { + track: tracks + } + }; +} + +function remapReviewTrackIdsToSourceIds(review) { + if (!review || !Array.isArray(review.titles)) { + return review; + } + + const normalizeSourceId = (track) => { + const normalized = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; + return normalized; + }; + + const titles = review.titles.map((title) => ({ + ...title, + audioTracks: (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { + const sourceTrackId = normalizeSourceId(track); + return { + ...track, + id: sourceTrackId || track?.id || null, + sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null + }; + }), + subtitleTracks: (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { + const sourceTrackId = normalizeSourceId(track); + return { + ...track, + id: sourceTrackId || track?.id || null, + sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null + }; + }) + })); + + return { + ...review, + titles + }; +} + +function extractPlaylistIdFromHandBrakeTitle(title) { + const directCandidates = [ + title?.Playlist, + title?.playlist, + title?.PlaylistName, + title?.playlistName, + title?.SourcePlaylist, + title?.sourcePlaylist + ]; + for (const candidate of directCandidates) { + const normalized = normalizePlaylistId(candidate); + if (normalized) { + return normalized; + } + } + + const textCandidates = [ + title?.Path, + title?.path, + title?.Name, + title?.name, + title?.File, + title?.file, + title?.TitleName, + title?.titleName, + title?.SourceName, + title?.sourceName + ]; + for (const candidate of textCandidates) { + const text = String(candidate || '').trim(); + if (!text) { + continue; + } + const match = text.match(/(\d{1,5})\.mpls\b/i); + if (!match) { + continue; + } + const normalized = normalizePlaylistId(match[1]); + if (normalized) { + return normalized; + } + } + + return null; +} + +function parseHandBrakeScanSizeBytes(title) { + const numeric = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + return Math.trunc(numeric); +} + +function buildHandBrakeScanTitleRows(scanJson) { + const titleList = pickScanTitleList(scanJson); + return titleList + .map((title, idx) => { + const handBrakeTitleId = normalizeScanTrackId( + title?.Index ?? title?.index ?? title?.Title ?? title?.title, + idx + ); + const playlist = extractPlaylistIdFromHandBrakeTitle(title); + const durationSeconds = parseHandBrakeDurationSeconds( + title?.Duration ?? title?.duration ?? title?.Length ?? title?.length + ); + const sizeBytes = parseHandBrakeScanSizeBytes(title); + const audioTrackCount = Array.isArray(title?.AudioList) ? title.AudioList.length : 0; + const subtitleTrackCount = Array.isArray(title?.SubtitleList) ? title.SubtitleList.length : 0; + return { + handBrakeTitleId, + playlist, + durationSeconds, + sizeBytes, + audioTrackCount, + subtitleTrackCount + }; + }) + .filter((item) => Number.isFinite(item.handBrakeTitleId) && item.handBrakeTitleId > 0); +} + +function listAvailableHandBrakePlaylists(scanJson) { + const rows = buildHandBrakeScanTitleRows(scanJson); + return Array.from(new Set( + rows + .map((item) => normalizePlaylistId(item?.playlist)) + .filter(Boolean) + )).sort(); +} + +function resolveHandBrakeTitleIdForPlaylist(scanJson, playlistIdRaw, options = {}) { + const playlistId = normalizePlaylistId(playlistIdRaw); + if (!playlistId) { + return null; + } + + const expectedMakemkvTitleIdRaw = Number(options?.expectedMakemkvTitleId); + const expectedMakemkvTitleId = Number.isFinite(expectedMakemkvTitleIdRaw) && expectedMakemkvTitleIdRaw >= 0 + ? Math.trunc(expectedMakemkvTitleIdRaw) + : null; + const expectedDurationRaw = Number(options?.expectedDurationSeconds); + const expectedDurationSeconds = Number.isFinite(expectedDurationRaw) && expectedDurationRaw > 0 + ? Math.trunc(expectedDurationRaw) + : null; + const expectedSizeRaw = Number(options?.expectedSizeBytes); + const expectedSizeBytes = Number.isFinite(expectedSizeRaw) && expectedSizeRaw > 0 + ? Math.trunc(expectedSizeRaw) + : null; + const durationToleranceRaw = Number(options?.durationToleranceSeconds); + const durationToleranceSeconds = Number.isFinite(durationToleranceRaw) && durationToleranceRaw >= 0 + ? Math.trunc(durationToleranceRaw) + : 5; + + const playlistAliases = normalizePlaylistAliasIds(options?.playlistAliases, playlistId); + const candidatePlaylistIds = [playlistId, ...playlistAliases]; + const candidatePlaylistSet = new Set(candidatePlaylistIds); + + const rows = buildHandBrakeScanTitleRows(scanJson); + const directMatches = rows.filter((item) => item.playlist === playlistId); + const matches = rows.filter((item) => candidatePlaylistSet.has(item.playlist)); + + const scoreForExpected = (row) => { + const durationDelta = expectedDurationSeconds !== null + ? Math.abs(Number(row?.durationSeconds || 0) - expectedDurationSeconds) + : Number.MAX_SAFE_INTEGER; + const sizeDelta = expectedSizeBytes !== null + ? Math.abs(Number(row?.sizeBytes || 0) - expectedSizeBytes) + : Number.MAX_SAFE_INTEGER; + const trackRichness = Number(row?.audioTrackCount || 0) + Number(row?.subtitleTrackCount || 0); + return { + row, + durationDelta, + sizeDelta, + trackRichness + }; + }; + + const sortByExpectedScore = (a, b) => + a.durationDelta - b.durationDelta + || a.sizeDelta - b.sizeDelta + || b.trackRichness - a.trackRichness + || b.row.durationSeconds - a.row.durationSeconds + || b.row.sizeBytes - a.row.sizeBytes + || a.row.handBrakeTitleId - b.row.handBrakeTitleId; + + if (matches.length > 0) { + if (expectedDurationSeconds !== null || expectedSizeBytes !== null) { + const scored = matches.map(scoreForExpected).sort(sortByExpectedScore); + const scoredDirect = directMatches.map(scoreForExpected).sort(sortByExpectedScore); + if (expectedDurationSeconds !== null) { + const directWithinTolerance = scoredDirect.filter((item) => item.durationDelta <= durationToleranceSeconds); + if (directWithinTolerance.length > 0) { + return directWithinTolerance[0].row.handBrakeTitleId; + } + const aliasWithinTolerance = scored.filter((item) => + item.durationDelta <= durationToleranceSeconds + && normalizePlaylistId(item?.row?.playlist) !== playlistId + ); + if (aliasWithinTolerance.length > 0) { + return aliasWithinTolerance[0].row.handBrakeTitleId; + } + } + if (scoredDirect.length > 0) { + return scoredDirect[0].row.handBrakeTitleId; + } + return scored[0].row.handBrakeTitleId; + } + const directBest = directMatches + .slice() + .sort((a, b) => + b.durationSeconds - a.durationSeconds + || b.sizeBytes - a.sizeBytes + || a.handBrakeTitleId - b.handBrakeTitleId + )[0]; + if (directBest) { + return directBest.handBrakeTitleId; + } + const best = matches.slice().sort((a, b) => + b.durationSeconds - a.durationSeconds + || b.sizeBytes - a.sizeBytes + || a.handBrakeTitleId - b.handBrakeTitleId + )[0]; + return best?.handBrakeTitleId || null; + } + + // Fallback 1: choose closest duration/size if playlist metadata is absent in scan JSON. + if ((expectedDurationSeconds !== null || expectedSizeBytes !== null) && rows.length > 0) { + const scored = rows.map(scoreForExpected).sort(sortByExpectedScore); + if (expectedDurationSeconds !== null) { + const withinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds); + if (withinTolerance.length > 0) { + return withinTolerance[0].row.handBrakeTitleId; + } + } + return scored[0].row.handBrakeTitleId; + } + + // Fallback 2: map MakeMKV title-id to HandBrake title-id if ordering matches. + if (expectedMakemkvTitleId !== null) { + const byPlusOne = rows.find((item) => item.handBrakeTitleId === (expectedMakemkvTitleId + 1)); + if (byPlusOne) { + return byPlusOne.handBrakeTitleId; + } + const byEqual = rows.find((item) => item.handBrakeTitleId === expectedMakemkvTitleId); + if (byEqual) { + return byEqual.handBrakeTitleId; + } + } + + if (rows.length === 1) { + return rows[0].handBrakeTitleId; + } + + return null; +} + +function isHandBrakePlaylistCacheEntryCompatible(entry, playlistIdRaw, options = {}) { + const playlistId = normalizePlaylistId(playlistIdRaw); + if (!playlistId) { + return false; + } + if (!entry || typeof entry !== 'object') { + return false; + } + const handBrakeTitleId = Number(entry?.handBrakeTitleId); + if (!Number.isFinite(handBrakeTitleId) || handBrakeTitleId <= 0) { + return false; + } + const titleInfo = entry?.titleInfo && typeof entry.titleInfo === 'object' ? entry.titleInfo : null; + if (!titleInfo) { + return false; + } + + const allowedPlaylistIds = new Set([ + playlistId, + ...normalizePlaylistAliasIds(options?.playlistAliases, playlistId) + ]); + const cachedPlaylistId = normalizePlaylistId(titleInfo?.playlistId || null); + if (cachedPlaylistId && !allowedPlaylistIds.has(cachedPlaylistId)) { + return false; + } + + const expectedDurationRaw = Number(options?.expectedDurationSeconds); + const expectedDurationSeconds = Number.isFinite(expectedDurationRaw) && expectedDurationRaw > 0 + ? Math.trunc(expectedDurationRaw) + : null; + const cachedDurationRaw = Number(titleInfo?.durationSeconds); + const cachedDurationSeconds = Number.isFinite(cachedDurationRaw) && cachedDurationRaw > 0 + ? Math.trunc(cachedDurationRaw) + : null; + if (expectedDurationSeconds !== null && cachedDurationSeconds !== null) { + // Reject clearly wrong cache mappings (e.g. 30s instead of 6681s movie title). + if (Math.abs(expectedDurationSeconds - cachedDurationSeconds) > 120) { + return false; + } + } + + const expectedSizeRaw = Number(options?.expectedSizeBytes); + const expectedSizeBytes = Number.isFinite(expectedSizeRaw) && expectedSizeRaw > 0 + ? Math.trunc(expectedSizeRaw) + : null; + const cachedSizeRaw = Number(titleInfo?.sizeBytes); + const cachedSizeBytes = Number.isFinite(cachedSizeRaw) && cachedSizeRaw > 0 + ? Math.trunc(cachedSizeRaw) + : null; + if (expectedSizeBytes !== null && cachedSizeBytes !== null) { + const delta = Math.abs(expectedSizeBytes - cachedSizeBytes); + if (delta > (512 * 1024 * 1024)) { + return false; + } + } + + return true; +} + +function normalizeCodecNumber(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return null; + } + return Math.trunc(numeric); +} + +function hasDtsHdMarker(track) { + const text = `${track?.description || ''} ${track?.title || ''} ${track?.format || ''} ${track?.codecName || ''}` + .toLowerCase(); + const codec = normalizeCodecNumber(track?.codec); + return text.includes('dts-hd') || text.includes('dts hd') || codec === 262144; +} + +function isLikelyDtsCoreTrack(track) { + const text = `${track?.description || ''} ${track?.title || ''} ${track?.format || ''} ${track?.codecName || ''}` + .toLowerCase(); + const codec = normalizeCodecNumber(track?.codec); + const looksDts = text.includes('dts') || text.includes('dca'); + const looksHd = text.includes('dts-hd') || text.includes('dts hd') || codec === 262144; + if (!looksDts || looksHd) { + return false; + } + + // HandBrake uses 8192 for DTS core in scan JSON. + if (codec !== null && codec !== 8192) { + return false; + } + return true; +} + +function filterDtsCoreFallbackTracks(audioTracks) { + const tracks = Array.isArray(audioTracks) ? audioTracks : []; + if (tracks.length === 0) { + return []; + } + + const hdLanguages = new Set( + tracks + .filter((track) => hasDtsHdMarker(track)) + .map((track) => String(track?.language || 'und')) + ); + + if (hdLanguages.size === 0) { + return tracks; + } + + return tracks.filter((track) => { + const language = String(track?.language || 'und'); + if (!hdLanguages.has(language)) { + return true; + } + return !isLikelyDtsCoreTrack(track); + }); +} + +function parseHandBrakeTitleList(scanJson) { + const titleList = pickScanTitleList(scanJson); + if (!Array.isArray(titleList) || titleList.length === 0) { + return []; + } + return titleList.map((title, idx) => { + const handBrakeTitleId = normalizeScanTrackId( + title?.Index ?? title?.index ?? title?.Title ?? title?.title, + idx + ); + const durationSeconds = parseHandBrakeDurationSeconds( + title?.Duration ?? title?.duration ?? title?.Length ?? title?.length + ); + const audioTrackCount = Array.isArray(title?.AudioList) ? title.AudioList.length : 0; + const subtitleTrackCount = Array.isArray(title?.SubtitleList) ? title.SubtitleList.length : 0; + const sizeBytes = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0; + const chapterList = Array.isArray(title?.ChapterList) + ? title.ChapterList + : []; + const chapterCountFromList = chapterList.length; + const chapterCountFromField = Number(title?.ChapterCount ?? title?.chapterCount ?? 0); + const chapterCount = chapterCountFromList > 0 + ? chapterCountFromList + : (Number.isFinite(chapterCountFromField) && chapterCountFromField > 0 ? Math.trunc(chapterCountFromField) : 0); + const chapterDurations = chapterList + .map((chapter) => parseHandBrakeDurationSeconds( + chapter?.Duration ?? chapter?.duration ?? chapter?.Length ?? chapter?.length + )) + .filter((value) => Number.isFinite(value) && value > 0); + const chapterAverageSeconds = chapterDurations.length > 0 + ? Number((chapterDurations.reduce((sum, value) => sum + value, 0) / chapterDurations.length).toFixed(2)) + : 0; + return { + handBrakeTitleId, + durationSeconds, + audioTrackCount, + subtitleTrackCount, + sizeBytes, + chapterCount, + chapterAverageSeconds + }; + }); +} + +function normalizeSeriesDetectionConfidence(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'high' || normalized === 'medium' || normalized === 'low') { + return normalized; + } + return 'low'; +} + +function pickHigherSeriesDetectionConfidence(left, right) { + const scores = { + low: 1, + medium: 2, + high: 3 + }; + const leftNormalized = normalizeSeriesDetectionConfidence(left); + const rightNormalized = normalizeSeriesDetectionConfidence(right); + return (scores[rightNormalized] || 0) > (scores[leftNormalized] || 0) + ? rightNormalized + : leftNormalized; +} + +function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options = {}) { + const minEpisodeSeconds = Math.max( + 60, + Math.round(Number(options?.minEpisodeMinutes || 18) * 60) + ); + const maxEpisodeSeconds = Math.max( + minEpisodeSeconds, + Math.round(Number(options?.maxEpisodeMinutes || 75) * 60) + ); + const shortTitleSeconds = Math.max( + 60, + Math.round(Number(options?.shortTitleMinutes || 5) * 60) + ); + const minEpisodeChapterCount = Math.max( + 1, + Math.round(Number(options?.minEpisodeChapterCount || 4)) + ); + const chapterSimilarityRatio = Math.max( + 1.1, + Number(options?.chapterSimilarityRatio || 1.8) + ); + const dominantFeatureDurationRatio = Math.max( + 1.2, + Number(options?.dominantFeatureDurationRatio || 2.2) + ); + const playAllToleranceLowerPct = normalizePlayAllTolerancePercent( + options?.playAllToleranceLowerPct, + 10 + ); + const playAllToleranceUpperPct = normalizePlayAllTolerancePercent( + options?.playAllToleranceUpperPct, + 10 + ); + + const titles = parseHandBrakeTitleList(scanJson); + if (!Array.isArray(titles) || titles.length === 0) { + return null; + } + + const titlesWithDuration = titles + .map((title) => ({ + id: Number(title?.handBrakeTitleId || 0) || null, + durationSeconds: Number(title?.durationSeconds || 0) || 0, + chapterCount: Number(title?.chapterCount || 0) || 0, + chapterAverageSeconds: Number(title?.chapterAverageSeconds || 0) || 0 + })) + .filter((title) => Number.isFinite(title.durationSeconds) && title.durationSeconds > 0); + + if (titlesWithDuration.length === 0) { + return null; + } + + const episodeWindowCandidates = titlesWithDuration.filter((title) => + title.durationSeconds >= minEpisodeSeconds + && title.durationSeconds <= maxEpisodeSeconds + ); + + const areChapterProfilesCompatible = (left, right) => { + const leftCount = Number(left?.chapterCount || 0); + const rightCount = Number(right?.chapterCount || 0); + if (leftCount > 0 && rightCount > 0) { + const ratio = Math.max(leftCount, rightCount) / Math.max(1, Math.min(leftCount, rightCount)); + if (ratio > chapterSimilarityRatio) { + return false; + } + } + const leftAvg = Number(left?.chapterAverageSeconds || 0); + const rightAvg = Number(right?.chapterAverageSeconds || 0); + if (leftAvg > 0 && rightAvg > 0) { + const ratio = Math.max(leftAvg, rightAvg) / Math.max(1, Math.min(leftAvg, rightAvg)); + if (ratio > chapterSimilarityRatio) { + return false; + } + } + return true; + }; + + let bestCluster = []; + for (const anchor of episodeWindowCandidates) { + const toleranceSeconds = Math.max(90, Math.round(anchor.durationSeconds * 0.12)); + const cluster = episodeWindowCandidates.filter((candidate) => + Math.abs(candidate.durationSeconds - anchor.durationSeconds) <= toleranceSeconds + && areChapterProfilesCompatible(anchor, candidate) + ); + if (cluster.length > bestCluster.length) { + bestCluster = cluster; + continue; + } + if (cluster.length === bestCluster.length && cluster.length > 0) { + const clusterMedian = medianPositiveNumber(cluster.map((entry) => entry.durationSeconds)); + const bestMedian = medianPositiveNumber(bestCluster.map((entry) => entry.durationSeconds)); + if (clusterMedian > bestMedian) { + bestCluster = cluster; + } + } + } + + const episodeCandidateCount = bestCluster.length; + const clusterMedianDuration = medianPositiveNumber(bestCluster.map((entry) => entry.durationSeconds)); + const clusterMedianChapterCount = medianPositiveNumber( + bestCluster + .map((entry) => Number(entry?.chapterCount || 0)) + .filter((value) => Number.isFinite(value) && value > 0) + ); + const clusterIds = new Set( + bestCluster + .map((entry) => Number(entry?.id)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + const shortCount = titlesWithDuration.filter((title) => title.durationSeconds <= shortTitleSeconds).length; + const episodeSumSeconds = episodeCandidateCount > 0 && clusterMedianDuration > 0 + ? Math.round(clusterMedianDuration * episodeCandidateCount) + : 0; + const playAllLowerBoundSeconds = episodeSumSeconds > 0 + ? Math.round(episodeSumSeconds * (1 - (playAllToleranceLowerPct / 100))) + : 0; + const playAllUpperBoundSeconds = episodeSumSeconds > 0 + ? Math.round(episodeSumSeconds * (1 + (playAllToleranceUpperPct / 100))) + : 0; + const outsideClusterTitles = titlesWithDuration.filter((title) => !clusterIds.has(Number(title?.id))); + const playAllCandidate = (() => { + if (episodeCandidateCount < 2 || clusterMedianDuration <= 0 || outsideClusterTitles.length === 0) { + return null; + } + return outsideClusterTitles + .filter((title) => + title.durationSeconds >= playAllLowerBoundSeconds + && title.durationSeconds <= playAllUpperBoundSeconds + ) + .sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null; + })(); + const playAllCount = playAllCandidate ? 1 : 0; + const playAllCandidateSeconds = Number(playAllCandidate?.durationSeconds || 0) || 0; + const playAllWithinTolerance = playAllCandidateSeconds > 0; + const longestOutsideClusterSeconds = outsideClusterTitles.length > 0 + ? Math.max(...outsideClusterTitles.map((title) => Number(title?.durationSeconds || 0)).filter((value) => Number.isFinite(value) && value > 0)) + : 0; + const extraCount = titlesWithDuration.filter((title) => + !clusterIds.has(Number(title?.id)) + && title.durationSeconds > shortTitleSeconds + ).length; + let seriesLike = episodeCandidateCount >= 2; + + const longestTitle = titlesWithDuration.reduce((best, current) => ( + !best || current.durationSeconds > best.durationSeconds ? current : best + ), null); + const longestTitleDominates = Boolean( + longestTitle + && clusterMedianDuration > 0 + && longestTitle.durationSeconds >= (clusterMedianDuration * dominantFeatureDurationRatio) + && Number(longestTitle?.chapterCount || 0) >= (minEpisodeChapterCount * 2) + ); + const lowChapterEpisodeCount = bestCluster.filter((entry) => { + const chapterCount = Number(entry?.chapterCount || 0); + if (chapterCount <= 0) { + return longestTitleDominates; + } + return chapterCount < minEpisodeChapterCount; + }).length; + const allEpisodeCandidatesLowChapter = ( + episodeCandidateCount > 0 + && lowChapterEpisodeCount === episodeCandidateCount + ); + if (seriesLike && longestTitleDominates && allEpisodeCandidatesLowChapter) { + seriesLike = false; + } + + let confidence = 'low'; + if (seriesLike) { + if (episodeCandidateCount >= 3 || playAllCount > 0) { + confidence = 'high'; + } else if (titlesWithDuration.length >= 3) { + confidence = 'medium'; + } + } + + const reasons = []; + if (episodeCandidateCount >= 3) { + reasons.push(`${episodeCandidateCount} ähnlich lange Episodenkandidaten im HandBrake-JSON erkannt.`); + } else if (episodeCandidateCount === 2) { + reasons.push('Zwei ähnlich lange Episodenkandidaten im HandBrake-JSON erkannt.'); + } + if (clusterMedianDuration > 0) { + reasons.push(`Typische Episodenlänge ca. ${Math.round(clusterMedianDuration / 60)} Minuten.`); + } + if (playAllCount > 0) { + reasons.push('Ein zusätzlicher langer Play-All-Kandidat wurde erkannt.'); + } else if (episodeCandidateCount >= 2 && episodeSumSeconds > 0 && longestOutsideClusterSeconds > playAllUpperBoundSeconds) { + reasons.push( + `Langer Titel außerhalb Play-All-Toleranz erkannt (>${Math.round(playAllUpperBoundSeconds / 60)} Minuten bei ±${playAllToleranceLowerPct}%/${playAllToleranceUpperPct}%).` + ); + } + if (shortCount > 0) { + reasons.push(`${shortCount} kurze Titel als Extras/Kurzsegmente erkannt.`); + } + if (!seriesLike && longestTitleDominates && allEpisodeCandidatesLowChapter) { + reasons.push( + 'Kurze Episodenkandidaten wurden wegen abweichender Kapitelstruktur als Bonusmaterial gewertet ' + + `(Kapitelzahl Kandidaten < ${minEpisodeChapterCount}, Haupttitel kapitelreich).` + ); + } + + return { + source: 'handbrake_scan_json', + generatedAt: nowIso(), + summary: { + seriesLike, + confidence, + reasons, + titleCount: titles.length, + episodeCandidateCount, + episodeChapterMedian: clusterMedianChapterCount > 0 ? Number(clusterMedianChapterCount.toFixed(2)) : 0, + lowChapterEpisodeCount, + longestTitleChapterCount: Number(longestTitle?.chapterCount || 0) || 0, + playAllCount, + playAllCandidateSeconds, + playAllWithinTolerance, + episodeSumSeconds, + playAllLowerBoundSeconds, + playAllUpperBoundSeconds, + longestOutsideClusterSeconds, + duplicateCount: 0, + extraCount, + typicalEpisodeMinutes: clusterMedianDuration > 0 + ? Number((clusterMedianDuration / 60).toFixed(2)) + : 0 + } + }; +} + +function buildStructuredSeriesAnalysisFromPlaylistAnalysis(playlistAnalysis, options = {}) { + const sourceTitles = Array.isArray(playlistAnalysis?.titles) + ? playlistAnalysis.titles + : []; + if (sourceTitles.length === 0) { + return null; + } + + const parseChapterCountFromText = (value) => { + const text = String(value || '').trim(); + if (!text) { + return 0; + } + const chapterMatch = text.match(/(\d+)\s+chapter\(s\)/i); + if (!chapterMatch) { + return 0; + } + const parsed = Number(chapterMatch[1]); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + return Math.trunc(parsed); + }; + + const normalizeChapterCountForSeriesHeuristics = (title) => { + const directChapterCount = Number(title?.chapterCount ?? title?.chapters ?? 0); + if (Number.isFinite(directChapterCount) && directChapterCount > 0) { + return Math.trunc(directChapterCount); + } + + const fields = title?.fields && typeof title.fields === 'object' + ? title.fields + : null; + const fieldChapterCount = Number(fields?.[8] ?? fields?.[7] ?? 0); + if (Number.isFinite(fieldChapterCount) && fieldChapterCount > 0) { + return Math.trunc(fieldChapterCount); + } + + const chapterInfoText = String( + title?.description + || fields?.[30] + || '' + ).trim(); + return parseChapterCountFromText(chapterInfoText); + }; + + const syntheticScanJson = { + MainFeature: 0, + TitleList: sourceTitles.map((title, index) => ({ + Index: normalizeScanTrackId(title?.titleId, index), + Duration: Number(title?.durationSeconds || 0) || String(title?.durationLabel || '').trim() || 0, + ChapterCount: normalizeChapterCountForSeriesHeuristics(title), + AudioList: Array.isArray(title?.audioTracks) ? title.audioTracks : [], + SubtitleList: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [], + Size: { + Bytes: Number(title?.sizeBytes || 0) || 0 + } + })) + }; + + const structured = buildStructuredSeriesAnalysisFromHandBrakeScanJson(syntheticScanJson, options); + if (!structured?.summary || typeof structured.summary !== 'object') { + return null; + } + + const summary = { + ...structured.summary, + titleCount: Math.max( + Number(structured.summary?.titleCount || 0) || 0, + sourceTitles.length + ) + }; + + return { + source: 'makemkv_analyze_structured', + generatedAt: nowIso(), + summary + }; +} + +function hasDiscDecryptWarningInScan(scanAnalysisLines = null) { + const lines = Array.isArray(scanAnalysisLines) + ? scanAnalysisLines + : (String(scanAnalysisLines || '').trim() + ? String(scanAnalysisLines || '').split(/\r?\n/) + : []); + if (lines.length === 0) { + return false; + } + + return lines.some((line) => + /(aacs|keydbcfg|no valid aacs|cannot decrypt|can't decrypt|encrypted|libdvdcss|\bcss\b|copy protection|bd\+)/i + .test(String(line || '')) + ); +} + +function shouldRunMakeMkvSeriesSecondChance(seriesAnalysis = null, scanJson = null, scanAnalysisLines = null) { + const summary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' + ? seriesAnalysis.summary + : null; + if (Boolean(summary?.seriesLike)) { + return false; + } + + const parsedTitles = parseHandBrakeTitleList(scanJson); + const durations = parsedTitles + .map((title) => Number(title?.durationSeconds || 0)) + .filter((value) => Number.isFinite(value) && value > 0); + const titleCount = Math.max( + Number(summary?.titleCount || 0) || 0, + parsedTitles.length + ); + const maxDuration = durations.length > 0 ? Math.max(...durations) : 0; + const shortOnlyScan = titleCount > 0 && titleCount <= 2 && maxDuration > 0 && maxDuration <= 600; + const hasDecryptWarning = hasDiscDecryptWarningInScan(scanAnalysisLines); + const noEpisodeSignals = (Number(summary?.episodeCandidateCount || 0) || 0) <= 0 + && (Number(summary?.playAllCount || 0) || 0) <= 0; + const noFeatureExtras = (Number(summary?.extraCount || 0) || 0) <= 0; + const lowConfidence = normalizeSeriesDetectionConfidence(summary?.confidence) === 'low'; + return lowConfidence && noEpisodeSignals && (shortOnlyScan || (hasDecryptWarning && noFeatureExtras)); +} + +function mergeSeriesAnalysisWithStructuredFallback(seriesAnalysis = null, structuredSeriesAnalysis = null) { + const baseSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' + ? seriesAnalysis.summary + : null; + const structuredSummary = structuredSeriesAnalysis?.summary && typeof structuredSeriesAnalysis.summary === 'object' + ? structuredSeriesAnalysis.summary + : null; + + if (!baseSummary && !structuredSummary) { + return null; + } + + if (!baseSummary && structuredSummary) { + return { + source: String(structuredSeriesAnalysis?.source || 'handbrake_scan_json').trim() || 'handbrake_scan_json', + generatedAt: structuredSeriesAnalysis?.generatedAt || nowIso(), + summary: { + ...structuredSummary, + confidence: normalizeSeriesDetectionConfidence(structuredSummary?.confidence) + } + }; + } + + if (baseSummary && !structuredSummary) { + return { + source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', + generatedAt: seriesAnalysis?.generatedAt || nowIso(), + summary: { + ...baseSummary, + confidence: normalizeSeriesDetectionConfidence(baseSummary?.confidence) + } + }; + } + + const seriesLike = Boolean(baseSummary?.seriesLike) || Boolean(structuredSummary?.seriesLike); + const confidence = seriesLike + ? pickHigherSeriesDetectionConfidence(baseSummary?.confidence, structuredSummary?.confidence) + : 'low'; + const reasons = Array.from(new Set([ + ...(Array.isArray(baseSummary?.reasons) ? baseSummary.reasons : []), + ...(Array.isArray(structuredSummary?.reasons) ? structuredSummary.reasons : []) + ])) + .map((reason) => String(reason || '').trim()) + .filter(Boolean); + const summary = { + ...baseSummary, + seriesLike, + confidence, + reasons, + titleCount: Math.max( + Number(baseSummary?.titleCount || 0) || 0, + Number(structuredSummary?.titleCount || 0) || 0 + ), + episodeCandidateCount: Math.max( + Number(baseSummary?.episodeCandidateCount || 0) || 0, + Number(structuredSummary?.episodeCandidateCount || 0) || 0 + ), + episodeChapterMedian: Math.max( + Number(baseSummary?.episodeChapterMedian || 0) || 0, + Number(structuredSummary?.episodeChapterMedian || 0) || 0 + ), + lowChapterEpisodeCount: Math.max( + Number(baseSummary?.lowChapterEpisodeCount || 0) || 0, + Number(structuredSummary?.lowChapterEpisodeCount || 0) || 0 + ), + longestTitleChapterCount: Math.max( + Number(baseSummary?.longestTitleChapterCount || 0) || 0, + Number(structuredSummary?.longestTitleChapterCount || 0) || 0 + ), + playAllCount: Math.max( + Number(baseSummary?.playAllCount || 0) || 0, + Number(structuredSummary?.playAllCount || 0) || 0 + ), + playAllCandidateSeconds: Math.max( + Number(baseSummary?.playAllCandidateSeconds || 0) || 0, + Number(structuredSummary?.playAllCandidateSeconds || 0) || 0 + ), + playAllWithinTolerance: Boolean(baseSummary?.playAllWithinTolerance) || Boolean(structuredSummary?.playAllWithinTolerance), + episodeSumSeconds: Math.max( + Number(baseSummary?.episodeSumSeconds || 0) || 0, + Number(structuredSummary?.episodeSumSeconds || 0) || 0 + ), + playAllLowerBoundSeconds: Math.max( + Number(baseSummary?.playAllLowerBoundSeconds || 0) || 0, + Number(structuredSummary?.playAllLowerBoundSeconds || 0) || 0 + ), + playAllUpperBoundSeconds: Math.max( + Number(baseSummary?.playAllUpperBoundSeconds || 0) || 0, + Number(structuredSummary?.playAllUpperBoundSeconds || 0) || 0 + ), + longestOutsideClusterSeconds: Math.max( + Number(baseSummary?.longestOutsideClusterSeconds || 0) || 0, + Number(structuredSummary?.longestOutsideClusterSeconds || 0) || 0 + ), + duplicateCount: Math.max( + Number(baseSummary?.duplicateCount || 0) || 0, + Number(structuredSummary?.duplicateCount || 0) || 0 + ), + extraCount: Math.max( + Number(baseSummary?.extraCount || 0) || 0, + Number(structuredSummary?.extraCount || 0) || 0 + ), + typicalEpisodeMinutes: Math.max( + Number(baseSummary?.typicalEpisodeMinutes || 0) || 0, + Number(structuredSummary?.typicalEpisodeMinutes || 0) || 0 + ) + }; + const structuredSeriesLike = Boolean(structuredSummary?.seriesLike); + const baseSeriesLike = Boolean(baseSummary?.seriesLike); + + return { + source: seriesLike && structuredSeriesLike && !baseSeriesLike + ? 'handbrake_scan_json' + : (String(seriesAnalysis?.source || structuredSeriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text'), + generatedAt: nowIso(), + summary + }; +} + +function normalizeSeriesScanTitleKind(rawKind) { + return String(rawKind || '').trim().toLowerCase(); +} + +function medianPositiveNumber(values = []) { + const normalized = (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); + if (normalized.length === 0) { + return 0; + } + const middle = Math.floor(normalized.length / 2); + if (normalized.length % 2 === 1) { + return normalized[middle]; + } + return (normalized[middle - 1] + normalized[middle]) / 2; +} + +function deriveSeriesMultiEpisodeCandidateIds(seriesTitles = []) { + const normalizedTitles = (Array.isArray(seriesTitles) ? seriesTitles : []) + .map((title) => ({ + index: normalizePositiveInteger(title?.index), + kind: normalizeSeriesScanTitleKind(title?.kind), + durationSeconds: Number(title?.durationSeconds || 0), + chapterCount: normalizePositiveInteger(title?.chapterCount) || 0 + })) + .filter((title) => + Number.isFinite(title?.index) + && title.index > 0 + && Number.isFinite(title?.durationSeconds) + && title.durationSeconds > 0 + ); + if (normalizedTitles.length === 0) { + return new Set(); + } + + const episodeCandidates = normalizedTitles.filter((title) => title.kind === 'episode_candidate'); + if (episodeCandidates.length < 2) { + return new Set(); + } + const baselineEpisodeDuration = medianPositiveNumber( + episodeCandidates.map((title) => title.durationSeconds) + ); + if (!baselineEpisodeDuration) { + return new Set(); + } + const baselineEpisodeDurations = episodeCandidates + .map((title) => title.durationSeconds) + .filter((value) => value <= baselineEpisodeDuration * 1.35); + const baselineDuration = medianPositiveNumber( + baselineEpisodeDurations.length > 0 + ? baselineEpisodeDurations + : episodeCandidates.map((title) => title.durationSeconds) + ) || baselineEpisodeDuration; + if (!baselineDuration) { + return new Set(); + } + + const baselineChapterCount = medianPositiveNumber( + episodeCandidates + .map((title) => title.chapterCount) + .filter((value) => Number.isFinite(value) && value > 0) + ); + + const candidates = normalizedTitles.filter((title) => !( + title.kind === 'episode_candidate' + || title.kind === 'play_all' + || title.kind === 'duplicate' + || title.kind === 'short' + )); + const output = new Set(); + for (const title of candidates) { + const durationRatio = title.durationSeconds / baselineDuration; + const roundedSpan = Math.round(durationRatio); + if (!Number.isFinite(roundedSpan) || roundedSpan < 2 || roundedSpan > 4) { + continue; + } + const minDurationRatio = roundedSpan === 2 ? 1.55 : roundedSpan * 0.72; + const maxDurationRatio = roundedSpan * 1.32; + if (durationRatio < minDurationRatio || durationRatio > maxDurationRatio) { + continue; + } + if (baselineChapterCount > 0 && title.chapterCount > 0) { + const chapterRatio = title.chapterCount / baselineChapterCount; + const minChapterRatio = roundedSpan === 2 + ? 1.2 + : Math.max(1.2, roundedSpan * 0.55); + if (chapterRatio < minChapterRatio) { + continue; + } + } + output.add(Number(title.index)); + } + + return output; +} + +function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = null, options = {}) { + const baseAnalyzeContext = analyzeContext && typeof analyzeContext === 'object' + ? analyzeContext + : {}; + const existingSeriesTitles = Array.isArray(baseAnalyzeContext?.seriesAnalysis?.titles) + ? baseAnalyzeContext.seriesAnalysis.titles + : []; + if (existingSeriesTitles.length > 0) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: 'already_available' + }; + } + + const scanText = Array.isArray(scanLines) + ? scanLines.join('\n') + : String(scanLines || '').trim(); + if (!String(scanText || '').trim()) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: 'scan_missing' + }; + } + + let textSeriesAnalysis = null; + let textSeriesTitles = []; + try { + const analysisResult = dvdSeriesScanService.analyzeHandBrakeScan(scanText, options?.seriesOptions || {}); + textSeriesAnalysis = analysisResult?.analysis || null; + textSeriesTitles = Array.isArray(textSeriesAnalysis?.titles) ? textSeriesAnalysis.titles : []; + } catch (_error) { + textSeriesAnalysis = null; + textSeriesTitles = []; + } + + const scanJson = parseMediainfoJsonOutput(scanText); + const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options?.seriesOptions || {}); + const playlistStructuredSeriesAnalysis = buildStructuredSeriesAnalysisFromPlaylistAnalysis( + baseAnalyzeContext?.playlistAnalysis || null, + options?.seriesOptions || {} + ); + const mergedFromScan = mergeSeriesAnalysisWithStructuredFallback( + textSeriesAnalysis, + structuredSeriesAnalysis + ); + const mergedSeriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( + mergedFromScan, + playlistStructuredSeriesAnalysis + ); + const derivedFromText = textSeriesTitles.length > 0; + const derivedFromStructured = Boolean(structuredSeriesAnalysis?.summary?.seriesLike); + const derivedFromPlaylistStructured = Boolean(playlistStructuredSeriesAnalysis?.summary?.seriesLike); + if (!mergedSeriesAnalysis || (!derivedFromText && !derivedFromStructured && !derivedFromPlaylistStructured)) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: derivedFromText ? 'scan_parse_failed' : 'no_titles' + }; + } + + const nextSeriesAnalysis = derivedFromText + ? { + ...mergedSeriesAnalysis, + titles: textSeriesTitles + } + : mergedSeriesAnalysis; + return { + analyzeContext: { + ...baseAnalyzeContext, + seriesAnalysis: nextSeriesAnalysis + }, + derived: true, + reason: derivedFromText + ? 'scan_classification' + : (derivedFromStructured + ? 'scan_json_structural_classification' + : 'playlist_structural_classification') + }; +} + +function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = null) { + const sourceCandidates = Array.isArray(candidates) ? candidates : []; + const seriesTitles = Array.isArray(analyzeContext?.seriesAnalysis?.titles) + ? analyzeContext.seriesAnalysis.titles + : []; + + if (sourceCandidates.length === 0 || seriesTitles.length === 0) { + return { + candidates: sourceCandidates, + applied: false, + reason: null, + sourceCount: sourceCandidates.length, + resultCount: sourceCandidates.length + }; + } + + const episodeCandidateIds = new Set( + seriesTitles + .filter((title) => normalizeSeriesScanTitleKind(title?.kind) === 'episode_candidate') + .map((title) => Number(title?.index)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + const multiEpisodeCandidateIds = deriveSeriesMultiEpisodeCandidateIds(seriesTitles); + const blockedIds = new Set( + seriesTitles + .filter((title) => ['play_all', 'duplicate', 'short'].includes(normalizeSeriesScanTitleKind(title?.kind))) + .map((title) => Number(title?.index)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + + let filtered = sourceCandidates; + let reason = null; + + if (episodeCandidateIds.size > 0) { + const allowedEpisodeIds = new Set([ + ...Array.from(episodeCandidateIds.values()), + ...Array.from(multiEpisodeCandidateIds.values()) + ]); + const byEpisodeIds = sourceCandidates.filter((item) => allowedEpisodeIds.has(Number(item?.handBrakeTitleId))); + if (byEpisodeIds.length > 0) { + filtered = byEpisodeIds; + reason = multiEpisodeCandidateIds.size > 0 + ? 'episode_candidates_plus_multi_episode' + : 'episode_candidates_only'; + } + } + + if (filtered === sourceCandidates && blockedIds.size > 0) { + const withoutBlocked = sourceCandidates.filter((item) => !blockedIds.has(Number(item?.handBrakeTitleId))); + if (withoutBlocked.length > 0 && withoutBlocked.length < sourceCandidates.length) { + filtered = withoutBlocked; + reason = 'excluded_playall_duplicate_short'; + } + } + + if (filtered === sourceCandidates && sourceCandidates.length >= 3) { + const durations = sourceCandidates + .map((item) => Number(item?.durationSeconds || 0)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); + if (durations.length >= 3) { + const median = durations[Math.floor(durations.length / 2)]; + const sortedDesc = sourceCandidates + .map((item) => ({ + id: Number(item?.handBrakeTitleId), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => Number.isFinite(item.id) && item.id > 0 && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0) + .sort((a, b) => b.durationSeconds - a.durationSeconds); + if (sortedDesc.length >= 2) { + const longest = sortedDesc[0]; + const second = sortedDesc[1]; + const looksLikePlayAll = longest.durationSeconds >= median * 1.8 + && longest.durationSeconds >= second.durationSeconds * 1.4; + if (looksLikePlayAll) { + const withoutLongest = sourceCandidates.filter((item) => Number(item?.handBrakeTitleId) !== longest.id); + if (withoutLongest.length > 0) { + filtered = withoutLongest; + reason = 'heuristic_playall_longest'; + } + } + } + } + } + + if (filtered === sourceCandidates && sourceCandidates.length === 2) { + const sortedDesc = sourceCandidates + .map((item) => ({ + id: Number(item?.handBrakeTitleId), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => Number.isFinite(item.id) && item.id > 0 && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0) + .sort((a, b) => b.durationSeconds - a.durationSeconds); + if (sortedDesc.length === 2) { + const longest = sortedDesc[0]; + const second = sortedDesc[1]; + const looksLikePlayAll = longest.durationSeconds >= second.durationSeconds * 1.6; + if (looksLikePlayAll) { + const withoutLongest = sourceCandidates.filter((item) => Number(item?.handBrakeTitleId) !== longest.id); + if (withoutLongest.length > 0) { + filtered = withoutLongest; + reason = 'heuristic_playall_two_titles'; + } + } + } + } + + if (filtered.length >= 3) { + const durations = filtered + .map((item) => Number(item?.durationSeconds || 0)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); + if (durations.length >= 3) { + const median = durations[Math.floor(durations.length / 2)]; + // Only apply this guard when the disc mostly contains regular-length episodes. + // This prevents accidentally removing genuinely short-format episode discs. + if (median >= (15 * 60)) { + const shortThresholdSeconds = Math.max(5 * 60, Math.round(median * 0.5)); + const withoutShort = filtered.filter((item) => Number(item?.durationSeconds || 0) >= shortThresholdSeconds); + if (withoutShort.length >= 2 && withoutShort.length < filtered.length) { + filtered = withoutShort; + reason = reason + ? `${reason}+heuristic_excluded_short_titles` + : 'heuristic_excluded_short_titles'; + } + } + } + } + + return { + candidates: filtered, + applied: filtered !== sourceCandidates, + reason, + sourceCount: sourceCandidates.length, + resultCount: filtered.length + }; +} + +function filterSeriesBackupReviewTitles(reviewPlan) { + const plan = reviewPlan && typeof reviewPlan === 'object' + ? reviewPlan + : null; + if (!plan) { + return { + plan: reviewPlan, + applied: false, + reason: 'invalid_plan', + sourceCount: 0, + resultCount: 0 + }; + } + + const titles = Array.isArray(plan.titles) ? plan.titles : []; + if (titles.length < 3) { + return { + plan, + applied: false, + reason: 'insufficient_titles', + sourceCount: titles.length, + resultCount: titles.length + }; + } + + const rows = titles + .map((title, index) => ({ + id: normalizeReviewTitleId(title?.id), + durationSeconds: Number(title?.durationSeconds || 0), + index + })) + .filter((row) => row.id && Number.isFinite(row.durationSeconds) && row.durationSeconds > 0); + + if (rows.length < 3) { + return { + plan, + applied: false, + reason: 'insufficient_duration_data', + sourceCount: titles.length, + resultCount: titles.length + }; + } + + const longEpisodeDurations = rows + .map((row) => row.durationSeconds) + .filter((value) => Number.isFinite(value) && value >= (20 * 60)); + const topDurationSampleSize = Math.max( + 3, + Math.min(rows.length, Math.ceil(rows.length * 0.35)) + ); + const topDurations = rows + .map((row) => row.durationSeconds) + .sort((a, b) => b - a) + .slice(0, topDurationSampleSize); + const baselineDurations = (longEpisodeDurations.length >= 2 ? longEpisodeDurations : topDurations) + .slice() + .sort((a, b) => a - b); + const medianDurationSeconds = baselineDurations[Math.floor(baselineDurations.length / 2)]; + if (!Number.isFinite(medianDurationSeconds) || medianDurationSeconds < (10 * 60)) { + return { + plan, + applied: false, + reason: 'median_too_short_for_filter', + sourceCount: titles.length, + resultCount: titles.length + }; + } + + const shortThresholdSeconds = Math.max(5 * 60, Math.round(medianDurationSeconds * 0.5)); + const keptRows = rows.filter((row) => row.durationSeconds >= shortThresholdSeconds); + if (keptRows.length < 2 || keptRows.length >= rows.length) { + return { + plan, + applied: false, + reason: 'no_relevant_short_titles', + sourceCount: titles.length, + resultCount: titles.length + }; + } + + const keepIdSet = new Set(keptRows.map((row) => Number(row.id))); + const filteredTitles = titles + .filter((title) => keepIdSet.has(Number(title?.id))) + .map((title, index) => ({ + ...title, + selectedByMinLength: true, + eligibleForEncode: true, + selectedForEncode: true, + encodeInput: index === 0 + })); + + const selectedTitleIds = normalizeReviewTitleIdList(filteredTitles.map((title) => title?.id)); + const encodeInputTitleId = selectedTitleIds[0] || null; + const selectedEncodeTitle = filteredTitles.find((title) => Number(title?.id) === Number(encodeInputTitleId)) || filteredTitles[0] || null; + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const filteredAssignments = {}; + for (const titleId of selectedTitleIds) { + const assignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; + if (!assignment || typeof assignment !== 'object') { + continue; + } + filteredAssignments[String(titleId)] = assignment; + } + const notes = Array.isArray(plan.notes) ? plan.notes : []; + const filterNotePrefix = 'Serien-Kurztitel-Filter aktiv:'; + const nextNotes = notes.some((entry) => String(entry || '').startsWith(filterNotePrefix)) + ? notes + : [ + ...notes, + `${filterNotePrefix} ${filteredTitles.length}/${titles.length} Titel behalten (Schwelle ${formatDurationClock(shortThresholdSeconds)}).` + ]; + + return { + plan: { + ...plan, + titles: filteredTitles, + selectedTitleIds, + encodeInputTitleId, + encodeInputPath: String( + selectedEncodeTitle?.filePath + || selectedEncodeTitle?.path + || plan.encodeInputPath + || '' + ).trim() || null, + titleSelectionRequired: false, + episodeAssignments: filteredAssignments, + notes: nextNotes + }, + applied: true, + reason: 'filtered_short_titles_by_median', + sourceCount: titles.length, + resultCount: filteredTitles.length, + shortThresholdSeconds + }; +} + +function resolveSeriesSinglePlayAllFallback(seriesCandidates = [], allTitles = [], selectedMetadata = null, options = {}) { + const episodeCountFromMetadata = normalizePositiveInteger( + selectedMetadata?.episodeCount + ?? (Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : null) + ) || 0; + if (episodeCountFromMetadata < 2) { + return { + applied: false, + reason: 'insufficient_episode_metadata' + }; + } + + const normalizedCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : []) + .map((item) => { + const handBrakeTitleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); + const durationSeconds = Number(item?.durationSeconds || 0); + return { + raw: item, + handBrakeTitleId, + durationSeconds + }; + }) + .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + if (normalizedCandidates.length < 2) { + return { + applied: false, + reason: 'insufficient_candidates' + }; + } + + const sortedByDuration = normalizedCandidates + .slice() + .sort((a, b) => b.durationSeconds - a.durationSeconds || a.handBrakeTitleId - b.handBrakeTitleId); + const longest = sortedByDuration[0]; + const second = sortedByDuration[1]; + if (!longest || !second) { + return { + applied: false, + reason: 'missing_duration_anchor' + }; + } + + const minLongestSeconds = Math.max(20 * 60, Math.round(Number(options?.minLongestSeconds) || (45 * 60))); + if (longest.durationSeconds < minLongestSeconds) { + return { + applied: false, + reason: 'longest_title_too_short' + }; + } + + const requiredLongestRatio = Math.max(1.8, Number(options?.minLongestVsSecondRatio) || 3.0); + if (longest.durationSeconds < second.durationSeconds * requiredLongestRatio) { + return { + applied: false, + reason: 'longest_not_dominant' + }; + } + + const expectedEpisodeSeconds = longest.durationSeconds / episodeCountFromMetadata; + if (!Number.isFinite(expectedEpisodeSeconds) || expectedEpisodeSeconds < (15 * 60) || expectedEpisodeSeconds > (95 * 60)) { + return { + applied: false, + reason: 'episode_runtime_implausible', + episodeCountFromMetadata, + expectedEpisodeSeconds + }; + } + + const shortThresholdSeconds = Math.max(10 * 60, Math.round(expectedEpisodeSeconds * 0.35)); + const restRows = sortedByDuration.slice(1); + if (restRows.length === 0) { + return { + applied: false, + reason: 'no_short_candidates' + }; + } + const hasOnlyShortRest = restRows.every((row) => row.durationSeconds <= shortThresholdSeconds); + if (!hasOnlyShortRest) { + return { + applied: false, + reason: 'rest_contains_non_short_title', + shortThresholdSeconds + }; + } + + const restDurationSum = restRows.reduce((sum, row) => sum + Number(row.durationSeconds || 0), 0); + if (restDurationSum >= (longest.durationSeconds * 0.55)) { + return { + applied: false, + reason: 'short_titles_sum_too_large', + restDurationSum + }; + } + + const allRows = (Array.isArray(allTitles) ? allTitles : []) + .map((item) => ({ + handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + const allRowsById = new Map(allRows.map((item) => [Number(item.handBrakeTitleId), item])); + + return { + applied: true, + reason: 'single_playall_plus_short_extras', + episodeCountFromMetadata, + expectedEpisodeSeconds, + shortThresholdSeconds, + keptTitleId: longest.handBrakeTitleId, + keptDurationSeconds: longest.durationSeconds, + removedTitleIds: restRows.map((row) => row.handBrakeTitleId), + removedCount: restRows.length, + removedDurationSumSeconds: restDurationSum, + longestVsSecondRatio: Number((longest.durationSeconds / Math.max(1, second.durationSeconds)).toFixed(2)), + longestChapterCount: normalizePositiveInteger( + allRowsById.get(Number(longest.handBrakeTitleId))?.chapterCount + ?? longest?.raw?.chapterCount + ?? null + ) || null, + candidates: [longest.raw] + }; +} + +function resolveSeriesPlayAllDoubleEpisodeDecision(seriesCandidates = [], allTitles = [], options = {}) { + const normalizedSeriesCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : []) + .map((item) => ({ + handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + if (normalizedSeriesCandidates.length < 2) { + return { + required: false, + reason: 'insufficient_series_candidates' + }; + } + + const allRows = (Array.isArray(allTitles) ? allTitles : []) + .map((item) => ({ + handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + if (allRows.length === 0) { + return { + required: false, + reason: 'all_titles_missing' + }; + } + + const selectedIdSet = new Set(normalizedSeriesCandidates.map((item) => Number(item.handBrakeTitleId))); + const nonSelectedRows = allRows + .filter((item) => !selectedIdSet.has(Number(item.handBrakeTitleId))) + .sort((a, b) => b.durationSeconds - a.durationSeconds || a.handBrakeTitleId - b.handBrakeTitleId); + if (nonSelectedRows.length === 0) { + return { + required: false, + reason: 'no_non_selected_title' + }; + } + + const longestCandidate = nonSelectedRows[0]; + const selectedDurationSumSeconds = normalizedSeriesCandidates + .reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0); + const longestSelectedDurationSeconds = normalizedSeriesCandidates + .reduce((maxValue, item) => Math.max(maxValue, Number(item.durationSeconds || 0)), 0); + if (!selectedDurationSumSeconds || !longestSelectedDurationSeconds) { + return { + required: false, + reason: 'duration_data_missing' + }; + } + + const minToleranceSeconds = Number(options?.minToleranceSeconds); + const ratioTolerance = Number(options?.ratioTolerance); + const minDominanceRatio = Number(options?.minDominanceRatio); + const maxAutoEpisodeCandidates = Number(options?.maxAutoEpisodeCandidates); + const toleranceSeconds = Math.max( + Number.isFinite(minToleranceSeconds) && minToleranceSeconds > 0 ? Math.trunc(minToleranceSeconds) : 120, + Math.round( + selectedDurationSumSeconds * ( + Number.isFinite(ratioTolerance) && ratioTolerance > 0 + ? ratioTolerance + : 0.08 + ) + ) + ); + const durationDeltaSeconds = Math.abs(longestCandidate.durationSeconds - selectedDurationSumSeconds); + const withinTolerance = durationDeltaSeconds <= toleranceSeconds; + const longestVsEpisodeRatio = longestCandidate.durationSeconds / Math.max(1, longestSelectedDurationSeconds); + const dominanceThreshold = Number.isFinite(minDominanceRatio) && minDominanceRatio > 0 + ? minDominanceRatio + : 1.4; + const hasDominantLongestCandidate = longestVsEpisodeRatio >= dominanceThreshold; + const maxAutoCandidates = Number.isFinite(maxAutoEpisodeCandidates) && maxAutoEpisodeCandidates > 0 + ? Math.trunc(maxAutoEpisodeCandidates) + : 2; + const isBoundaryEpisodeCount = normalizedSeriesCandidates.length <= maxAutoCandidates; + const required = withinTolerance && hasDominantLongestCandidate && isBoundaryEpisodeCount; + + return { + required, + reason: required ? 'playall_or_double_episode_boundary' : 'not_boundary_case', + defaultSelectedTitleIds: normalizeReviewTitleIdList(normalizedSeriesCandidates.map((item) => item.handBrakeTitleId)), + longestCandidateTitleId: longestCandidate.handBrakeTitleId, + selectedDurationSumSeconds, + longestCandidateDurationSeconds: longestCandidate.durationSeconds, + durationDeltaSeconds, + toleranceSeconds, + longestVsEpisodeRatio: Number(longestVsEpisodeRatio.toFixed(3)) + }; +} + +function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { + const titleList = pickScanTitleList(scanJson); + if (!Array.isArray(titleList) || titleList.length === 0) { + return null; + } + + const preferredPlaylistId = normalizePlaylistId(options?.playlistId || null); + const rawPreferredHandBrakeTitleId = Number(options?.handBrakeTitleId); + const preferredHandBrakeTitleId = Number.isFinite(rawPreferredHandBrakeTitleId) && rawPreferredHandBrakeTitleId > 0 + ? Math.trunc(rawPreferredHandBrakeTitleId) + : null; + const strictHandBrakeTitleId = Boolean(options?.strictHandBrakeTitleId && preferredHandBrakeTitleId); + const makeMkvSubtitleTracks = Array.isArray(options?.makeMkvSubtitleTracks) + ? options.makeMkvSubtitleTracks + : []; + + const parsedTitles = titleList.map((title, idx) => { + const handBrakeTitleId = normalizeScanTrackId( + title?.Index ?? title?.index ?? title?.Title ?? title?.title, + idx + ); + const playlistId = normalizePlaylistId( + title?.Playlist + || title?.playlist + || title?.PlaylistName + || title?.playlistName + || null + ); + const durationSeconds = parseHandBrakeDurationSeconds( + title?.Duration ?? title?.duration ?? title?.Length ?? title?.length + ); + const sizeBytes = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0; + const rawFileName = String( + title?.Name + || title?.TitleName + || title?.File + || title?.SourceName + || '' + ).trim(); + const fileName = rawFileName || `Title #${handBrakeTitleId}`; + + const audioTracksRaw = (Array.isArray(title?.AudioList) ? title.AudioList : []) + .map((track, trackIndex) => { + const sourceTrackId = normalizeScanTrackId( + // Prefer source numbering from HandBrake JSON so UI/CLI IDs stay stable + // (e.g. audio 2..10, subtitle 11..21 on some Blu-rays). + track?.TrackNumber + ?? track?.Track + ?? track?.id + ?? track?.ID + ?? track?.Index, + trackIndex + ); + const languageCode = normalizeTrackLanguage( + track?.LanguageCode + || track?.ISO639_2 + || track?.Language + || track?.language + || 'und' + ); + const languageLabel = String( + track?.Language + || track?.LanguageCode + || track?.language + || languageCode + ).trim() || languageCode; + + return { + id: sourceTrackId, + sourceTrackId, + language: languageCode, + languageLabel, + title: track?.Name || track?.Description || null, + description: track?.Description || null, + codec: track?.Codec ?? null, + codecName: track?.CodecName || null, + format: track?.Codec || track?.CodecName || track?.CodecParam || null, + channels: track?.ChannelLayoutName || track?.ChannelLayout || track?.Channels || null + }; + }) + .filter((track) => Number.isFinite(Number(track?.sourceTrackId)) && Number(track.sourceTrackId) > 0); + const audioTracks = filterDtsCoreFallbackTracks(audioTracksRaw); + + const subtitleTracksRaw = (Array.isArray(title?.SubtitleList) ? title.SubtitleList : []) + .map((track, trackIndex) => { + const sourceTrackId = normalizeScanTrackId( + track?.TrackNumber + ?? track?.Track + ?? track?.id + ?? track?.ID + ?? track?.Index, + trackIndex + ); + const languageCode = normalizeTrackLanguage( + track?.LanguageCode + || track?.ISO639_2 + || track?.Language + || track?.language + || 'und' + ); + const languageLabel = String( + track?.Language + || track?.LanguageCode + || track?.language + || languageCode + ).trim() || languageCode; + + return { + id: sourceTrackId, + sourceTrackId, + language: languageCode, + languageLabel, + title: track?.Name || track?.Description || null, + format: track?.SourceName || track?.Format || track?.Codec || null, + channels: null, + forcedFlag: parseSubtitleForcedFlag(track), + sdhFlag: parseSubtitleSdhFlag(track), + defaultFlag: parseSubtitleDefaultFlag(track), + eventCount: parseSubtitleEventCount(track), + streamSizeBytes: parseSubtitleStreamSizeBytes(track) + }; + }) + .filter((track) => Number.isFinite(Number(track?.sourceTrackId)) && Number(track.sourceTrackId) > 0); + const subtitleTracks = annotateSubtitleForcedAvailability(subtitleTracksRaw, makeMkvSubtitleTracks); + + return { + handBrakeTitleId, + playlistId, + durationSeconds, + sizeBytes, + fileName, + audioTracks, + subtitleTracks + }; + }); + + let selected = null; + if (preferredHandBrakeTitleId) { + selected = parsedTitles.find((title) => title.handBrakeTitleId === preferredHandBrakeTitleId) || null; + } + if (!selected && strictHandBrakeTitleId) { + return null; + } + if (!selected && preferredPlaylistId) { + const playlistMatches = parsedTitles + .filter((title) => normalizePlaylistId(title?.playlistId) === preferredPlaylistId) + .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId); + selected = playlistMatches[0] || null; + } + // Use HandBrake's own MainFeature designation before falling back to heuristics. + // This is exactly the "1 valid title" HandBrake reports in text output. + if (!selected) { + const mainFeatureId = pickScanMainFeatureTitleId(scanJson); + if (mainFeatureId) { + selected = parsedTitles.find((title) => title.handBrakeTitleId === mainFeatureId) || null; + } + } + if (!selected) { + selected = parsedTitles + .slice() + .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId)[0] || null; + } + if (!selected) { + return null; + } + + return { + source: 'handbrake_scan', + titleId: selected.handBrakeTitleId, + handBrakeTitleId: selected.handBrakeTitleId, + fileName: selected.fileName, + durationSeconds: selected.durationSeconds, + sizeBytes: selected.sizeBytes, + playlistId: selected.playlistId || preferredPlaylistId || null, + audioTracks: selected.audioTracks, + subtitleTracks: selected.subtitleTracks + }; +} + +function buildSelectedHandBrakeReviewTitles(scanJson, selectedHandBrakeTitleIds = [], options = {}) { + const normalizedTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds) + .sort((a, b) => Number(a) - Number(b)); + if (normalizedTitleIds.length === 0) { + return []; + } + + const encodeInputPath = String(options?.encodeInputPath || '').trim() || null; + const selectedSet = new Set(normalizedTitleIds.map((id) => Number(id))); + const resolvedTitleIds = []; + const titles = []; + + for (const handBrakeTitleId of normalizedTitleIds) { + const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { + handBrakeTitleId, + strictHandBrakeTitleId: true + }); + if (!titleInfo) { + continue; + } + const normalizedTitleId = normalizeReviewTitleId(titleInfo?.handBrakeTitleId); + if (!normalizedTitleId) { + continue; + } + resolvedTitleIds.push(normalizedTitleId); + const selectedForEncode = selectedSet.has(normalizedTitleId); + titles.push({ + id: normalizedTitleId, + filePath: encodeInputPath || `disc-track-scan://title-${normalizedTitleId}`, + fileName: titleInfo?.fileName || `Title #${normalizedTitleId}`, + makemkvTitleId: null, + sizeBytes: Number(titleInfo?.sizeBytes || 0) || 0, + durationSeconds: Number(titleInfo?.durationSeconds || 0) || 0, + durationMinutes: Number(((Number(titleInfo?.durationSeconds || 0) || 0) / 60).toFixed(2)), + selectedByMinLength: true, + eligibleForEncode: true, + selectedForEncode, + encodeInput: selectedForEncode, + playlistId: titleInfo?.playlistId || null, + playlistFile: titleInfo?.playlistId ? `${titleInfo.playlistId}.mpls` : null, + playlistRecommended: false, + playlistEvaluationLabel: null, + playlistSegmentCommand: null, + playlistSegmentFiles: [], + audioTracks: (Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []).map((track) => ({ + ...track, + id: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.id || null, + sourceTrackId: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.sourceTrackId || track?.id || null, + selectedByRule: true, + selectedForEncode, + encodePreviewActions: [], + encodePreviewSummary: 'Übernehmen', + encodeActions: [], + encodeActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' + })), + subtitleTracks: (Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []).map((track) => ({ + ...track, + id: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.id || null, + sourceTrackId: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.sourceTrackId || track?.id || null, + selectedByRule: true, + selectedForEncode, + subtitlePreviewSummary: 'Übernehmen', + subtitlePreviewFlags: [], + subtitlePreviewBurnIn: false, + subtitlePreviewForced: false, + subtitlePreviewForcedOnly: false, + subtitlePreviewDefaultTrack: false, + burnIn: false, + forced: false, + forcedOnly: false, + defaultTrack: false, + flags: [], + subtitleActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' + })) + }); + } + + return titles; +} + +function collectAlternativeFeatureReviewCandidates({ + playlistCandidates = [], + handBrakePlaylistScan = null, + playlistAnalysis = null, + selectedPlaylistId = null, + selectedHandBrakeTitleId = null, + selectedTitleInfo = null, + makeMkvSubtitleTracks = [] +}) { + const normalizedSelectedPlaylistId = normalizePlaylistId(selectedPlaylistId); + const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); + const normalizedCache = normalizeHandBrakePlaylistScanCache(handBrakePlaylistScan); + const baselineDurationSeconds = Number(selectedTitleInfo?.durationSeconds || 0) || 0; + const baselineSizeBytes = Number(selectedTitleInfo?.sizeBytes || 0) || 0; + const candidateRows = Array.isArray(playlistCandidates) ? playlistCandidates : []; + const candidateMap = new Map(); + + for (const row of candidateRows) { + const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null); + if (!playlistId || candidateMap.has(playlistId)) { + continue; + } + candidateMap.set(playlistId, row); + } + + const resolved = []; + const seenKeys = new Set(); + + const pushCandidate = (playlistIdRaw, titleInfoRaw, sourceRow = null) => { + const playlistId = normalizePlaylistId(playlistIdRaw || titleInfoRaw?.playlistId || null); + const titleInfo = enrichTitleInfoWithForcedSubtitleAvailability( + titleInfoRaw, + makeMkvSubtitleTracks + ); + const handBrakeTitleId = normalizeReviewTitleId( + titleInfo?.handBrakeTitleId + || sourceRow?.handBrakeTitleId + || null + ); + if (!playlistId || !titleInfo || !handBrakeTitleId) { + return; + } + const durationSeconds = Number(titleInfo?.durationSeconds || sourceRow?.durationSeconds || 0) || 0; + const sizeBytes = Number(titleInfo?.sizeBytes || sourceRow?.sizeBytes || 0) || 0; + const isSelectedDefault = ( + (normalizedSelectedPlaylistId && playlistId === normalizedSelectedPlaylistId) + || (normalizedSelectedHandBrakeTitleId && handBrakeTitleId === normalizedSelectedHandBrakeTitleId) + ); + if (!isSelectedDefault && durationSeconds < baselineDurationSeconds) { + return; + } + const dedupeKey = `${playlistId}:${handBrakeTitleId}`; + if (seenKeys.has(dedupeKey)) { + return; + } + seenKeys.add(dedupeKey); + resolved.push({ + playlistId, + handBrakeTitleId, + titleInfo, + sourceRow, + durationSeconds, + sizeBytes, + playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistId), + isSelectedDefault + }); + }; + + if (normalizedSelectedPlaylistId && selectedTitleInfo) { + pushCandidate( + normalizedSelectedPlaylistId, + selectedTitleInfo, + candidateMap.get(normalizedSelectedPlaylistId) || null + ); + } + + if (normalizedCache?.byPlaylist && typeof normalizedCache.byPlaylist === 'object') { + for (const [playlistId, cacheEntry] of Object.entries(normalizedCache.byPlaylist)) { + pushCandidate( + playlistId, + cacheEntry?.titleInfo || null, + candidateMap.get(playlistId) || null + ); + } + } + + for (const row of candidateRows) { + const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null); + if (!playlistId) { + continue; + } + const cacheEntry = normalizedCache?.byPlaylist?.[playlistId] || null; + pushCandidate( + playlistId, + cacheEntry?.titleInfo || null, + row + ); + } + + const byPriority = resolved + .slice() + .sort((left, right) => { + if (left.isSelectedDefault !== right.isSelectedDefault) { + return left.isSelectedDefault ? -1 : 1; + } + return left.durationSeconds - right.durationSeconds + || left.sizeBytes - right.sizeBytes + || String(left.playlistId || '').localeCompare(String(right.playlistId || '')) + || left.handBrakeTitleId - right.handBrakeTitleId; + }); + + if (byPriority.length === 0 && normalizedSelectedPlaylistId && selectedTitleInfo) { + return [{ + playlistId: normalizedSelectedPlaylistId, + handBrakeTitleId: normalizedSelectedHandBrakeTitleId || null, + titleInfo: enrichTitleInfoWithForcedSubtitleAvailability(selectedTitleInfo, makeMkvSubtitleTracks), + sourceRow: candidateMap.get(normalizedSelectedPlaylistId) || null, + durationSeconds: baselineDurationSeconds, + sizeBytes: baselineSizeBytes, + playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, normalizedSelectedPlaylistId), + isSelectedDefault: true + }]; + } + + return byPriority; +} + +function pickTitleIdForTrackReview(playlistAnalysis, selectedTitleId = null) { + const explicit = normalizeNonNegativeInteger(selectedTitleId); + if (explicit !== null) { + return explicit; + } + + const recommendationTitleId = Number(playlistAnalysis?.recommendation?.titleId); + if (Number.isFinite(recommendationTitleId) && recommendationTitleId >= 0) { + return Math.trunc(recommendationTitleId); + } + + const candidates = Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []; + if (candidates.length > 0) { + const candidatesWithPlaylist = candidates.filter((item) => normalizePlaylistId(item?.playlistId)); + const sortPool = candidatesWithPlaylist.length > 0 ? candidatesWithPlaylist : candidates; + const sortedCandidates = [...sortPool].sort((a, b) => + Number(b?.durationSeconds || 0) - Number(a?.durationSeconds || 0) + || Number(b?.sizeBytes || 0) - Number(a?.sizeBytes || 0) + || Number(a?.titleId || 0) - Number(b?.titleId || 0) + ); + const candidateTitleId = Number(sortedCandidates[0]?.titleId); + if (Number.isFinite(candidateTitleId) && candidateTitleId >= 0) { + return Math.trunc(candidateTitleId); + } + } + + const titles = Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []; + if (titles.length > 0) { + const sortedTitles = [...titles].sort((a, b) => + Number(b?.durationSeconds || 0) - Number(a?.durationSeconds || 0) + || Number(b?.sizeBytes || 0) - Number(a?.sizeBytes || 0) + || Number(a?.titleId || 0) - Number(b?.titleId || 0) + ); + const titleId = Number(sortedTitles[0]?.titleId); + if (Number.isFinite(titleId) && titleId >= 0) { + return Math.trunc(titleId); + } + } + + return null; +} + +function isCandidateTitleId(playlistAnalysis, titleId) { + const normalizedTitleId = normalizeNonNegativeInteger(titleId); + if (normalizedTitleId === null) { + return false; + } + const candidates = Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []; + return candidates.some((item) => Number(item?.titleId) === normalizedTitleId); +} + +function buildDiscScanReview({ + scanJson, + settings, + playlistAnalysis = null, + selectedPlaylistId = null, + selectedMakemkvTitleId = null, + mediaProfile = null, + sourceArg = null, + mode = 'pre_rip', + preRip = true, + encodeInputPath = null, + disableMinLengthFilter = false +}) { + const minLengthMinutes = disableMinLengthFilter + ? 0 + : Number(settings?.makemkv_min_length_minutes || 0); + const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); + const selectedPlaylist = normalizePlaylistId(selectedPlaylistId); + const selectedMakemkvId = Number(selectedMakemkvTitleId); + + const titleList = pickScanTitleList(scanJson); + const parsedTitles = titleList.map((title, idx) => { + const reviewTitleId = normalizeScanTrackId( + title?.Index ?? title?.index ?? title?.Title ?? title?.title, + idx + ); + const durationSeconds = parseHandBrakeDurationSeconds( + title?.Duration ?? title?.duration ?? title?.Length ?? title?.length + ); + const durationMinutes = Number((durationSeconds / 60).toFixed(2)); + const rawPlaylist = title?.Playlist + || title?.playlist + || title?.PlaylistName + || title?.playlistName + || null; + const playlistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, rawPlaylist); + const mappedMakemkvTitle = Array.isArray(playlistAnalysis?.titles) + ? (playlistAnalysis.titles.find((item) => + normalizePlaylistId(item?.playlistId) === normalizePlaylistId(playlistInfo.playlistId) + ) || null) + : null; + const makemkvTitleId = Number.isFinite(Number(mappedMakemkvTitle?.titleId)) + ? Math.trunc(Number(mappedMakemkvTitle.titleId)) + : null; + + const audioList = Array.isArray(title?.AudioList) ? title.AudioList : []; + const subtitleList = Array.isArray(title?.SubtitleList) ? title.SubtitleList : []; + + const audioTracksRaw = audioList.map((item, trackIndex) => { + const trackId = normalizeScanTrackId(item?.TrackNumber ?? item?.Track ?? item?.id, trackIndex); + const languageLabel = String(item?.Language || item?.LanguageCode || item?.language || 'und'); + const format = item?.Codec || item?.CodecName || item?.CodecParam || item?.Name || null; + return { + id: trackId, + sourceTrackId: trackId, + language: normalizeTrackLanguage(item?.LanguageCode || item?.ISO639_2 || languageLabel), + languageLabel, + title: item?.Name || item?.Description || null, + description: item?.Description || null, + codec: item?.Codec ?? null, + codecName: item?.CodecName || null, + format, + channels: item?.ChannelLayoutName || item?.ChannelLayout || item?.Channels || null, + selectedByRule: true, + encodePreviewActions: [], + encodePreviewSummary: 'Übernehmen' + }; + }); + const audioTracks = filterDtsCoreFallbackTracks(audioTracksRaw); + + const subtitleTracksRaw = subtitleList.map((item, trackIndex) => { + const trackId = normalizeScanTrackId(item?.TrackNumber ?? item?.Track ?? item?.id, trackIndex); + const languageLabel = String(item?.Language || item?.LanguageCode || item?.language || 'und'); + return { + id: trackId, + sourceTrackId: trackId, + language: normalizeTrackLanguage(item?.LanguageCode || item?.ISO639_2 || languageLabel), + languageLabel, + title: item?.Name || item?.Description || null, + format: item?.SourceName || item?.Format || null, + forcedFlag: parseSubtitleForcedFlag(item), + sdhFlag: parseSubtitleSdhFlag(item), + defaultFlag: parseSubtitleDefaultFlag(item), + eventCount: parseSubtitleEventCount(item), + streamSizeBytes: parseSubtitleStreamSizeBytes(item), + selectedByRule: true, + subtitlePreviewSummary: 'Übernehmen', + subtitlePreviewFlags: [], + subtitlePreviewBurnIn: false, + subtitlePreviewForced: false, + subtitlePreviewForcedOnly: false, + subtitlePreviewDefaultTrack: false + }; + }); + const subtitleTracks = annotateSubtitleForcedAvailability( + subtitleTracksRaw, + Array.isArray(mappedMakemkvTitle?.subtitleTracks) ? mappedMakemkvTitle.subtitleTracks : [] + ); + + return { + id: reviewTitleId, + filePath: encodeInputPath || `disc-track-scan://title-${reviewTitleId}`, + fileName: `Disc Title ${reviewTitleId}`, + makemkvTitleId, + sizeBytes: Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0, + durationSeconds, + durationMinutes, + selectedByMinLength: durationSeconds >= minLengthSeconds, + playlistMatch: playlistInfo, + audioTracks, + subtitleTracks + }; + }); + + const encodeCandidates = parsedTitles.filter((item) => item.selectedByMinLength); + const selectedPlaylistCandidate = selectedPlaylist + ? encodeCandidates.filter((item) => normalizePlaylistId(item?.playlistMatch?.playlistId) === selectedPlaylist) + : []; + const selectedMakemkvCandidate = Number.isFinite(selectedMakemkvId) && selectedMakemkvId >= 0 + ? encodeCandidates.find((item) => Number(item?.makemkvTitleId) === Math.trunc(selectedMakemkvId)) || null + : null; + const preferredByIndex = Number.isFinite(selectedMakemkvId) && selectedMakemkvId >= 0 + ? encodeCandidates.find((item) => Number(item?.id) === Math.trunc(selectedMakemkvId)) || null + : null; + + let encodeInputTitle = null; + if (selectedPlaylistCandidate.length > 0) { + encodeInputTitle = selectedPlaylistCandidate.reduce((best, current) => ( + !best || current.durationSeconds > best.durationSeconds ? current : best + ), null); + } else if (selectedMakemkvCandidate) { + encodeInputTitle = selectedMakemkvCandidate; + } else if (preferredByIndex) { + encodeInputTitle = preferredByIndex; + } else { + encodeInputTitle = encodeCandidates.reduce((best, current) => ( + !best || current.durationSeconds > best.durationSeconds ? current : best + ), null); + } + + const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired && !selectedPlaylist); + const normalizedTitles = parsedTitles.map((title) => { + const isEncodeInput = Boolean(encodeInputTitle && Number(encodeInputTitle.id) === Number(title.id)); + return { + ...title, + selectedForEncode: isEncodeInput, + encodeInput: isEncodeInput, + eligibleForEncode: title.selectedByMinLength, + playlistId: title.playlistMatch?.playlistId || null, + playlistFile: title.playlistMatch?.playlistFile || null, + playlistAliases: normalizePlaylistAliasIds(title.playlistMatch?.playlistAliases, title.playlistMatch?.playlistId), + playlistRecommended: Boolean(title.playlistMatch?.recommended), + playlistEvaluationLabel: title.playlistMatch?.evaluationLabel || null, + playlistSegmentCommand: title.playlistMatch?.segmentCommand || null, + playlistSegmentFiles: Array.isArray(title.playlistMatch?.segmentFiles) ? title.playlistMatch.segmentFiles : [], + audioTracks: title.audioTracks.map((track) => { + const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule); + return { + ...track, + selectedForEncode, + encodeActions: [], + encodeActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' + }; + }), + subtitleTracks: title.subtitleTracks.map((track) => { + const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule); + const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; + const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; + return { + ...track, + selectedForEncode, + burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, + forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, + forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, + defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, + flags: selectedForEncode ? previewFlags : [], + subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' + }; + }) + }; + }); + + const selectedTitleIds = normalizedTitles.filter((item) => item.selectedByMinLength).map((item) => item.id); + const recommendedPlaylistId = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId || null); + const recommendedReviewTitle = normalizedTitles.find((item) => item.playlistId === recommendedPlaylistId) || null; + + return { + generatedAt: nowIso(), + mode, + mediaProfile: normalizeMediaProfile(mediaProfile) || null, + preRip: Boolean(preRip), + reviewConfirmed: false, + minLengthMinutes, + minLengthSeconds, + selectedTitleIds, + selectors: { + preset: settings?.handbrake_preset || '-', + extraArgs: settings?.handbrake_extra_args || '', + presetProfileSource: 'disc-scan', + audio: { + mode: 'manual', + encoders: [], + copyMask: [], + fallbackEncoder: '-' + }, + subtitle: { + mode: 'manual', + forcedOnly: false, + burnBehavior: 'none' + } + }, + notes: [ + preRip + ? `Vorab-Spurprüfung von Disc-Quelle ${sourceArg || '-'}.` + : `Titel-/Spurprüfung aus RAW-Quelle ${sourceArg || '-'}.`, + preRip + ? 'Backup/Rip startet erst nach manueller Bestätigung und CTA.' + : 'Encode startet erst nach manueller Bestätigung und CTA.' + ], + titles: normalizedTitles, + encodeInputPath: encodeInputTitle ? (encodeInputPath || `disc-track-scan://title-${encodeInputTitle.id}`) : null, + encodeInputTitleId: encodeInputTitle ? encodeInputTitle.id : null, + playlistDecisionRequired, + playlistRecommendation: recommendedPlaylistId + ? { + playlistId: recommendedPlaylistId, + playlistFile: `${recommendedPlaylistId}.mpls`, + reviewTitleId: recommendedReviewTitle?.id || null, + reason: playlistAnalysis?.recommendation?.reason || null + } + : null, + titleSelectionRequired: Boolean(playlistDecisionRequired && !encodeInputTitle) + }; +} + +function findExistingRawDirectory(rawBaseDir, metadataBase) { + if (!rawBaseDir || !metadataBase) { + return null; + } + + if (!fs.existsSync(rawBaseDir)) { + return null; + } + + let entries; + try { + entries = fs.readdirSync(rawBaseDir, { withFileTypes: true }); + } catch (_error) { + return null; + } + + const normalizedBase = sanitizeFileName(metadataBase); + const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedIncompletePrefix = RAW_INCOMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedRipCompletePrefix = RAW_RIP_COMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const folderPattern = new RegExp( + `^(?:(?:${escapedIncompletePrefix}|${escapedRipCompletePrefix}))?${escapedBase}(?:\\s\\[tt\\d{6,12}\\])?\\s-\\sRAW\\s-\\sjob-\\d+\\s*$`, + 'i' + ); + const candidates = entries + .filter((entry) => entry.isDirectory() && folderPattern.test(entry.name)) + .map((entry) => { + const absPath = path.join(rawBaseDir, entry.name); + try { + const dirEntries = fs.readdirSync(absPath); + const stat = fs.statSync(absPath); + return { + path: absPath, + entryCount: dirEntries.length, + mtimeMs: Number(stat.mtimeMs || 0) + }; + } catch (_error) { + return null; + } + }) + .filter((item) => item && item.entryCount > 0) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + return candidates.length > 0 ? candidates[0].path : null; +} + +function buildRawMetadataBase(jobLike = {}, fallbackJobId = null, options = {}) { + const opts = options && typeof options === 'object' ? options : {}; + const normalizedJobId = Number(fallbackJobId || jobLike?.id || 0); + const fallbackTitle = Number.isFinite(normalizedJobId) && normalizedJobId > 0 + ? `job-${Math.trunc(normalizedJobId)}` + : 'job-unknown'; + + const analyzeContext = opts.analyzeContext && typeof opts.analyzeContext === 'object' + ? opts.analyzeContext + : null; + const selectedMetadata = opts.selectedMetadata && typeof opts.selectedMetadata === 'object' + ? opts.selectedMetadata + : resolveSelectedMetadataForJob(jobLike, analyzeContext, null); + const mediaProfile = normalizeMediaProfile( + opts.mediaProfile + || jobLike?.media_type + || analyzeContext?.mediaProfile + || null + ); + const workflowKind = normalizeDvdMetadataWorkflowKind( + opts.workflowKind + ?? selectedMetadata?.workflowKind + ?? analyzeContext?.workflowKind + ?? analyzeContext?.selectedMetadata?.workflowKind + ?? null + ); + const jobKind = String(jobLike?.job_kind || '').trim().toLowerCase(); + const isMultipartMovie = opts.isMultipartMovie === true + || Number(jobLike?.is_multipart_movie || 0) === 1 + || jobKind === 'multipart_movie_child' + || jobKind === 'multipart_movie_container'; + const isSeriesDisc = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const isDiscFilm = isSeriesDiscMediaProfile(mediaProfile) && workflowKind === 'film'; + const seasonNumber = normalizePositiveInteger( + opts.seriesSeasonNumber + ?? selectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ); + const discNumber = normalizePositiveInteger( + opts.seriesDiscNumber + ?? selectedMetadata?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + const rawYear = Number( + opts.year + ?? selectedMetadata?.year + ?? jobLike?.year + ?? jobLike?.fallbackYear + ?? null + ); + const yearValue = Number.isFinite(rawYear) && rawYear > 0 + ? Math.trunc(rawYear) + : new Date().getFullYear(); + + if (mediaProfile === 'cd') { + const cdArtist = normalizeCdTrackText( + opts.artist + ?? selectedMetadata?.artist + ?? jobLike?.artist + ?? '' + ); + const cdAlbum = normalizeCdTrackText( + opts.album + ?? selectedMetadata?.album + ?? selectedMetadata?.title + ?? jobLike?.title + ?? jobLike?.detected_title + ?? fallbackTitle + ); + // CD-RAW-Ordner sollen immer "INTERPRET - ALBUM (YEAR)" enthalten. + // Falls ein Teil fehlt, wird mit dem jeweils vorhandenen Teil/Fallback gefuellt. + const resolvedArtist = cdArtist || cdAlbum || fallbackTitle; + const resolvedAlbum = cdAlbum || cdArtist || fallbackTitle; + return sanitizeFileName(`${resolvedArtist} - ${resolvedAlbum} (${yearValue})`); + } + + if (isSeriesDisc && seasonNumber && discNumber) { + const seriesTitle = normalizeCdTrackText( + opts.seriesTitle + || selectedMetadata?.title + || selectedMetadata?.seriesTitle + || jobLike?.title + || jobLike?.detected_title + || fallbackTitle + ) || fallbackTitle; + return sanitizeFileName(`${seriesTitle} (${yearValue}) - S${seasonNumber}D${discNumber}`); + } + + if (isDiscFilm && isMultipartMovie && discNumber) { + const movieTitle = normalizeCdTrackText( + selectedMetadata?.title + || jobLike?.title + || jobLike?.detected_title + || jobLike?.detectedTitle + || fallbackTitle + ) || fallbackTitle; + return sanitizeFileName(`${movieTitle} (${yearValue}) - D${discNumber}`); + } + + return sanitizeFileName( + renderTemplate('${title} (${year})', { + title: jobLike?.title || jobLike?.detected_title || jobLike?.detectedTitle || fallbackTitle, + year: yearValue + }) + ); +} + +function normalizeRawFolderState(rawState, fallback = RAW_FOLDER_STATES.INCOMPLETE) { + const state = String(rawState || '').trim().toLowerCase(); + if (!state) { + return fallback; + } + if (state === RAW_FOLDER_STATES.INCOMPLETE) { + return RAW_FOLDER_STATES.INCOMPLETE; + } + if (state === RAW_FOLDER_STATES.RIP_COMPLETE || state === 'ripcomplete' || state === 'rip-complete') { + return RAW_FOLDER_STATES.RIP_COMPLETE; + } + if (state === RAW_FOLDER_STATES.COMPLETE || state === 'none' || state === 'final') { + return RAW_FOLDER_STATES.COMPLETE; + } + return fallback; +} + +function stripRawStatePrefix(folderName) { + const rawName = String(folderName || '').trim(); + if (!rawName) { + return ''; + } + const cleaned = rawName + .replace(RAW_STATE_PREFIX_CLEANUP_PATTERN, '') + .replace(/^[_-]+/, '') + .trim(); + return cleaned; +} + +function extractRawFolderJobId(folderName) { + const rawName = stripRawStatePrefix(path.basename(String(folderName || '').trim())); + if (!rawName) { + return null; + } + const match = rawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); + const parsed = Number(match?.[1]); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function extractSeriesSeasonDiscFromRawFolderName(folderName) { + const baseName = stripRawStatePrefix(path.basename(String(folderName || '').trim())); + if (!baseName) { + return { seasonNumber: null, discNumber: null }; + } + + const tokenMatch = baseName.match(/\bS(\d{1,3})D(\d{1,3})\b/i); + if (!tokenMatch) { + return { seasonNumber: null, discNumber: null }; + } + + return { + seasonNumber: normalizePositiveInteger(tokenMatch[1]), + discNumber: normalizePositiveInteger(tokenMatch[2]) + }; +} + +function extractMultipartFilmDiscFromRawFolderName(folderName) { + const baseName = stripRawStatePrefix(path.basename(String(folderName || '').trim())); + if (!baseName) { + return null; + } + const withoutJobSuffix = baseName.replace(/(?:\s*-\s*RAW\s*-\s*job-\d+\s*)+$/i, '').trim(); + if (!withoutJobSuffix) { + return null; + } + const match = withoutJobSuffix.match(/\s-\sD(\d{1,3})\s*$/i); + return normalizePositiveInteger(match?.[1] || null); +} + +function resolveMultipartDiscNumberPair(options = {}) { + const preferredOrphanDiscNumber = normalizePositiveInteger(options?.preferredOrphanDiscNumber); + const preferredCurrentDiscNumber = normalizePositiveInteger(options?.preferredCurrentDiscNumber); + const orphanDiscNumber = preferredOrphanDiscNumber || 1; + let currentDiscNumber = preferredCurrentDiscNumber || (orphanDiscNumber === 1 ? 2 : 1); + if (currentDiscNumber === orphanDiscNumber) { + currentDiscNumber = orphanDiscNumber === 1 ? 2 : 1; + while (currentDiscNumber === orphanDiscNumber) { + currentDiscNumber += 1; + } + } + return { + orphanDiscNumber, + currentDiscNumber + }; +} + +function normalizeMultipartMovieSelectedMetadata(baseSelectedMetadata = {}, discNumber = null) { + const source = baseSelectedMetadata && typeof baseSelectedMetadata === 'object' + ? baseSelectedMetadata + : {}; + const normalizedDiscNumber = normalizePositiveInteger(discNumber); + return { + ...source, + workflowKind: 'film', + metadataProvider: 'tmdb', + metadataKind: 'movie', + providerId: null, + tmdbId: null, + seasonNumber: null, + seasonName: null, + episodeCount: 0, + episodes: [], + discNumber: normalizedDiscNumber || null + }; +} + +function applyRawFolderStateToName(folderName, state) { + const baseName = stripRawStatePrefix(folderName); + if (!baseName) { + return baseName; + } + const normalizedState = normalizeRawFolderState(state, RAW_FOLDER_STATES.COMPLETE); + if (normalizedState === RAW_FOLDER_STATES.INCOMPLETE) { + return `${RAW_INCOMPLETE_PREFIX}${baseName}`; + } + if (normalizedState === RAW_FOLDER_STATES.RIP_COMPLETE) { + return `${RAW_RIP_COMPLETE_PREFIX}${baseName}`; + } + return baseName; +} + +function resolveRawFolderStateFromPath(rawPath) { + const sourcePath = String(rawPath || '').trim(); + if (!sourcePath) { + return RAW_FOLDER_STATES.COMPLETE; + } + const folderName = path.basename(sourcePath); + if (/^(?:Incomplete_|Rip[_-]?Incomplete_)/i.test(folderName)) { + return RAW_FOLDER_STATES.INCOMPLETE; + } + if (/^Rip_Complete_/i.test(folderName)) { + return RAW_FOLDER_STATES.RIP_COMPLETE; + } + return RAW_FOLDER_STATES.COMPLETE; +} + +function resolveRawFolderStateFromOptions(options = {}) { + if (options && Object.prototype.hasOwnProperty.call(options, 'state')) { + return normalizeRawFolderState(options.state, RAW_FOLDER_STATES.INCOMPLETE); + } + if (options && options.ripComplete) { + return RAW_FOLDER_STATES.RIP_COMPLETE; + } + if (options && Object.prototype.hasOwnProperty.call(options, 'incomplete')) { + return options.incomplete ? RAW_FOLDER_STATES.INCOMPLETE : RAW_FOLDER_STATES.COMPLETE; + } + return RAW_FOLDER_STATES.INCOMPLETE; +} + +function buildRawDirName(metadataBase, jobId, options = {}) { + const state = resolveRawFolderStateFromOptions(options); + const baseName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`); + return applyRawFolderStateToName(baseName, state); +} + +function buildRawPathForState(rawPath, state) { + const sourcePath = String(rawPath || '').trim(); + if (!sourcePath) { + return null; + } + const folderName = path.basename(sourcePath); + const nextFolderName = applyRawFolderStateToName(folderName, state); + if (!nextFolderName) { + return sourcePath; + } + return path.join(path.dirname(sourcePath), nextFolderName); +} + +function buildRipCompleteRawPath(rawPath) { + return buildRawPathForState(rawPath, RAW_FOLDER_STATES.RIP_COMPLETE); +} + +function buildCompletedRawPath(rawPath) { + return buildRawPathForState(rawPath, RAW_FOLDER_STATES.COMPLETE); +} + +function normalizeComparablePath(inputPath) { + const source = String(inputPath || '').trim(); + if (!source) { + return ''; + } + return path.resolve(source).replace(/[\\/]+$/, ''); +} + +function isPathInsideDirectory(parentPath, candidatePath) { + const parent = normalizeComparablePath(parentPath); + const candidate = normalizeComparablePath(candidatePath); + if (!parent || !candidate) { + return false; + } + if (candidate === parent) { + return true; + } + const parentWithSep = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`; + return candidate.startsWith(parentWithSep); +} + +function resolveIncompleteDirectoryFromOutputPath(outputPath, jobId = null) { + const normalizedOutputPath = normalizeComparablePath(outputPath); + if (!normalizedOutputPath) { + return null; + } + + const normalizedJobId = normalizePositiveInteger(jobId); + const expectedFolderPattern = normalizedJobId + ? new RegExp(`^incomplete_job-${normalizedJobId}\\s*$`, 'i') + : /^incomplete_job-\d+\s*$/i; + let currentPath = normalizedOutputPath; + let remainingDepth = 32; + while (currentPath && remainingDepth > 0) { + const folderName = String(path.basename(currentPath) || '').trim(); + if (expectedFolderPattern.test(folderName)) { + return currentPath; + } + const parentPath = normalizeComparablePath(path.dirname(currentPath)); + if (!parentPath || parentPath === currentPath) { + break; + } + currentPath = parentPath; + remainingDepth -= 1; + } + return null; +} + +function remapPathToRetargetedRawRoot(inputPath, previousRawPath, nextRawPath) { + const sourceInputPath = String(inputPath || '').trim(); + if (!sourceInputPath) { + return null; + } + const previousRaw = normalizeComparablePath(previousRawPath); + const nextRaw = normalizeComparablePath(nextRawPath); + const input = normalizeComparablePath(sourceInputPath); + if (!previousRaw || !nextRaw || !input) { + return sourceInputPath; + } + if (input === previousRaw) { + return nextRaw; + } + if (!isPathInsideDirectory(previousRaw, input)) { + return sourceInputPath; + } + const relative = path.relative(previousRaw, input); + if (!relative || relative === '.') { + return nextRaw; + } + return path.join(nextRaw, relative); +} + +function isEncodeInputMismatchedWithRaw(rawPath, encodeInputPath) { + const raw = normalizeComparablePath(rawPath); + const input = normalizeComparablePath(encodeInputPath); + if (!raw || !input) { + return true; + } + if (raw === input) { + return false; + } + return !isPathInsideDirectory(raw, input); +} + +function isJobFinished(jobLike = null) { + const status = String(jobLike?.status || '').trim().toUpperCase(); + const lastState = String(jobLike?.last_state || '').trim().toUpperCase(); + return status === 'FINISHED' || lastState === 'FINISHED'; +} + +function toPlaylistFile(playlistId) { + const normalized = normalizePlaylistId(playlistId); + return normalized ? `${normalized}.mpls` : null; +} + +function describePlaylistManualDecision(playlistAnalysis) { + const obfuscationDetected = Boolean(playlistAnalysis?.obfuscationDetected); + const candidateCount = Array.isArray(playlistAnalysis?.candidates) + ? playlistAnalysis.candidates.length + : 0; + const reasonCodeRaw = String(playlistAnalysis?.manualDecisionReason || '').trim(); + const reasonCode = reasonCodeRaw || ( + obfuscationDetected + ? 'multiple_similar_candidates' + : (candidateCount > 1 ? 'multiple_candidates_after_min_length' : 'manual_selection_required') + ); + const detailText = obfuscationDetected + ? 'Blu-ray verwendet Playlist-Obfuscation (mehrere gleichlange Kandidaten).' + : (candidateCount > 1 + ? `Mehrere Playlists erfüllen MIN_LENGTH_MINUTES (${candidateCount} Kandidaten).` + : 'Manuelle Playlist-Auswahl erforderlich.'); + + return { + obfuscationDetected, + candidateCount, + reasonCode, + detailText + }; +} + +function buildPlaylistCandidates(playlistAnalysis) { + const rawList = Array.isArray(playlistAnalysis?.candidatePlaylists) + ? playlistAnalysis.candidatePlaylists + : []; + const sourceRows = [ + ...(Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []), + ...(Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []), + ...(Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []) + ]; + const segmentMap = playlistAnalysis?.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object' + ? playlistAnalysis.playlistSegments + : {}; + const aliasMap = playlistAnalysis?.playlistAliasMap && typeof playlistAnalysis.playlistAliasMap === 'object' + ? playlistAnalysis.playlistAliasMap + : {}; + + return rawList + .map((playlistId) => normalizePlaylistId(playlistId)) + .filter(Boolean) + .map((playlistId) => { + const source = sourceRows.find((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile) === playlistId) || null; + const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; + const playlistAliases = normalizePlaylistAliasIds( + Array.isArray(source?.playlistAliases) && source.playlistAliases.length > 0 + ? source.playlistAliases + : aliasMap[playlistId], + playlistId + ); + const score = Number(source?.score); + const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence); + const correctedCoherence = Number(source?.correctedCoherence ?? source?.structuralMetrics?.correctedCoherence); + const legacySequenceCoherence = Number(source?.legacySequenceCoherence ?? source?.structuralMetrics?.legacySequenceCoherence); + const titleId = Number(source?.titleId ?? source?.id); + const handBrakeTitleId = Number(source?.handBrakeTitleId); + const durationSecondsRaw = Number(source?.durationSeconds ?? source?.duration ?? 0); + const durationSeconds = Number.isFinite(durationSecondsRaw) && durationSecondsRaw > 0 + ? Math.trunc(durationSecondsRaw) + : 0; + const sizeBytesRaw = Number(source?.sizeBytes ?? source?.size ?? 0); + const sizeBytes = Number.isFinite(sizeBytesRaw) && sizeBytesRaw > 0 + ? Math.trunc(sizeBytesRaw) + : 0; + const durationLabelRaw = String(source?.durationLabel || '').trim(); + const durationLabel = durationLabelRaw || formatDurationClock(durationSeconds); + const sourceAudioTracks = Array.isArray(source?.audioTracks) ? source.audioTracks : []; + const fallbackAudioTrackPreview = sourceAudioTracks + .slice(0, 8) + .map((track) => { + const rawTrackId = Number(track?.sourceTrackId ?? track?.id); + const trackId = Number.isFinite(rawTrackId) && rawTrackId > 0 ? Math.trunc(rawTrackId) : null; + const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); + const languageLabel = String(track?.languageLabel || track?.language || language).trim() || language; + const format = String(track?.format || '').trim(); + const channels = String(track?.channels || '').trim(); + const parts = []; + if (trackId !== null) { + parts.push(`#${trackId}`); + } + parts.push(language); + parts.push(languageLabel); + if (format) { + parts.push(format); + } + if (channels) { + parts.push(channels); + } + return parts.join(' | '); + }) + .filter((line) => line.length > 0); + const sourceAudioTrackPreview = Array.isArray(source?.audioTrackPreview) + ? source.audioTrackPreview.map((line) => String(line || '').trim()).filter((line) => line.length > 0) + : []; + const audioTrackPreview = sourceAudioTrackPreview.length > 0 ? sourceAudioTrackPreview : fallbackAudioTrackPreview; + const audioSummary = String(source?.audioSummary || '').trim() || buildHandBrakeAudioSummary(audioTrackPreview); + + return { + playlistId, + playlistFile: toPlaylistFile(playlistId), + playlistAliases, + titleId: Number.isFinite(titleId) ? Math.trunc(titleId) : null, + durationSeconds, + durationLabel: durationLabel || null, + sizeBytes, + score: Number.isFinite(score) ? score : null, + recommended: Boolean(source?.recommended), + evaluationLabel: source?.evaluationLabel || null, + sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null, + correctedCoherence: Number.isFinite(correctedCoherence) ? correctedCoherence : null, + legacySequenceCoherence: Number.isFinite(legacySequenceCoherence) ? legacySequenceCoherence : null, + clusterId: String(source?.clusterId || '').trim() || null, + clusterSize: Number(source?.clusterSize || 0) || null, + commonRuns: Array.isArray(source?.commonRuns) ? source.commonRuns : [], + variantSpans: Array.isArray(source?.variantSpans) ? source.variantSpans : [], + overlapEntries: Array.isArray(source?.overlapEntries) ? source.overlapEntries : [], + positionReason: String(source?.positionReason || '').trim() || null, + segmentCommand: source?.segmentCommand + || segmentEntry?.segmentCommand + || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, + segmentFiles: Array.isArray(source?.segmentFiles) && source.segmentFiles.length > 0 + ? source.segmentFiles + : (Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles : []), + handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0 + ? Math.trunc(handBrakeTitleId) + : null, + audioSummary: audioSummary || null, + audioTrackPreview + }; + }); +} + +function formatPlaylistCandidateRunsForLog(commonRuns) { + return (Array.isArray(commonRuns) ? commonRuns : []) + .map((run) => { + const start = Number(run?.startSegment); + const end = Number(run?.endSegment); + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + return `${String(Math.trunc(start)).padStart(5, '0')}-${String(Math.trunc(end)).padStart(5, '0')}`; + }) + .filter(Boolean) + .join(' | '); +} + +function formatPlaylistCandidateVariantSpansForLog(variantSpans) { + return (Array.isArray(variantSpans) ? variantSpans : []) + .map((span) => { + const before = Number.isFinite(Number(span?.beforeSegment)) + ? String(Math.trunc(Number(span.beforeSegment))).padStart(5, '0') + : 'START'; + const after = Number.isFinite(Number(span?.afterSegment)) + ? String(Math.trunc(Number(span.afterSegment))).padStart(5, '0') + : 'END'; + const segments = (Array.isArray(span?.segments) ? span.segments : []) + .map((segment) => Number(segment)) + .filter((segment) => Number.isFinite(segment)) + .map((segment) => String(Math.trunc(segment)).padStart(5, '0')); + if (segments.length === 0) { + return null; + } + return `${before}->${after}: [${segments.join(', ')}]`; + }) + .filter(Boolean) + .join(' | '); +} + +function formatPlaylistCandidateOverlapForLog(overlapEntries) { + return (Array.isArray(overlapEntries) ? overlapEntries : []) + .map((entry) => { + const otherPlaylistId = normalizePlaylistId(entry?.otherPlaylistId); + const ratio = Number(entry?.ratioToSmaller || 0); + if (!otherPlaylistId) { + return null; + } + return `${otherPlaylistId}.mpls=${ratio.toFixed(3)}`; + }) + .filter(Boolean) + .join(', '); +} + +function buildPlaylistCandidateDiagnosticLine(candidate) { + const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`; + const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-'; + const scoreLabel = Number(Number(candidate?.score)).toFixed(2); + const legacyCoherence = Number(candidate?.legacySequenceCoherence || 0).toFixed(3); + const correctedCoherence = Number(candidate?.correctedCoherence || candidate?.sequenceCoherence || 0).toFixed(3); + const clusterLabel = candidate?.clusterId + ? `${candidate.clusterId}${candidate?.clusterSize ? `(${candidate.clusterSize})` : ''}` + : 'standalone'; + const overlapLabel = formatPlaylistCandidateOverlapForLog(candidate?.overlapEntries) || '-'; + const commonRunsLabel = formatPlaylistCandidateRunsForLog(candidate?.commonRuns) || '-'; + const variantSpansLabel = formatPlaylistCandidateVariantSpansForLog(candidate?.variantSpans) || '-'; + const handBrakeTitleId = Number(candidate?.handBrakeTitleId); + const titleLabel = Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0 + ? `HB #${Math.trunc(handBrakeTitleId)}` + : `MakeMKV #${candidate?.titleId ?? '-'}`; + const recommendedLabel = candidate?.recommended ? ' | empfohlen' : ''; + const reasonLabel = String(candidate?.positionReason || candidate?.evaluationLabel || '').trim() || '-'; + return `${playlistFile} | ${titleLabel} | Dauer ${durationLabel} | Score ${scoreLabel} | altKoh ${legacyCoherence} | neuKoh ${correctedCoherence}` + + ` | Cluster ${clusterLabel} | Overlap ${overlapLabel} | CommonRuns ${commonRunsLabel} | VariantSpans ${variantSpansLabel}` + + `${recommendedLabel} | Grund: ${reasonLabel}`; +} + +function buildHandBrakeAudioTrackPreview(titleInfo) { + const tracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; + return tracks + .map((track) => { + const rawTrackId = Number(track?.sourceTrackId ?? track?.id); + const trackId = Number.isFinite(rawTrackId) && rawTrackId > 0 ? Math.trunc(rawTrackId) : null; + const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); + const description = String(track?.description || track?.title || '').trim(); + const codec = String(track?.codecName || track?.format || '').trim(); + const channels = String(track?.channels || '').trim(); + + const parts = []; + if (trackId !== null) { + parts.push(`#${trackId}`); + } + parts.push(language); + if (description) { + parts.push(description); + } else { + if (codec) { + parts.push(codec); + } + if (channels) { + parts.push(channels); + } + } + return parts.join(' | ').trim(); + }) + .filter((line) => line.length > 0); +} + +function buildHandBrakeAudioSummary(previewLines) { + const lines = Array.isArray(previewLines) + ? previewLines.filter((line) => String(line || '').trim().length > 0) + : []; + if (lines.length === 0) { + return null; + } + return lines.slice(0, 3).join(' || '); +} + +function normalizeHandBrakePlaylistScanCache(rawCache) { + if (!rawCache || typeof rawCache !== 'object') { + return null; + } + + const inputPath = String(rawCache?.inputPath || '').trim() || null; + const source = String(rawCache?.source || '').trim() || 'HANDBRAKE_SCAN_PLAYLIST_MAP'; + const generatedAt = String(rawCache?.generatedAt || '').trim() || null; + + const rawEntries = []; + if (rawCache?.byPlaylist && typeof rawCache.byPlaylist === 'object') { + for (const [key, value] of Object.entries(rawCache.byPlaylist)) { + rawEntries.push({ key, value }); + } + } else if (Array.isArray(rawCache?.playlists)) { + for (const item of rawCache.playlists) { + rawEntries.push({ key: item?.playlistId || item?.playlistFile || null, value: item }); + } + } + + const byPlaylist = {}; + for (const entry of rawEntries) { + const row = entry?.value && typeof entry.value === 'object' ? entry.value : null; + const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || entry?.key || null); + if (!playlistId) { + continue; + } + const rawHandBrakeTitleId = Number(row?.handBrakeTitleId ?? row?.titleId); + const handBrakeTitleId = Number.isFinite(rawHandBrakeTitleId) && rawHandBrakeTitleId > 0 + ? Math.trunc(rawHandBrakeTitleId) + : null; + const titleInfo = row?.titleInfo && typeof row.titleInfo === 'object' ? row.titleInfo : null; + const audioTrackPreview = Array.isArray(row?.audioTrackPreview) + ? row.audioTrackPreview.map((line) => String(line || '').trim()).filter((line) => line.length > 0) + : buildHandBrakeAudioTrackPreview(titleInfo); + const audioSummary = String(row?.audioSummary || '').trim() || buildHandBrakeAudioSummary(audioTrackPreview); + + byPlaylist[playlistId] = { + playlistId, + handBrakeTitleId, + titleInfo, + audioTrackPreview, + audioSummary: audioSummary || null + }; + } + + if (Object.keys(byPlaylist).length === 0) { + return null; + } + + return { + generatedAt, + source, + inputPath, + byPlaylist + }; +} + +function getCachedHandBrakePlaylistEntry(scanCache, playlistIdRaw) { + const playlistId = normalizePlaylistId(playlistIdRaw); + if (!playlistId) { + return null; + } + const normalized = normalizeHandBrakePlaylistScanCache(scanCache); + if (!normalized) { + return null; + } + return normalized.byPlaylist[playlistId] || null; +} + +function hasCachedHandBrakeDataForPlaylistCandidates(scanCache, playlistCandidates = []) { + const normalized = normalizeHandBrakePlaylistScanCache(scanCache); + if (!normalized) { + return false; + } + + const candidateIds = (Array.isArray(playlistCandidates) ? playlistCandidates : []) + .map((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile || item)) + .filter(Boolean); + if (candidateIds.length === 0) { + return false; + } + + return candidateIds.every((playlistId) => { + const row = normalized.byPlaylist[playlistId]; + return Boolean(row && row.handBrakeTitleId && row.titleInfo); + }); +} + +function buildHandBrakePlaylistScanCache(scanJson, playlistCandidates = [], rawPath = null) { + const candidateMetaByPlaylist = new Map(); + for (const row of (Array.isArray(playlistCandidates) ? playlistCandidates : [])) { + const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || row); + if (!playlistId || candidateMetaByPlaylist.has(playlistId)) { + continue; + } + candidateMetaByPlaylist.set(playlistId, { + expectedMakemkvTitleId: normalizeNonNegativeInteger(row?.titleId), + expectedDurationSeconds: Number(row?.durationSeconds || 0) || null, + expectedSizeBytes: Number(row?.sizeBytes || 0) || null, + playlistAliases: normalizePlaylistAliasIds(row?.playlistAliases, playlistId) + }); + } + + const candidateIds = Array.from(new Set( + (Array.isArray(playlistCandidates) ? playlistCandidates : []) + .map((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile || item)) + .filter(Boolean) + )); + + const byPlaylist = {}; + for (const playlistId of candidateIds) { + const expected = candidateMetaByPlaylist.get(playlistId) || {}; + const handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(scanJson, playlistId, expected); + if (!handBrakeTitleId) { + continue; + } + const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { + playlistId, + handBrakeTitleId + }); + if (!titleInfo) { + continue; + } + if (!isHandBrakePlaylistCacheEntryCompatible({ + playlistId, + handBrakeTitleId, + titleInfo + }, playlistId, expected)) { + continue; + } + const audioTrackPreview = buildHandBrakeAudioTrackPreview(titleInfo); + byPlaylist[playlistId] = { + playlistId, + handBrakeTitleId, + titleInfo, + audioTrackPreview, + audioSummary: buildHandBrakeAudioSummary(audioTrackPreview) + }; + } + + return normalizeHandBrakePlaylistScanCache({ + generatedAt: nowIso(), + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + inputPath: rawPath || null, + byPlaylist + }); +} + +function enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, scanCache) { + const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null; + const normalizedCache = normalizeHandBrakePlaylistScanCache(scanCache); + if (!analysis || !normalizedCache) { + return analysis; + } + + const enrichRow = (row) => { + const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null); + if (!playlistId) { + return row; + } + const cached = normalizedCache.byPlaylist[playlistId]; + if (!cached) { + return row; + } + return { + ...row, + handBrakeTitleId: cached.handBrakeTitleId || null, + audioSummary: cached.audioSummary || null, + audioTrackPreview: Array.isArray(cached.audioTrackPreview) ? cached.audioTrackPreview : [] + }; + }; + + const recommendationPlaylistId = normalizePlaylistId(analysis?.recommendation?.playlistId); + const recommendationCached = recommendationPlaylistId + ? normalizedCache.byPlaylist[recommendationPlaylistId] || null + : null; + + return { + ...analysis, + evaluatedCandidates: Array.isArray(analysis?.evaluatedCandidates) + ? analysis.evaluatedCandidates.map((row) => enrichRow(row)) + : [], + candidates: Array.isArray(analysis?.candidates) + ? analysis.candidates.map((row) => enrichRow(row)) + : [], + titles: Array.isArray(analysis?.titles) + ? analysis.titles.map((row) => enrichRow(row)) + : [], + recommendation: analysis?.recommendation && typeof analysis.recommendation === 'object' + ? { + ...analysis.recommendation, + handBrakeTitleId: recommendationCached?.handBrakeTitleId || null, + audioSummary: recommendationCached?.audioSummary || null, + audioTrackPreview: Array.isArray(recommendationCached?.audioTrackPreview) + ? recommendationCached.audioTrackPreview + : [] + } + : analysis?.recommendation || null + }; +} + +function pickTitleIdForPlaylist(playlistAnalysis, playlistId) { + const normalized = normalizePlaylistId(playlistId); + if (!normalized || !playlistAnalysis) { + return null; + } + + const playlistMap = playlistAnalysis?.playlistToTitleId + && typeof playlistAnalysis.playlistToTitleId === 'object' + ? playlistAnalysis.playlistToTitleId + : null; + if (playlistMap) { + const byFile = Number(playlistMap[`${normalized}.mpls`]); + if (Number.isFinite(byFile) && byFile >= 0) { + return Math.trunc(byFile); + } + const byId = Number(playlistMap[normalized]); + if (Number.isFinite(byId) && byId >= 0) { + return Math.trunc(byId); + } + } + + const sources = [ + ...(Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []), + ...(Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []), + ...(Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []) + ]; + + const matches = sources + .filter((item) => normalizePlaylistId(item?.playlistId) === normalized) + .map((item) => ({ + titleId: Number(item?.titleId ?? item?.id), + durationSeconds: Number(item?.durationSeconds || 0), + sizeBytes: Number(item?.sizeBytes || 0) + })) + .filter((item) => Number.isFinite(item.titleId) && item.titleId >= 0) + .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId); + + return matches.length > 0 ? matches[0].titleId : null; +} + +function normalizeReviewTitleId(rawValue) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); +} + +function normalizeReviewTitleIdList(rawValues) { + const values = Array.isArray(rawValues) ? rawValues : []; + const seen = new Set(); + const normalized = []; + for (const value of values) { + const id = normalizeReviewTitleId(value); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + normalized.push(id); + } + return normalized; +} + +function resolveSeriesBatchSelectedTitleIdsFromPlan(parentPlan = null) { + const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; + const explicitSelectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(plan?.selectedTitleIds) + ? plan.selectedTitleIds + : [plan?.encodeInputTitleId] + ); + if (explicitSelectedTitleIds.length > 1) { + return explicitSelectedTitleIds; + } + + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const assignmentTitleIds = normalizeReviewTitleIdList(Object.keys(assignmentMap)); + const episodeHintTitleIds = normalizeReviewTitleIdList( + titles + .filter((title) => { + if (Boolean(title?.selectedForEncode || title?.encodeInput)) { + return true; + } + const hasEpisodeStartHint = normalizePositiveInteger( + title?.episodeNumberStart + ?? title?.episodeNumber + ?? title?.episodeIdStart + ?? title?.episodeId + ?? null + ); + if (hasEpisodeStartHint) { + return true; + } + const hasEpisodeEndHint = normalizePositiveInteger( + title?.episodeNumberEnd + ?? title?.episodeIdEnd + ?? null + ); + if (hasEpisodeEndHint) { + return true; + } + return Boolean(String(title?.episodeTitle || '').trim()); + }) + .map((title) => title?.id) + ); + + const mergedFallbackTitleIds = normalizeReviewTitleIdList([ + ...explicitSelectedTitleIds, + ...assignmentTitleIds, + ...episodeHintTitleIds + ]); + return mergedFallbackTitleIds.length > 0 + ? mergedFallbackTitleIds + : explicitSelectedTitleIds; +} + +function normalizeEpisodeNumberValue(value) { + const numeric = Number(String(value ?? '').trim()); + if (!Number.isFinite(numeric) || numeric <= 0) { + return null; + } + const rounded = Math.round(numeric * 100) / 100; + return rounded; +} + +function normalizeEpisodeAssignmentsPayload(rawAssignments, selectedTitleIds = []) { + const selectedSet = new Set( + normalizeReviewTitleIdList(selectedTitleIds).map((id) => Number(id)) + ); + const assignmentEntries = Array.isArray(rawAssignments) + ? rawAssignments + .map((entry) => { + const titleId = normalizeReviewTitleId(entry?.titleId ?? entry?.id ?? null); + if (!titleId) { + return null; + } + return [titleId, entry]; + }) + .filter(Boolean) + : (rawAssignments && typeof rawAssignments === 'object' + ? Object.entries(rawAssignments) + : []); + if (assignmentEntries.length === 0) { + return {}; + } + + const output = {}; + for (const [rawTitleId, rawAssignment] of assignmentEntries) { + const titleId = normalizeReviewTitleId(rawTitleId); + if (!titleId) { + continue; + } + if (selectedSet.size > 0 && !selectedSet.has(Number(titleId))) { + continue; + } + const assignment = rawAssignment && typeof rawAssignment === 'object' + ? rawAssignment + : {}; + const episodeIdRaw = Number(assignment?.episodeId ?? assignment?.id ?? 0); + const episodeId = Number.isFinite(episodeIdRaw) && episodeIdRaw > 0 + ? Math.trunc(episodeIdRaw) + : null; + const episodeIdStartRaw = Number( + assignment?.episodeIdStart + ?? assignment?.episodeStartId + ?? assignment?.episode_start_id + ?? 0 + ); + const episodeIdStart = Number.isFinite(episodeIdStartRaw) && episodeIdStartRaw > 0 + ? Math.trunc(episodeIdStartRaw) + : null; + const episodeIdEndRaw = Number( + assignment?.episodeIdEnd + ?? assignment?.episodeEndId + ?? assignment?.episode_end_id + ?? 0 + ); + const episodeIdEnd = Number.isFinite(episodeIdEndRaw) && episodeIdEndRaw > 0 + ? Math.trunc(episodeIdEndRaw) + : null; + const baseEpisodeNumber = normalizeEpisodeNumberValue(assignment?.episodeNumber ?? assignment?.number ?? null); + const episodeNumberStart = normalizeEpisodeNumberValue( + assignment?.episodeNumberStart + ?? assignment?.episodeNoStart + ?? assignment?.episode_start + ?? baseEpisodeNumber + ?? null + ); + let episodeNumberEnd = normalizeEpisodeNumberValue( + assignment?.episodeNumberEnd + ?? assignment?.episodeNoEnd + ?? assignment?.episode_end + ?? null + ); + const explicitSpan = normalizePositiveInteger( + assignment?.episodeSpan + ?? assignment?.episodeCount + ?? null + ); + if (episodeNumberEnd === null && episodeNumberStart !== null && explicitSpan && explicitSpan > 1) { + episodeNumberEnd = episodeNumberStart + explicitSpan - 1; + } + if (episodeNumberEnd !== null && episodeNumberStart !== null && episodeNumberEnd < episodeNumberStart) { + episodeNumberEnd = episodeNumberStart; + } + const episodeNumber = episodeNumberStart ?? baseEpisodeNumber; + const normalizedEpisodeSpan = ( + episodeNumberStart !== null + && episodeNumberEnd !== null + && episodeNumberEnd >= episodeNumberStart + ) + ? Math.max(1, Math.trunc(episodeNumberEnd - episodeNumberStart + 1)) + : ( + explicitSpan && explicitSpan > 0 + ? explicitSpan + : null + ); + const episodeRange = String(assignment?.episodeRange || '').trim() || null; + const seasonNumber = normalizePositiveInteger(assignment?.seasonNumber ?? assignment?.season ?? null); + const rawEpisodeTitle = String( + assignment?.episodeTitle + ?? assignment?.title + ?? assignment?.name + ?? '' + ).trim() || null; + const episodeTitleStart = String(assignment?.episodeTitleStart || '').trim() || null; + const episodeTitleEnd = String(assignment?.episodeTitleEnd || '').trim() || null; + if ( + !episodeId + && !episodeIdStart + && !episodeIdEnd + && episodeNumber === null + && episodeNumberStart === null + && episodeNumberEnd === null + && seasonNumber === null + && !rawEpisodeTitle + && !episodeTitleStart + && !episodeTitleEnd + ) { + continue; + } + const rangeStart = episodeNumberStart ?? episodeNumber ?? 1; + const rangeEnd = episodeNumberEnd ?? episodeNumberStart ?? episodeNumber ?? rangeStart; + const episodeTitle = rawEpisodeTitle + ? normalizeSeriesEpisodeTitleForOutput( + rawEpisodeTitle, + { + start: rangeStart, + end: rangeEnd, + isMulti: (normalizedEpisodeSpan || 1) > 1 + } + ) + : null; + output[String(titleId)] = { + titleId, + episodeId: episodeIdStart || episodeId || null, + episodeNumber, + episodeNumberStart: episodeNumberStart ?? episodeNumber ?? null, + episodeNumberEnd: episodeNumberEnd ?? episodeNumberStart ?? episodeNumber ?? null, + episodeSpan: normalizedEpisodeSpan, + episodeRange, + seasonNumber, + episodeTitle, + episodeIdStart: episodeIdStart || episodeId || null, + episodeIdEnd, + episodeTitleStart, + episodeTitleEnd + }; + } + return output; +} + +function isSeriesBatchParentPlan(rawPlan) { + const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; + if (!plan) { + return false; + } + return Boolean(plan.seriesBatchParent) && !Boolean(plan.seriesBatchChild); +} + +function isSeriesBatchChildPlan(rawPlan) { + const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; + if (!plan) { + return false; + } + return Boolean(plan.seriesBatchChild); +} + +function resolveSeriesBatchParentJobIdFromPlan(rawPlan, fallbackParentJobId = null) { + const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; + if (!plan) { + return normalizePositiveInteger(fallbackParentJobId); + } + const fromPlan = normalizePositiveInteger( + plan.seriesBatchParentJobId + || plan.parentJobId + || null + ); + if (fromPlan) { + return fromPlan; + } + return normalizePositiveInteger(fallbackParentJobId); +} + +function isTerminalStatus(status) { + const normalized = String(status || '').trim().toUpperCase(); + return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; +} + +function normalizeSeriesEpisodeStatus(status, fallback = 'QUEUED') { + const normalized = String(status || '').trim().toUpperCase(); + if (['QUEUED', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELLED'].includes(normalized)) { + return normalized; + } + return String(fallback || 'QUEUED').trim().toUpperCase() || 'QUEUED'; +} + +function buildSeriesBatchEpisodesFromPlan(parentJob, parentPlan, titleIds = []) { + const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; + const normalizedTitleIds = normalizeReviewTitleIdList(titleIds); + const existingEpisodes = Array.isArray(plan?.seriesBatchEpisodes) ? plan.seriesBatchEpisodes : []; + const completedTitleIdSet = new Set( + normalizeReviewTitleIdList([ + ...(Array.isArray(plan?.seriesBatchCompletedTitleIds) ? plan.seriesBatchCompletedTitleIds : []), + ...existingEpisodes + .filter((entry) => normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED') === 'FINISHED') + .map((entry) => entry?.titleId) + ]).map((value) => Number(value)) + ); + const existingByTitleId = new Map(); + for (const entry of existingEpisodes) { + const titleId = normalizeReviewTitleId(entry?.titleId); + if (!titleId) { + continue; + } + existingByTitleId.set(Number(titleId), entry); + } + + const nextEpisodes = []; + for (let index = 0; index < normalizedTitleIds.length; index += 1) { + const titleId = normalizedTitleIds[index]; + const existing = existingByTitleId.get(Number(titleId)) || null; + const label = resolveSeriesBatchChildDisplayTitle(parentJob, plan, titleId, index); + const parsedProgress = Number(existing?.progress); + const rawStatus = normalizeSeriesEpisodeStatus(existing?.status, 'QUEUED'); + const status = ( + completedTitleIdSet.has(Number(titleId)) + && rawStatus !== 'ERROR' + && rawStatus !== 'CANCELLED' + ) + ? 'FINISHED' + : rawStatus; + const progress = status === 'FINISHED' + ? 100 + : ( + Number.isFinite(parsedProgress) + ? Math.max(0, Math.min(100, parsedProgress)) + : 0 + ); + nextEpisodes.push({ + episodeIndex: index + 1, + titleId: Number(titleId), + jobId: normalizePositiveInteger(existing?.jobId) || null, + label, + status, + progress, + eta: existing?.eta ?? null, + startedAt: existing?.startedAt || null, + finishedAt: existing?.finishedAt || null, + outputPath: existing?.outputPath || null, + error: existing?.error || null, + trackSelection: existing?.trackSelection && typeof existing.trackSelection === 'object' + ? existing.trackSelection + : null, + handbrakeInfo: existing?.handbrakeInfo && typeof existing.handbrakeInfo === 'object' + ? existing.handbrakeInfo + : null + }); + } + + return nextEpisodes; +} + +function buildSeriesBatchProgressFromEpisodes(parentJobId, episodes = []) { + const rows = Array.isArray(episodes) ? episodes : []; + const totalCount = rows.length; + let finishedCount = 0; + let cancelledCount = 0; + let errorCount = 0; + let runningCount = 0; + let aggregateProgress = 0; + const children = []; + + for (const entry of rows) { + const status = normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'); + const progressRaw = Number(entry?.progress); + const progress = status === 'FINISHED' + ? 100 + : ( + Number.isFinite(progressRaw) + ? Math.max(0, Math.min(100, progressRaw)) + : 0 + ); + if (status === 'FINISHED') { + finishedCount += 1; + } else if (status === 'ERROR') { + errorCount += 1; + } else if (status === 'CANCELLED') { + cancelledCount += 1; + } else if (status === 'RUNNING') { + runningCount += 1; + } + aggregateProgress += (status === 'FINISHED' ? 100 : progress); + children.push({ + jobId: normalizePositiveInteger(entry?.jobId) || Number(parentJobId), + title: String(entry?.label || `Episode #${entry?.episodeIndex || children.length + 1}`).trim(), + status, + progress: Number(progress.toFixed(2)), + eta: entry?.eta ?? null, + episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || null, + titleId: normalizeReviewTitleId(entry?.titleId) || null, + outputPath: entry?.outputPath || null, + error: entry?.error || null + }); + } + + const overallProgress = totalCount > 0 + ? Number((aggregateProgress / totalCount).toFixed(2)) + : 0; + const summaryText = `Serien-Encoding: ${finishedCount}/${totalCount} abgeschlossen${errorCount > 0 ? ` | Fehler: ${errorCount}` : ''}${cancelledCount > 0 ? ` | Abgebrochen: ${cancelledCount}` : ''}`; + + return { + parentJobId: Number(parentJobId), + totalCount, + finishedCount, + cancelledCount, + errorCount, + runningCount, + overallProgress, + summaryText, + children + }; +} + +function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, childIndex = 0) { + const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const selectedTitle = titles.find((title) => Number(title?.id) === Number(titleId)) || null; + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const assignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; + + const baseTitle = String( + parentJob?.title + || parentJob?.detected_title + || 'Serie' + ).trim() || 'Serie'; + const seasonNumber = normalizePositiveInteger( + assignment?.seasonNumber + ?? selectedTitle?.seasonNumber + ?? null + ); + const episodeRange = resolveSeriesEpisodeRangeFromAssignment( + assignment, + selectedTitle, + childIndex + 1, + { allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(assignment) } + ); + const fallbackEpisodeLabel = `Folge ${buildSeriesEpisodeRangeToken(episodeRange.start, episodeRange.end)}`; + const rawEpisodeLabel = String( + assignment?.episodeTitle + || selectedTitle?.episodeTitle + || fallbackEpisodeLabel + ).trim(); + const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange) || fallbackEpisodeLabel; + + const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null; + const episodeToken = buildSeriesEpisodeRangeToken( + episodeRange.start, + episodeRange.end + ); + const code = seasonToken && episodeToken ? `S${seasonToken}E${episodeToken}` : `Episode ${childIndex + 1}`; + + return `${baseTitle} - ${code}${episodeLabel ? ` - ${episodeLabel}` : ''}`; +} + +function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex, childCount) { + const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const selectedTitle = titles.find((title) => Number(title?.id) === Number(titleId)) || null; + const selectedTitleIds = [Number(titleId)]; + const handBrakeTitleId = normalizeReviewTitleId( + selectedTitle?.handBrakeTitleId + ?? selectedTitle?.titleIndex + ?? titleId + ); + const remappedTitles = titles.map((title) => { + const currentTitleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = currentTitleId !== null && Number(currentTitleId) === Number(titleId); + return { + ...title, + selectedForEncode, + encodeInput: selectedForEncode + }; + }); + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const selectedAssignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; + const inferredRangeForChild = resolveSeriesEpisodeRangeForPlanTitle(plan, titleId, childIndex + 1)?.range || null; + const selectedAssignmentHasRange = hasExplicitSeriesEpisodeAssignmentRange(selectedAssignment); + const resolvedChildAssignment = ( + selectedAssignmentHasRange + || !inferredRangeForChild + ) + ? selectedAssignment + : { + ...(selectedAssignment && typeof selectedAssignment === 'object' ? selectedAssignment : {}), + titleId: Number(titleId), + episodeNumber: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumber) ?? inferredRangeForChild.start, + episodeNumberStart: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberStart) ?? inferredRangeForChild.start, + episodeNumberEnd: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberEnd) ?? inferredRangeForChild.end, + episodeSpan: normalizePositiveInteger(selectedAssignment?.episodeSpan) + || Math.max(1, Math.trunc(Number(inferredRangeForChild.end) - Number(inferredRangeForChild.start) + 1)), + episodeRange: String(selectedAssignment?.episodeRange || '').trim() + || buildSeriesEpisodeRangeToken(inferredRangeForChild.start, inferredRangeForChild.end), + seasonNumber: normalizePositiveInteger(selectedAssignment?.seasonNumber) ?? normalizePositiveInteger(selectedTitle?.seasonNumber) ?? null + }; + + return { + ...plan, + reviewConfirmed: true, + reviewConfirmedAt: String(plan?.reviewConfirmedAt || '').trim() || nowIso(), + selectedTitleIds, + encodeInputTitleId: Number(titleId), + encodeInputPath: String( + selectedTitle?.filePath + || selectedTitle?.path + || plan?.encodeInputPath + || '' + ).trim() || null, + handBrakeTitleId: handBrakeTitleId || null, + handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [], + titleSelectionRequired: false, + titles: remappedTitles, + episodeAssignments: resolvedChildAssignment + ? { + [String(titleId)]: { + ...resolvedChildAssignment, + titleId: Number(titleId), + filePath: selectedTitle?.filePath || resolvedChildAssignment?.filePath || null + } + } + : {}, + seriesBatchParent: false, + seriesBatchChild: true, + seriesBatchParentJobId: Number(parentJobId), + seriesBatchChildIndex: Number(childIndex) + 1, + seriesBatchChildCount: Number(childCount) || 1, + seriesBatchTitleId: Number(titleId) + }; +} + +function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId, selectedEncodeTitleIds = null) { + const normalizedPrimaryTitleId = normalizeReviewTitleId(selectedEncodeTitleId); + const normalizedTitleIds = normalizeReviewTitleIdList([ + ...(Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds : []), + ...(normalizedPrimaryTitleId ? [normalizedPrimaryTitleId] : []) + ]); + if (normalizedTitleIds.length === 0) { + return { + plan: encodePlan, + selectedTitle: null, + selectedTitleIds: [] + }; + } + + const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : []; + const selectedTitleSet = new Set(normalizedTitleIds.map((id) => Number(id))); + const selectedTitles = []; + for (const selectedId of normalizedTitleIds) { + const selectedTitle = titles.find((item) => Number(item?.id) === selectedId) || null; + if (!selectedTitle) { + const error = new Error(`Gewählter Titel #${selectedId} ist nicht vorhanden.`); + error.statusCode = 400; + throw error; + } + const eligible = selectedTitle?.eligibleForEncode !== undefined + ? Boolean(selectedTitle.eligibleForEncode) + : Boolean(selectedTitle?.selectedByMinLength); + if (!eligible) { + const error = new Error(`Titel #${selectedId} ist laut MIN_LENGTH_MINUTES nicht encodierbar.`); + error.statusCode = 400; + throw error; + } + selectedTitles.push(selectedTitle); + } + const effectivePrimaryTitleId = normalizedPrimaryTitleId || normalizeReviewTitleId(selectedTitles[0]?.id); + const selectedTitle = titles.find((item) => Number(item?.id) === effectivePrimaryTitleId) || selectedTitles[0] || null; + + const remappedTitles = titles.map((title) => { + const titleId = Number(title?.id); + const isEncodeInput = titleId === effectivePrimaryTitleId; + const selectedForEncode = selectedTitleSet.has(titleId); + + const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { + const selectedByRule = Boolean(track?.selectedByRule); + const trackSelectedForEncode = selectedForEncode && selectedByRule; + const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : []; + const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen'; + + return { + ...track, + selectedForEncode: trackSelectedForEncode, + encodeActions: trackSelectedForEncode ? previewActions : [], + encodeActionSummary: trackSelectedForEncode ? previewSummary : 'Nicht übernommen' + }; + }); + + const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { + const selectedByRule = Boolean(track?.selectedByRule); + const trackSelectedForEncode = selectedForEncode && selectedByRule; + const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; + const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; + + return { + ...track, + selectedForEncode: trackSelectedForEncode, + burnIn: trackSelectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, + forced: trackSelectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, + forcedOnly: trackSelectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, + defaultTrack: trackSelectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, + flags: trackSelectedForEncode ? previewFlags : [], + subtitleActionSummary: trackSelectedForEncode ? previewSummary : 'Nicht übernommen' + }; + }); + + return { + ...title, + encodeInput: isEncodeInput, + selectedForEncode, + audioTracks, + subtitleTracks + }; + }); + + return { + plan: { + ...encodePlan, + titles: remappedTitles, + selectedTitleIds: normalizedTitleIds, + encodeInputTitleId: effectivePrimaryTitleId, + encodeInputPath: selectedTitle?.filePath || null, + handBrakeTitleId: normalizeReviewTitleId( + selectedTitle?.handBrakeTitleId + ?? selectedTitle?.titleIndex + ?? encodePlan?.handBrakeTitleId + ?? null + ), + selectedPlaylistId: normalizePlaylistId( + selectedTitle?.playlistId + || selectedTitle?.playlistFile + || encodePlan?.selectedPlaylistId + || null + ), + selectedMakemkvTitleId: normalizeNonNegativeInteger( + selectedTitle?.makemkvTitleId + ?? encodePlan?.selectedMakemkvTitleId + ?? null + ), + titleSelectionRequired: false + }, + selectedTitle, + selectedTitleIds: normalizedTitleIds + }; +} + +function normalizeTrackIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = Math.trunc(value); + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function normalizeTrackIdSequence(rawList, options = {}) { + const list = Array.isArray(rawList) ? rawList : []; + const dedupe = options?.dedupe !== false; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = Math.trunc(value); + const key = String(normalized); + if (dedupe && seen.has(key)) { + continue; + } + if (dedupe) { + seen.add(key); + } + output.push(normalized); + } + return output; +} + +function isBurnedSubtitleTrack(track) { + const previewFlags = Array.isArray(track?.subtitlePreviewFlags) + ? track.subtitlePreviewFlags + : (Array.isArray(track?.flags) ? track.flags : []); + const hasBurnedFlag = previewFlags.some((flag) => String(flag || '').trim().toLowerCase() === 'burned'); + const summary = `${track?.subtitlePreviewSummary || ''} ${track?.subtitleActionSummary || ''}`; + return Boolean( + track?.subtitlePreviewBurnIn + || track?.burnIn + || hasBurnedFlag + || /burned/i.test(summary) + ); +} + +function normalizeSubtitleVariantSelection(rawSelection) { + const map = new Map(); + const order = []; + const seenOrder = new Set(); + + const append = (rawLanguage, rawValue = null) => { + const language = normalizeTrackLanguage(rawLanguage); + if (!language) { + return; + } + + let full = false; + let forced = false; + if (typeof rawValue === 'string') { + const lowered = rawValue.trim().toLowerCase(); + if (lowered === 'both' || lowered === 'full+forced' || lowered === 'forced+full') { + full = true; + forced = true; + } else { + full = lowered.includes('full'); + forced = lowered.includes('forced'); + } + } else if (Array.isArray(rawValue)) { + const normalizedItems = rawValue.map((item) => String(item || '').trim().toLowerCase()); + full = normalizedItems.includes('full'); + forced = normalizedItems.includes('forced'); + } else { + full = Boolean(rawValue?.full); + forced = Boolean(rawValue?.forced); + } + + map.set(language, { full, forced }); + if (!seenOrder.has(language)) { + seenOrder.add(language); + order.push(language); + } + }; + + if (Array.isArray(rawSelection)) { + for (const entry of rawSelection) { + append(entry?.language ?? entry?.lang ?? entry?.code, entry); + } + } else if (rawSelection && typeof rawSelection === 'object') { + for (const [language, value] of Object.entries(rawSelection)) { + append(language, value); + } + } + + return { map, order }; +} + +function normalizeSubtitleLanguageOrder(rawOrder = []) { + const list = Array.isArray(rawOrder) ? rawOrder : []; + const output = []; + const seen = new Set(); + for (const item of list) { + const language = normalizeTrackLanguage(item); + if (!language || seen.has(language)) { + continue; + } + seen.add(language); + output.push(language); + } + return output; +} + +function normalizeSubtitleSelectionMode(value, fallback = 'variants') { + const mode = String(value || '').trim().toLowerCase(); + if (mode === 'track_ids' || mode === 'variants') { + return mode; + } + return fallback; +} + +function normalizeSubtitlePhysicalTrack(track, originalIndex = 0) { + const id = normalizeTrackIdList([track?.id ?? track?.sourceTrackId])[0] || null; + if (!id) { + return null; + } + + const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); + const explicitForcedOnly = Boolean( + track?.isForcedOnly + ?? track?.subtitlePreviewForcedOnly + ?? track?.forcedOnly + ?? track?.forcedTrackOnly + ); + const subtitleType = String(track?.subtitleType || '').trim().toLowerCase(); + const isForcedOnly = explicitForcedOnly || subtitleType === 'forced'; + const fullHasForced = !isForcedOnly && Boolean( + track?.fullHasForced + ?? track?.subtitleFullHasForced + ?? track?.hasForcedVariant + ?? track?.fullTrackHasForced + ); + const confidence = normalizeSubtitleConfidence(track?.sourceConfidence || track?.confidence || 'low'); + + return { + id, + language, + defaultFlag: parseSubtitleDefaultFlag(track), + confidence, + isForcedOnly, + fullHasForced, + selectedForEncode: Boolean(track?.selectedForEncode), + originalIndex + }; +} + +function compareSubtitleForcedCandidatePriority(a, b) { + const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); + if (defaultDiff !== 0) { + return defaultDiff; + } + const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence); + if (confidenceDiff !== 0) { + return confidenceDiff; + } + if (a.id !== b.id) { + return a.id - b.id; + } + return a.originalIndex - b.originalIndex; +} + +function compareSubtitleFullCandidatePriority(a, b) { + const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); + if (defaultDiff !== 0) { + return defaultDiff; + } + if (a.id !== b.id) { + return a.id - b.id; + } + return a.originalIndex - b.originalIndex; +} + +function buildSubtitleVariantSelectionFromTrackIds(subtitleTracks, selectedTrackIds = []) { + const requestedTrackIds = new Set(normalizeTrackIdList(selectedTrackIds).map(String)); + const candidates = (Array.isArray(subtitleTracks) ? subtitleTracks : []) + .map((track, index) => normalizeSubtitlePhysicalTrack(track, index)) + .filter(Boolean) + .filter((track) => !requestedTrackIds.size || requestedTrackIds.has(String(track.id))); + + const map = new Map(); + const order = []; + const seenOrder = new Set(); + for (const track of candidates) { + const current = map.get(track.language) || { full: false, forced: false }; + if (track.isForcedOnly) { + current.forced = true; + } else { + current.full = true; + } + map.set(track.language, current); + if (!seenOrder.has(track.language)) { + seenOrder.add(track.language); + order.push(track.language); + } + } + return { map, order }; +} + +function buildEmptySubtitleVariantSelectionForAllLanguages(subtitleTracks) { + const map = new Map(); + const order = []; + const seen = new Set(); + const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : []; + for (const track of tracks) { + if (isBurnedSubtitleTrack(track) || Boolean(track?.duplicate)) { + continue; + } + const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); + if (!language || seen.has(language)) { + continue; + } + seen.add(language); + order.push(language); + map.set(language, { full: false, forced: false }); + } + return { map, order }; +} + +function resolveDeterministicSubtitleSelection({ + subtitleTracks = [], + subtitleVariantSelection = null, + subtitleLanguageOrder = [], + fallbackSelectedSubtitleTrackIds = [], + fallbackSelectedSubtitleTrackIdsOrdered = [], + hasExplicitVariantSelection = false +} = {}) { + const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : []) + .map((track, index) => ({ track, normalized: normalizeSubtitlePhysicalTrack(track, index) })) + .filter((entry) => Boolean(entry.normalized)) + .filter((entry) => !isBurnedSubtitleTrack(entry.track) && !Boolean(entry.track?.duplicate)) + .map((entry) => entry.normalized); + + const groupedByLanguage = new Map(); + for (const track of tracks) { + if (!groupedByLanguage.has(track.language)) { + groupedByLanguage.set(track.language, []); + } + groupedByLanguage.get(track.language).push(track); + } + + const fallbackSelectionSet = new Set(normalizeTrackIdList(fallbackSelectedSubtitleTrackIds).map(String)); + const explicitSelection = normalizeSubtitleVariantSelection(subtitleVariantSelection); + const explicitOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder); + const fallbackSelectionFromTrackIds = buildSubtitleVariantSelectionFromTrackIds( + subtitleTracks, + fallbackSelectedSubtitleTrackIds + ); + const fallbackSelectionFromOrderedTrackIds = buildSubtitleVariantSelectionFromTrackIds( + subtitleTracks, + normalizeTrackIdSequence(fallbackSelectedSubtitleTrackIdsOrdered, { dedupe: false }) + ); + + const availableLanguagesSorted = Array.from(groupedByLanguage.entries()) + .map(([language, languageTracks]) => { + const minTrackId = languageTracks.reduce((minValue, track) => Math.min(minValue, track.id), Number.MAX_SAFE_INTEGER); + return { language, minTrackId }; + }) + .sort((a, b) => a.minTrackId - b.minTrackId || a.language.localeCompare(b.language)) + .map((item) => item.language); + + const languageOrder = []; + const seenLanguages = new Set(); + const pushLanguage = (language) => { + if (!groupedByLanguage.has(language) || seenLanguages.has(language)) { + return; + } + seenLanguages.add(language); + languageOrder.push(language); + }; + + for (const language of explicitOrder) { + pushLanguage(language); + } + if (!hasExplicitVariantSelection) { + for (const language of explicitSelection.order) { + pushLanguage(language); + } + for (const language of fallbackSelectionFromOrderedTrackIds.order) { + pushLanguage(language); + } + for (const language of fallbackSelectionFromTrackIds.order) { + pushLanguage(language); + } + } + for (const language of availableLanguagesSorted) { + pushLanguage(language); + } + + const subtitleTrackIdsOrdered = []; + const subtitleForcedSelectionIndexes = []; + const selectedPhysicalTrackIds = []; + const selectedPhysicalTrackSet = new Set(); + const validationErrors = []; + const normalizedVariantSelection = {}; + + const appendTrackId = (trackId, options = {}) => { + subtitleTrackIdsOrdered.push(trackId); + const position = subtitleTrackIdsOrdered.length; + if (options?.forcedIndex) { + subtitleForcedSelectionIndexes.push(position); + } + const key = String(trackId); + if (!selectedPhysicalTrackSet.has(key)) { + selectedPhysicalTrackSet.add(key); + selectedPhysicalTrackIds.push(trackId); + } + }; + + for (const language of languageOrder) { + const languageTracks = groupedByLanguage.get(language) || []; + const forcedOnlyCandidates = languageTracks + .filter((track) => track.isForcedOnly) + .sort(compareSubtitleForcedCandidatePriority); + const fullCandidates = languageTracks + .filter((track) => !track.isForcedOnly) + .sort(compareSubtitleFullCandidatePriority); + const fullHasForcedCandidates = fullCandidates + .filter((track) => track.fullHasForced) + .sort(compareSubtitleFullCandidatePriority); + + const bestForcedOnlyTrack = forcedOnlyCandidates[0] || null; + const bestFullTrack = fullCandidates[0] || null; + const bestFullHasForcedTrack = fullHasForcedCandidates[0] || null; + + const explicitEntry = explicitSelection.map.get(language) || null; + const fallbackEntryFromTrackIds = fallbackSelectionFromTrackIds.map.get(language) || null; + const fallbackEntryFromOrderedTrackIds = fallbackSelectionFromOrderedTrackIds.map.get(language) || null; + const hasAnyFallbackSelectedTrack = languageTracks.some((track) => fallbackSelectionSet.has(String(track.id)) || track.selectedForEncode); + + let requestedFull = false; + let requestedForced = false; + if (explicitEntry) { + requestedFull = Boolean(explicitEntry.full); + requestedForced = Boolean(explicitEntry.forced); + } else if (hasExplicitVariantSelection) { + requestedFull = false; + requestedForced = false; + } else if (fallbackEntryFromOrderedTrackIds) { + requestedFull = Boolean(fallbackEntryFromOrderedTrackIds.full); + requestedForced = Boolean(fallbackEntryFromOrderedTrackIds.forced); + } else if (fallbackEntryFromTrackIds) { + requestedFull = Boolean(fallbackEntryFromTrackIds.full); + requestedForced = Boolean(fallbackEntryFromTrackIds.forced); + } else if (hasAnyFallbackSelectedTrack) { + requestedFull = Boolean(bestFullTrack); + requestedForced = Boolean(bestForcedOnlyTrack); + } else { + requestedFull = Boolean(bestFullTrack); + requestedForced = Boolean(bestForcedOnlyTrack); + } + + normalizedVariantSelection[language] = { + full: requestedFull, + forced: requestedForced + }; + + const languageActive = requestedFull || requestedForced; + if (!languageActive) { + continue; + } + + if (bestForcedOnlyTrack) { + // Case A: forced-only track exists -> forced entry is always included. + appendTrackId(bestForcedOnlyTrack.id); + if (requestedFull && bestFullTrack) { + appendTrackId(bestFullTrack.id); + } + continue; + } + + if (bestFullHasForcedTrack) { + // Case B: no forced-only track, but full track contains forced content. + if (requestedFull && requestedForced) { + appendTrackId(bestFullHasForcedTrack.id); + appendTrackId(bestFullHasForcedTrack.id, { forcedIndex: true }); + } else if (requestedForced) { + appendTrackId(bestFullHasForcedTrack.id, { forcedIndex: true }); + } else if (requestedFull) { + appendTrackId(bestFullHasForcedTrack.id); + } + continue; + } + + // Case C: full-only track available, forced request is invalid. + if (requestedForced && !requestedFull) { + validationErrors.push(`Sprache ${language}: Forced wurde gewählt, aber es gibt keinen forced-only oder fullHasForced Track.`); + continue; + } + if (requestedFull && bestFullTrack) { + appendTrackId(bestFullTrack.id); + continue; + } + if (!bestFullTrack) { + validationErrors.push(`Sprache ${language}: Kein gültiger Full-Track verfügbar.`); + } + } + + const invalidForcedIndexes = subtitleForcedSelectionIndexes.filter((index) => index <= 0 || index > subtitleTrackIdsOrdered.length); + if (invalidForcedIndexes.length > 0) { + validationErrors.push(`Ungültige --subtitle-forced Indizes: ${invalidForcedIndexes.join(',')}`); + } + + return { + subtitleTrackIdsOrdered, + subtitleForcedSelectionIndexes, + selectedPhysicalTrackIds, + subtitleVariantSelection: normalizedVariantSelection, + subtitleLanguageOrder: languageOrder, + validationErrors + }; +} + +function normalizeScriptIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = Math.trunc(value); + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null; + if (!plan || !Array.isArray(plan.titles)) { + return { + plan: encodePlan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] + }; + } + + const encodeInputTitleId = normalizeReviewTitleId(plan.encodeInputTitleId); + if (!encodeInputTitleId) { + return { + plan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] + }; + } + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(plan?.selectedTitleIds) + ? plan.selectedTitleIds + : [encodeInputTitleId] + ); + const selectedTitleIdSet = new Set( + (selectedTitleIds.length > 0 ? selectedTitleIds : [encodeInputTitleId]).map((id) => Number(id)) + ); + + const selectionPayload = selectedTrackSelection && typeof selectedTrackSelection === 'object' + ? selectedTrackSelection + : null; + if (!selectionPayload) { + return { + plan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] + }; + } + + const rawSelection = selectionPayload[encodeInputTitleId] + || selectionPayload[String(encodeInputTitleId)] + || selectionPayload; + if (!rawSelection || typeof rawSelection !== 'object') { + return { + plan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] + }; + } + + const encodeTitle = plan.titles.find((title) => Number(title?.id) === encodeInputTitleId) || null; + if (!encodeTitle) { + return { + plan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] + }; + } + + const validAudioTrackIds = new Set( + (Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : []) + .map((track) => Number(track?.id)) + .filter((id) => Number.isFinite(id)) + .map((id) => Math.trunc(id)) + ); + const validSubtitleTrackIds = new Set( + (Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : []) + .filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) + .map((track) => Number(track?.id)) + .filter((id) => Number.isFinite(id)) + .map((id) => Math.trunc(id)) + ); + + const requestedAudioTrackIds = normalizeTrackIdList(rawSelection.audioTrackIds) + .filter((id) => validAudioTrackIds.has(id)); + const requestedSubtitleTrackIds = normalizeTrackIdList(rawSelection.subtitleTrackIds) + .filter((id) => validSubtitleTrackIds.has(id)); + const requestedSubtitleTrackIdsOrdered = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false }) + .filter((id) => validSubtitleTrackIds.has(id)); + + const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); + const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); + const requestedSubtitleSelectionMode = normalizeSubtitleSelectionMode( + rawSelection.subtitleSelectionMode, + hasRawSubtitleVariantSelection ? 'variants' : (hasRawSubtitleTrackIds ? 'track_ids' : 'variants') + ); + const explicitSubtitleVariants = requestedSubtitleSelectionMode === 'variants' + ? normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection) + : { map: new Map(), order: [] }; + const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder); + let effectiveSubtitleVariantSelection = explicitSubtitleVariants; + let forceExplicitEmptySubtitleSelection = false; + + if (requestedSubtitleSelectionMode === 'track_ids') { + if (requestedSubtitleTrackIds.length > 0) { + effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds( + encodeTitle.subtitleTracks, + requestedSubtitleTrackIds + ); + } else { + // User provided subtitleTrackIds explicitly, but none match this title. + // Treat this as an explicit empty selection for this title and do not + // fall back to auto-selected subtitle flags (which could include all tracks). + effectiveSubtitleVariantSelection = buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); + forceExplicitEmptySubtitleSelection = true; + } + } else if (effectiveSubtitleVariantSelection.map.size === 0 && hasRawSubtitleTrackIds) { + if (requestedSubtitleTrackIds.length > 0) { + effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds( + encodeTitle.subtitleTracks, + requestedSubtitleTrackIds + ); + } else { + effectiveSubtitleVariantSelection = buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); + forceExplicitEmptySubtitleSelection = true; + } + } + + const hasExplicitSubtitleVariantSelection = ( + requestedSubtitleSelectionMode === 'track_ids' + || hasRawSubtitleVariantSelection + || forceExplicitEmptySubtitleSelection + || (hasRawSubtitleTrackIds && effectiveSubtitleVariantSelection.map.size > 0) + ); + + const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ + subtitleTracks: encodeTitle.subtitleTracks, + subtitleVariantSelection: Object.fromEntries(effectiveSubtitleVariantSelection.map.entries()), + subtitleLanguageOrder: explicitSubtitleLanguageOrder.length > 0 + ? explicitSubtitleLanguageOrder + : effectiveSubtitleVariantSelection.order, + fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds, + fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered, + hasExplicitVariantSelection: hasExplicitSubtitleVariantSelection + }); + + const audioSelectionSet = new Set(requestedAudioTrackIds.map((id) => String(id))); + const subtitleSelectionSet = new Set(resolvedSubtitleSelection.selectedPhysicalTrackIds.map((id) => String(id))); + + const remappedTitles = plan.titles.map((title) => { + const titleId = Number(title?.id); + const isEncodeInput = titleId === encodeInputTitleId; + const keepSelectedForEncode = selectedTitleIdSet.has(titleId); + + const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { + const trackId = Number(track?.id); + const selectedForEncode = isEncodeInput + ? audioSelectionSet.has(String(Math.trunc(trackId))) + : (keepSelectedForEncode ? Boolean(track?.selectedForEncode) : false); + const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : []; + const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen'; + return { + ...track, + selectedForEncode, + encodeActions: selectedForEncode ? previewActions : [], + encodeActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' + }; + }); + + const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { + const trackId = Number(track?.id); + const selectedForEncode = isEncodeInput + ? subtitleSelectionSet.has(String(Math.trunc(trackId))) + : (keepSelectedForEncode ? Boolean(track?.selectedForEncode) : false); + const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; + const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; + return { + ...track, + selectedForEncode, + burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, + forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, + forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, + defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, + flags: selectedForEncode ? previewFlags : [], + subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' + }; + }); + + return { + ...title, + encodeInput: isEncodeInput, + selectedForEncode: keepSelectedForEncode, + audioTracks, + subtitleTracks + }; + }); + + const currentManualSelection = { + titleId: encodeInputTitleId, + subtitleSelectionMode: requestedSubtitleSelectionMode, + audioTrackIds: requestedAudioTrackIds, + subtitleTrackIds: resolvedSubtitleSelection.selectedPhysicalTrackIds, + subtitleTrackIdsOrdered: resolvedSubtitleSelection.subtitleTrackIdsOrdered, + subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, + subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection, + subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder, + subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors, + updatedAt: nowIso() + }; + const existingManualTrackSelectionByTitle = plan?.manualTrackSelectionByTitle + && typeof plan.manualTrackSelectionByTitle === 'object' + ? plan.manualTrackSelectionByTitle + : {}; + const manualTrackSelectionByTitle = { + ...existingManualTrackSelectionByTitle, + [encodeInputTitleId]: currentManualSelection + }; + + return { + plan: { + ...plan, + titles: remappedTitles, + manualTrackSelection: currentManualSelection, + manualTrackSelectionByTitle + }, + selectionApplied: true, + audioTrackIds: requestedAudioTrackIds, + subtitleTrackIds: resolvedSubtitleSelection.subtitleTrackIdsOrdered, + subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, + subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors + }; +} + +function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null; + if (!plan || !Array.isArray(plan.titles)) { + return null; + } + + const encodeInputTitleId = normalizeReviewTitleId(plan.encodeInputTitleId); + let encodeTitle = null; + + if (encodeInputTitleId) { + encodeTitle = plan.titles.find((title) => Number(title?.id) === encodeInputTitleId) || null; + } + if (!encodeTitle && inputPath) { + encodeTitle = plan.titles.find((title) => String(title?.filePath || '') === String(inputPath || '')) || null; + } + + if (!encodeTitle) { + return null; + } + + const allAudioTracks = Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : []; + const allSubtitleTracks = Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : []; + + // manualTrackSelection stores validated track?.id values written by applyManualTrackSelectionToPlan. + // Use it as the authoritative source for audio/subtitle IDs. Fall back to the selectedForEncode + // flags on the track objects when the field is absent (e.g. auto-selected plans without user review). + // Always resolve through track?.id – never sourceTrackId, which may hold MakeMKV stream IDs + // (e.g. 189) that have no relation to HandBrake's 1-indexed track positions. + const manualSelectionByTitle = plan?.manualTrackSelectionByTitle + && typeof plan.manualTrackSelectionByTitle === 'object' + ? plan.manualTrackSelectionByTitle + : {}; + const manualSelectionForTitle = encodeInputTitleId + ? ( + manualSelectionByTitle[encodeInputTitleId] + || manualSelectionByTitle[String(encodeInputTitleId)] + || null + ) + : null; + const manualSelection = ( + manualSelectionForTitle && typeof manualSelectionForTitle === 'object' + ? manualSelectionForTitle + : ( + plan.manualTrackSelection && typeof plan.manualTrackSelection === 'object' + ? plan.manualTrackSelection + : null + ) + ); + const manualTitleId = normalizeReviewTitleId(manualSelection?.titleId); + const manualMatchesTitle = !manualTitleId || !encodeInputTitleId || manualTitleId === encodeInputTitleId; + + const audioTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.audioTrackIds)) + ? normalizeTrackIdList(manualSelection.audioTrackIds) + : normalizeTrackIdList(allAudioTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id)); + + const fallbackSubtitleTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIds)) + ? normalizeTrackIdList(manualSelection.subtitleTrackIds) + : normalizeTrackIdList(allSubtitleTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id)); + const fallbackSubtitleTrackIdsOrdered = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIdsOrdered)) + ? normalizeTrackIdSequence(manualSelection.subtitleTrackIdsOrdered, { dedupe: false }) + : []; + const manualSubtitleVariantSelection = manualMatchesTitle && manualSelection?.subtitleVariantSelection + ? manualSelection.subtitleVariantSelection + : null; + const hasManualSubtitleTrackIds = Boolean( + manualMatchesTitle + && manualSelection + && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleTrackIds') + ); + const hasManualSubtitleVariantSelection = Boolean( + manualMatchesTitle + && manualSelection + && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleVariantSelection') + ); + const manualSubtitleSelectionMode = normalizeSubtitleSelectionMode( + manualMatchesTitle ? manualSelection?.subtitleSelectionMode : null, + hasManualSubtitleVariantSelection ? 'variants' : (hasManualSubtitleTrackIds ? 'track_ids' : 'variants') + ); + const hasExplicitManualSubtitleSelection = Boolean( + manualSubtitleSelectionMode === 'track_ids' + || hasManualSubtitleVariantSelection + || hasManualSubtitleTrackIds + ); + const manualSubtitleLanguageOrder = manualMatchesTitle && Array.isArray(manualSelection?.subtitleLanguageOrder) + ? manualSelection.subtitleLanguageOrder + : []; + + const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ + subtitleTracks: allSubtitleTracks, + subtitleVariantSelection: manualSubtitleVariantSelection, + subtitleLanguageOrder: manualSubtitleLanguageOrder, + fallbackSelectedSubtitleTrackIds: fallbackSubtitleTrackIds, + fallbackSelectedSubtitleTrackIdsOrdered: fallbackSubtitleTrackIdsOrdered, + hasExplicitVariantSelection: hasExplicitManualSubtitleSelection + }); + + const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIdsOrdered.length > 0 + ? resolvedSubtitleSelection.subtitleTrackIdsOrdered + : (hasExplicitManualSubtitleSelection + ? [] + : (fallbackSubtitleTrackIdsOrdered.length > 0 + ? fallbackSubtitleTrackIdsOrdered + : fallbackSubtitleTrackIds)); + const subtitleForcedTrackIndexes = resolvedSubtitleSelection.subtitleForcedSelectionIndexes; + + // Resolve burn/default/forced attributes from the actual track objects, matched by track?.id. + const subtitleTrackIdSet = new Set(normalizeTrackIdList(subtitleTrackIds).map(String)); + const selectedSubtitleTracks = allSubtitleTracks.filter((t) => { + const tid = normalizeTrackIdList([t?.id])[0]; + return tid !== undefined && subtitleTrackIdSet.has(String(tid)); + }); + + const subtitleBurnTrackId = normalizeTrackIdList( + selectedSubtitleTracks.filter((track) => Boolean(track?.burnIn)).map((track) => track?.id) + )[0] || null; + const subtitleDefaultTrackId = normalizeTrackIdList( + selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.id) + )[0] || null; + const subtitleForcedTrackId = subtitleForcedTrackIndexes.length === 1 + ? (subtitleTrackIds[subtitleForcedTrackIndexes[0] - 1] || null) + : null; + const subtitleForcedOnly = false; + + return { + titleId: Number(encodeTitle?.id) || null, + subtitleSelectionMode: manualSubtitleSelectionMode, + audioTrackIds, + subtitleTrackIds, + subtitleForcedTrackIndexes, + subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection, + subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder, + subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors, + subtitleBurnTrackId, + subtitleDefaultTrackId, + subtitleForcedTrackId, + subtitleForcedOnly + }; +} + +function buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId = null) { + const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null; + if (!analysis) { + return new Set(); + } + + const segmentMap = analysis.playlistSegments && typeof analysis.playlistSegments === 'object' + ? analysis.playlistSegments + : {}; + + const set = new Set(); + const appendSegments = (playlistIdRaw) => { + const playlistId = normalizePlaylistId(playlistIdRaw); + if (!playlistId) { + return; + } + const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; + const segmentFiles = Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles : []; + for (const file of segmentFiles) { + const name = path.basename(String(file || '').trim()).toLowerCase(); + if (!name) { + continue; + } + set.add(name); + } + }; + + if (selectedPlaylistId) { + appendSegments(selectedPlaylistId); + return set; + } + + appendSegments(analysis?.recommendation?.playlistId || null); + if (set.size > 0) { + return set; + } + + const candidates = Array.isArray(analysis.evaluatedCandidates) ? analysis.evaluatedCandidates : []; + for (const candidate of candidates) { + appendSegments(candidate?.playlistId || null); + } + return set; +} + + +function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedPlaylistId = null } = {}) { + const sourcePath = String(rawPath || '').trim(); + if (!sourcePath) { + return { + mediaFiles: [], + source: 'none' + }; + } + + try { + const sourceStat = fs.statSync(sourcePath); + if (sourceStat.isFile()) { + const ext = path.extname(sourcePath).toLowerCase(); + const supportedSingleFileExts = new Set([ + '.aax', '.m4b', '.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts', '.flv', '.webm', '.iso' + ]); + if ( + supportedSingleFileExts.has(ext) + || isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size) + ) { + return { + mediaFiles: [{ path: sourcePath, size: Number(sourceStat.size || 0) }], + source: ext === '' ? 'single_extensionless' : 'single_file' + }; + } + return { + mediaFiles: [], + source: 'none' + }; + } + } catch (_error) { + return { + mediaFiles: [], + source: 'none' + }; + } + + const topLevelExtensionlessImages = listTopLevelExtensionlessDvdImages(sourcePath); + if (topLevelExtensionlessImages.length > 0) { + return { + mediaFiles: topLevelExtensionlessImages, + source: 'dvd_image' + }; + } + + const primary = findMediaFiles(sourcePath, [ + '.aax', '.m4b', '.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts', '.flv', '.webm', '.iso' + ]); + if (primary.length > 0) { + return { + mediaFiles: primary, + source: path.extname(primary[0]?.path || '').toLowerCase() === '.aax' ? 'audiobook' : 'mkv' + }; + } + + const streamDir = path.join(sourcePath, 'BDMV', 'STREAM'); + const backupRoot = fs.existsSync(streamDir) ? streamDir : sourcePath; + let backupFiles = findMediaFiles(backupRoot, ['.m2ts']); + if (backupFiles.length === 0) { + const vobFiles = findMediaFiles(sourcePath, ['.vob']); + if (vobFiles.length > 0) { + return { + mediaFiles: vobFiles, + source: 'dvd' + }; + } + return { + mediaFiles: [], + source: 'none' + }; + } + + const allowedSegments = buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId); + if (allowedSegments.size > 0) { + const filtered = backupFiles.filter((file) => allowedSegments.has(path.basename(file.path).toLowerCase())); + if (filtered.length > 0) { + backupFiles = filtered; + } + } + + return { + mediaFiles: backupFiles, + source: 'backup' + }; +} + +function resolveDvdScanInputPathFromRaw(rawPath, rawMedia = null) { + const sourcePath = String(rawPath || '').trim(); + const media = rawMedia && typeof rawMedia === 'object' + ? rawMedia + : collectRawMediaCandidates(sourcePath); + const mediaFiles = Array.isArray(media?.mediaFiles) ? media.mediaFiles : []; + const mediaSource = String(media?.source || '').trim().toLowerCase(); + + if (mediaFiles.length > 0 && mediaSource === 'dvd') { + // mediaFiles[0] points to ".../VIDEO_TS/VTS_XX_*.VOB" -> scan parent folder that contains VIDEO_TS. + const firstPath = String(mediaFiles[0]?.path || '').trim(); + if (firstPath) { + return path.dirname(path.dirname(firstPath)); + } + } + if ( + mediaFiles.length > 0 + && (mediaSource === 'dvd_image' || mediaSource === 'single_extensionless' || mediaSource === 'single_file') + ) { + return String(mediaFiles[0]?.path || '').trim() || sourcePath || null; + } + + return sourcePath || null; +} + +function hasBluRayBackupStructure(rawPath) { + if (!rawPath) { + return false; + } + + const bdmvDir = path.join(rawPath, 'BDMV'); + const streamDir = path.join(bdmvDir, 'STREAM'); + + try { + return fs.existsSync(bdmvDir) && fs.existsSync(streamDir); + } catch (_error) { + return false; + } +} + +function findPreferredRawInput(rawPath, options = {}) { + const { mediaFiles } = collectRawMediaCandidates(rawPath, options); + if (!Array.isArray(mediaFiles) || mediaFiles.length === 0) { + return null; + } + return mediaFiles[0]; +} + +function extractManualSelectionPayloadFromPlan(encodePlan) { + const selection = extractHandBrakeTrackSelectionFromPlan(encodePlan); + if (!selection) { + return null; + } + const normalizedTitleId = normalizeReviewTitleId(selection.titleId ?? encodePlan?.encodeInputTitleId); + return { + titleId: normalizedTitleId || null, + subtitleSelectionMode: normalizeSubtitleSelectionMode( + selection.subtitleSelectionMode, + selection.subtitleVariantSelection ? 'variants' : 'track_ids' + ), + audioTrackIds: normalizeTrackIdList(selection.audioTrackIds), + subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds), + subtitleTrackIdsOrdered: normalizeTrackIdSequence(selection.subtitleTrackIds, { dedupe: false }), + subtitleForcedTrackIndexes: normalizeTrackIdList(selection.subtitleForcedTrackIndexes), + subtitleVariantSelection: selection.subtitleVariantSelection || null, + subtitleLanguageOrder: Array.isArray(selection.subtitleLanguageOrder) ? selection.subtitleLanguageOrder : [] + }; +} + +function normalizeChainIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = Math.trunc(value); + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function normalizeUserPresetForPlan(rawPreset) { + if (!rawPreset || typeof rawPreset !== 'object') { + return null; + } + const rawId = Number(rawPreset.id); + const presetId = Number.isFinite(rawId) && rawId > 0 ? Math.trunc(rawId) : null; + const name = String(rawPreset.name || '').trim(); + const handbrakePreset = String(rawPreset.handbrakePreset || '').trim(); + const extraArgs = String(rawPreset.extraArgs || '').trim(); + if (!presetId && !name && !handbrakePreset && !extraArgs) { + return null; + } + return { + id: presetId, + name: name || (presetId ? `Preset #${presetId}` : 'User-Preset'), + handbrakePreset: handbrakePreset || null, + extraArgs: extraArgs || null + }; +} + +function resolveUserPresetDefaultSlotKey(mediaProfile, isSeriesSelection = false) { + const normalizedProfile = normalizeMediaProfile(mediaProfile); + if (normalizedProfile !== 'dvd' && normalizedProfile !== 'bluray') { + return null; + } + return `${normalizedProfile}_${isSeriesSelection ? 'series' : 'movie'}`; +} + +async function applyConfiguredDefaultUserPresetToReviewPlan(reviewPlan, options = {}) { + const plan = reviewPlan && typeof reviewPlan === 'object' + ? reviewPlan + : null; + if (!plan) { + return { + plan: reviewPlan, + applied: false, + slotKey: null, + presetId: null, + presetName: null + }; + } + + const existingUserPreset = normalizeUserPresetForPlan(plan?.userPreset || null); + if (existingUserPreset) { + return { + plan, + applied: false, + slotKey: null, + presetId: existingUserPreset.id || null, + presetName: existingUserPreset.name || null + }; + } + + const slotKey = resolveUserPresetDefaultSlotKey(options?.mediaProfile || null, Boolean(options?.isSeriesSelection)); + if (!slotKey) { + return { + plan, + applied: false, + slotKey: null, + presetId: null, + presetName: null + }; + } + + try { + const defaultsMap = await userPresetDefaultsService.listDefaults(); + const rawPresetId = defaultsMap && typeof defaultsMap === 'object' + ? defaultsMap[slotKey] + : null; + const presetId = normalizePositiveInteger(rawPresetId); + if (!presetId) { + return { + plan, + applied: false, + slotKey, + presetId: null, + presetName: null + }; + } + + const preset = await userPresetService.getPresetById(presetId); + const normalizedPreset = normalizeUserPresetForPlan(preset); + if (!normalizedPreset) { + return { + plan, + applied: false, + slotKey, + presetId, + presetName: null + }; + } + + const presetMediaType = String(preset?.mediaType || '').trim().toLowerCase(); + const normalizedProfile = normalizeMediaProfile(options?.mediaProfile || null); + const mediaTypeAllowed = presetMediaType === 'all' + || !presetMediaType + || presetMediaType === normalizedProfile; + if (!mediaTypeAllowed) { + return { + plan, + applied: false, + slotKey, + presetId, + presetName: normalizedPreset.name || null + }; + } + + return { + plan: { + ...plan, + userPreset: normalizedPreset + }, + applied: true, + slotKey, + presetId: normalizedPreset.id || presetId || null, + presetName: normalizedPreset.name || null + }; + } catch (error) { + logger.warn('review:user-preset-default:resolve-failed', { + mediaProfile: options?.mediaProfile || null, + isSeriesSelection: Boolean(options?.isSeriesSelection), + error: errorToMeta(error) + }); + return { + plan, + applied: false, + slotKey, + presetId: null, + presetName: null + }; + } +} + +function buildScriptDescriptorList(scriptIds, sourceScripts = []) { + const normalizedIds = normalizeScriptIdList(scriptIds); + if (normalizedIds.length === 0) { + return []; + } + const source = Array.isArray(sourceScripts) ? sourceScripts : []; + const namesById = new Map( + source + .map((item) => { + const id = Number(item?.id ?? item?.scriptId); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + const name = String(item?.name || '').trim(); + if (!normalizedId || !name) { + return null; + } + return [normalizedId, name]; + }) + .filter(Boolean) + ); + return normalizedIds.map((id) => ({ + id, + name: namesById.get(id) || `Skript #${id}` + })); +} + +function findSelectedTitleInPlan(encodePlan) { + if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) { + return null; + } + const preferredTitleId = normalizeReviewTitleId(encodePlan.encodeInputTitleId); + if (preferredTitleId) { + const byId = encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === preferredTitleId) || null; + if (byId) { + return byId; + } + } + return encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null; +} + +function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) { + const reviewTitles = Array.isArray(reviewPlan?.titles) ? reviewPlan.titles : []; + if (reviewTitles.length === 0) { + return null; + } + if (Boolean(reviewPlan?.alternativeTitleSelection?.enabled)) { + return null; + } + + const previousSelectedTitle = findSelectedTitleInPlan(previousPlan); + if (!previousSelectedTitle) { + return null; + } + + const previousPlaylistId = normalizePlaylistId( + previousSelectedTitle?.playlistId + || previousPlan?.selectedPlaylistId + || null + ); + if (previousPlaylistId) { + const byPlaylist = reviewTitles.find((title) => normalizePlaylistId(title?.playlistId) === previousPlaylistId) || null; + const id = normalizeReviewTitleId(byPlaylist?.id); + if (id) { + return id; + } + } + + const previousMakemkvTitleId = normalizeNonNegativeInteger( + previousSelectedTitle?.makemkvTitleId + ?? previousPlan?.selectedMakemkvTitleId + ?? null + ); + if (previousMakemkvTitleId !== null) { + const byMakemkvTitleId = reviewTitles.find((title) => ( + normalizeNonNegativeInteger(title?.makemkvTitleId) === previousMakemkvTitleId + )) || null; + const id = normalizeReviewTitleId(byMakemkvTitleId?.id); + if (id) { + return id; + } + } + + const previousFileName = path.basename( + String(previousSelectedTitle?.filePath || previousSelectedTitle?.fileName || '').trim() + ).toLowerCase(); + if (previousFileName) { + const byFileName = reviewTitles.find((title) => { + const candidate = path.basename( + String(title?.filePath || title?.fileName || '').trim() + ).toLowerCase(); + return candidate && candidate === previousFileName; + }) || null; + const id = normalizeReviewTitleId(byFileName?.id); + if (id) { + return id; + } + } + + const previousTitleId = normalizeReviewTitleId(previousPlan?.encodeInputTitleId); + if (!previousTitleId) { + return null; + } + const fallback = reviewTitles.find((title) => normalizeReviewTitleId(title?.id) === previousTitleId) || null; + return normalizeReviewTitleId(fallback?.id); +} + +function normalizeTrackSignatureText(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +function resolveSubtitleForcedOnlyForSignature(track) { + const subtitleType = String(track?.subtitleType || '').trim().toLowerCase(); + return Boolean( + track?.isForcedOnly + ?? track?.subtitlePreviewForcedOnly + ?? track?.forcedOnly + ?? track?.forcedTrackOnly + ?? subtitleType === 'forced' + ); +} + +function resolveSubtitleFullHasForcedForSignature(track) { + return Boolean( + track?.fullHasForced + ?? track?.subtitleFullHasForced + ?? track?.hasForcedVariant + ?? track?.fullTrackHasForced + ?? false + ); +} + +function buildAudioTrackSignature(track) { + return [ + normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'), + normalizeTrackSignatureText(track?.format || track?.codecName || track?.codec || ''), + normalizeTrackSignatureText(track?.channels || track?.channelCount || ''), + normalizeTrackSignatureText(track?.channelLayout || ''), + normalizeTrackSignatureText(track?.title || track?.description || '') + ].join('|'); +} + +function buildSubtitleTrackSignature(track) { + return [ + normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'), + normalizeTrackSignatureText(track?.format || track?.codecName || track?.codec || ''), + resolveSubtitleForcedOnlyForSignature(track) ? 'forced' : 'full', + resolveSubtitleFullHasForcedForSignature(track) ? 'full_has_forced' : 'plain' + ].join('|'); +} + +function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, options = {}) { + const excludeBurned = Boolean(options?.excludeBurned); + const kind = String(options?.kind || 'audio').trim().toLowerCase() === 'subtitle' + ? 'subtitle' + : 'audio'; + const sourceTracks = Array.isArray(options?.sourceTracks) ? options.sourceTracks : []; + const tracks = Array.isArray(targetTracks) ? targetTracks : []; + const allowedTracks = excludeBurned + ? tracks.filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) + : tracks; + const requested = normalizeTrackIdList(sourceTrackIds); + if (requested.length === 0 || allowedTracks.length === 0) { + return []; + } + + const targetMeta = allowedTracks + .map((track, index) => { + const targetId = normalizeTrackIdList([track?.id])[0] || null; + if (targetId === null) { + return null; + } + return { + track, + index, + targetId, + sourceId: normalizeTrackIdList([track?.sourceTrackId])[0] || null, + signature: kind === 'subtitle' + ? buildSubtitleTrackSignature(track) + : buildAudioTrackSignature(track) + }; + }) + .filter(Boolean); + + const mapped = []; + const seen = new Set(); + const usedTargetIds = new Set(); + for (const sourceTrackId of requested) { + const match = targetMeta.find((entry) => + !usedTargetIds.has(String(entry.targetId)) + && (entry.sourceId === sourceTrackId || entry.targetId === sourceTrackId) + ) || null; + if (!match) { + continue; + } + const targetId = match.targetId; + const key = String(targetId); + if (seen.has(key)) { + continue; + } + seen.add(key); + usedTargetIds.add(key); + mapped.push(targetId); + } + + if (mapped.length >= requested.length) { + return mapped; + } + + const requestedSet = new Set(requested.map((id) => String(id))); + const sourceMeta = sourceTracks + .map((track, index) => { + const sourceId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; + if (sourceId === null) { + return null; + } + if (!requestedSet.has(String(sourceId))) { + return null; + } + return { + sourceId, + index, + signature: kind === 'subtitle' + ? buildSubtitleTrackSignature(track) + : buildAudioTrackSignature(track) + }; + }) + .filter(Boolean) + .sort((left, right) => left.index - right.index); + + for (const source of sourceMeta) { + if (!source.signature) { + continue; + } + const match = targetMeta.find((entry) => + !usedTargetIds.has(String(entry.targetId)) + && entry.signature === source.signature + ) || null; + if (!match) { + continue; + } + const key = String(match.targetId); + if (seen.has(key)) { + continue; + } + seen.add(key); + usedTargetIds.add(key); + mapped.push(match.targetId); + } + + return mapped; +} + +function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) { + const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0; + const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0; + const previousReviewConfirmed = Boolean(previousPlan?.reviewConfirmed); + if (!hasReviewTitles || !hasPreviousTitles) { + return { + plan: reviewPlan, + applied: false, + selectedEncodeTitleId: normalizeReviewTitleId(reviewPlan?.encodeInputTitleId), + preEncodeScriptCount: 0, + postEncodeScriptCount: 0, + preEncodeChainCount: 0, + postEncodeChainCount: 0, + userPresetApplied: false + }; + } + + let nextPlan = reviewPlan; + const prefillTitleId = resolvePrefillEncodeTitleId(nextPlan, previousPlan); + let selectedTitleApplied = false; + if (prefillTitleId) { + try { + const remapped = applyEncodeTitleSelectionToPlan(nextPlan, prefillTitleId); + nextPlan = remapped.plan; + selectedTitleApplied = true; + } catch (_error) { + // Keep calculated review defaults when title from previous run is no longer available. + } + } + + const previousSelectedTitle = findSelectedTitleInPlan(previousPlan); + const nextSelectedTitle = findSelectedTitleInPlan(nextPlan); + let trackSelectionApplied = false; + if (previousReviewConfirmed && previousSelectedTitle && nextSelectedTitle) { + const previousSelectedAudioTracks = (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)); + const previousSelectedSubtitleTracks = (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)); + const previousAudioSourceIds = normalizeTrackIdList( + previousSelectedAudioTracks.map((track) => track?.sourceTrackId ?? track?.id) + ); + const previousSubtitleSourceIds = normalizeTrackIdList( + previousSelectedSubtitleTracks.map((track) => track?.sourceTrackId ?? track?.id) + ); + + const mappedAudioTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( + nextSelectedTitle?.audioTracks, + previousAudioSourceIds, + { + kind: 'audio', + sourceTracks: previousSelectedAudioTracks + } + ); + const mappedSubtitleTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( + nextSelectedTitle?.subtitleTracks, + previousSubtitleSourceIds, + { + kind: 'subtitle', + sourceTracks: previousSelectedSubtitleTracks, + excludeBurned: true + } + ); + const fallbackAudioTrackIds = normalizeTrackIdList( + (Array.isArray(nextSelectedTitle?.audioTracks) ? nextSelectedTitle.audioTracks : []) + .filter((track) => Boolean(track?.selectedByRule)) + .map((track) => track?.id) + ); + const fallbackSubtitleTrackIds = normalizeTrackIdList( + (Array.isArray(nextSelectedTitle?.subtitleTracks) ? nextSelectedTitle.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) + .map((track) => track?.id) + ); + const effectiveAudioTrackIds = previousAudioSourceIds.length > 0 && mappedAudioTrackIds.length === 0 + ? fallbackAudioTrackIds + : mappedAudioTrackIds; + const effectiveSubtitleTrackIds = previousSubtitleSourceIds.length > 0 && mappedSubtitleTrackIds.length === 0 + ? fallbackSubtitleTrackIds + : mappedSubtitleTrackIds; + + const targetTitleId = normalizeReviewTitleId(nextSelectedTitle?.id || nextPlan?.encodeInputTitleId); + if (targetTitleId) { + const trackSelectionResult = applyManualTrackSelectionToPlan(nextPlan, { + [targetTitleId]: { + audioTrackIds: effectiveAudioTrackIds, + subtitleTrackIds: effectiveSubtitleTrackIds + } + }); + nextPlan = trackSelectionResult.plan; + trackSelectionApplied = Boolean(trackSelectionResult.selectionApplied); + } + } + + const preEncodeScriptIds = normalizeScriptIdList(previousPlan?.preEncodeScriptIds || []); + const postEncodeScriptIds = normalizeScriptIdList(previousPlan?.postEncodeScriptIds || []); + const preEncodeChainIds = normalizeChainIdList(previousPlan?.preEncodeChainIds || []); + const postEncodeChainIds = normalizeChainIdList(previousPlan?.postEncodeChainIds || []); + nextPlan = { + ...nextPlan, + preEncodeScriptIds, + postEncodeScriptIds, + preEncodeScripts: buildScriptDescriptorList(preEncodeScriptIds, previousPlan?.preEncodeScripts || []), + postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []), + preEncodeChainIds, + postEncodeChainIds, + userPreset: null, + reviewConfirmed: false, + reviewConfirmedAt: null, + prefilledFromPreviousRun: true, + prefilledFromPreviousRunAt: nowIso() + }; + + const applied = selectedTitleApplied + || trackSelectionApplied + || preEncodeScriptIds.length > 0 + || postEncodeScriptIds.length > 0 + || preEncodeChainIds.length > 0 + || postEncodeChainIds.length > 0; + + return { + plan: nextPlan, + applied, + selectedEncodeTitleId: normalizeReviewTitleId(nextPlan?.encodeInputTitleId), + preEncodeScriptCount: preEncodeScriptIds.length, + postEncodeScriptCount: postEncodeScriptIds.length, + preEncodeChainCount: preEncodeChainIds.length, + postEncodeChainCount: postEncodeChainIds.length, + userPresetApplied: false + }; +} + +function applyMultipartSettingsLockToReviewPlan(reviewPlan, lockContext = null) { + const plan = reviewPlan && typeof reviewPlan === 'object' + ? reviewPlan + : null; + const lock = lockContext && typeof lockContext === 'object' + ? lockContext + : null; + const lockEnabled = Boolean(lock?.enabled); + const sourceJobId = normalizePositiveInteger(lock?.sourceJobId || null); + if (!plan || !lockEnabled) { + return { + plan: reviewPlan, + applied: false, + sourceJobId: null, + sourceDiscNumber: null + }; + } + + const sourceDiscNumber = normalizePositiveInteger(lock?.sourceDiscNumber || null); + return { + plan: { + ...plan, + multipartSettingsLock: { + enabled: true, + sourceJobId: sourceJobId || null, + sourceDiscNumber: sourceDiscNumber || null, + strategy: 'reuse_existing_disc_settings', + lockedAt: nowIso() + } + }, + applied: true, + sourceJobId: sourceJobId || null, + sourceDiscNumber: sourceDiscNumber || null + }; +} + +class PipelineService extends EventEmitter { + constructor() { + super(); + this.snapshot = { + state: 'IDLE', + activeJobId: null, + progress: 0, + eta: null, + statusText: null, + context: {} + }; + this.detectedDisc = null; + this.cdDrives = new Map(); // devicePath → per-drive CD state object + this.driveLocksByJob = new Map(); // jobId -> { devicePath, owner } + this.activeProcess = null; + this.activeProcesses = new Map(); + this.cancelRequestedByJob = new Set(); + this.jobProgress = new Map(); + this.seriesBatchParentByChild = new Map(); + this.seriesBatchParentProgressSyncAt = new Map(); + this.lastPersistAt = 0; + this.lastProgressKey = null; + this.queueEntries = []; + this.queuePumpRunning = false; + this.queueEntrySeq = 1; + this.pluginRegistryInitialized = false; + this.sourcePluginRegistry = null; + this.lastQueueSnapshot = { + maxParallelJobs: 1, + runningCount: 0, + runningJobs: [], + queuedJobs: [], + queuedCount: 0, + updatedAt: nowIso() + }; + } + + normalizeBooleanSetting(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; + } + + ensureSourcePluginRegistry() { + if (this.pluginRegistryInitialized) { + return this.sourcePluginRegistry; + } + + try { + const { registry } = require('../plugins/PluginRegistry'); + const { BluRayPlugin } = require('../plugins/BluRayPlugin'); + const { DVDPlugin } = require('../plugins/DVDPlugin'); + const { CdPlugin } = require('../plugins/CdPlugin'); + const { AudiobookPlugin } = require('../plugins/AudiobookPlugin'); + const { ConverterPlugin } = require('../plugins/ConverterPlugin'); + const plugins = [ + new BluRayPlugin(), + new DVDPlugin(), + new CdPlugin(), + new AudiobookPlugin(), + new ConverterPlugin() + ]; + for (const plugin of plugins) { + if (!registry.getPlugin(plugin.id)) { + registry.register(plugin); + } + } + this.sourcePluginRegistry = registry; + this.pluginRegistryInitialized = true; + return this.sourcePluginRegistry; + } catch (error) { + logger.warn('plugin-architecture:registry-init-failed', { + error: errorToMeta(error) + }); + return null; + } + } + + async resolveAnalyzePlugin(discInfo = null) { + const registry = this.ensureSourcePluginRegistry(); + if (!registry) { + return null; + } + return registry.findPlugin(discInfo) || null; + } + + sanitizePluginExecutionState(rawState = null) { + if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) { + return null; + } + + const normalizeStage = (value) => { + const stage = String(value || '').trim().toLowerCase(); + return stage || 'unknown'; + }; + + const pluginId = String(rawState.pluginId || '').trim().toLowerCase() || 'unknown'; + const pluginName = String(rawState.pluginName || '').trim() || pluginId; + const pluginFile = String(rawState.pluginFile || '').trim() || null; + const markerSource = String(rawState.markerSource || '').trim().toLowerCase() || 'plugin-file'; + const byStageRaw = rawState.byStage && typeof rawState.byStage === 'object' && !Array.isArray(rawState.byStage) + ? rawState.byStage + : {}; + const byStage = {}; + for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) { + const normalizedStage = normalizeStage(stageKey); + const count = Number(stageMeta?.count); + byStage[normalizedStage] = { + count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1, + lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null + }; + } + const explicitStages = Array.isArray(rawState.stages) + ? rawState.stages.map((stage) => normalizeStage(stage)).filter(Boolean) + : []; + const rawLastStage = String(rawState.lastStage || '').trim(); + const lastStage = rawLastStage ? normalizeStage(rawLastStage) : null; + const stages = Array.from(new Set([ + ...explicitStages, + ...Object.keys(byStage), + ...(lastStage ? [lastStage] : []) + ])); + + return { + markerSource, + pluginId, + pluginName, + pluginFile, + jobId: this.normalizeQueueJobId(rawState.jobId), + firstMarkedAt: String(rawState.firstMarkedAt || '').trim() || null, + lastMarkedAt: String(rawState.lastMarkedAt || '').trim() || null, + lastStage, + stages, + byStage + }; + } + + mergePluginExecutionState(existingState = null, nextState = null) { + const existing = this.sanitizePluginExecutionState(existingState); + const incoming = this.sanitizePluginExecutionState(nextState); + if (!existing) { + return incoming; + } + if (!incoming) { + return existing; + } + + const byStage = {}; + const stageKeys = new Set([ + ...Object.keys(existing.byStage || {}), + ...Object.keys(incoming.byStage || {}) + ]); + for (const stage of stageKeys) { + const previousMeta = existing.byStage?.[stage] || null; + const nextMeta = incoming.byStage?.[stage] || null; + const count = Number(previousMeta?.count || 0) + Number(nextMeta?.count || 0); + byStage[stage] = { + count: count > 0 ? Math.trunc(count) : 1, + lastMarkedAt: nextMeta?.lastMarkedAt || previousMeta?.lastMarkedAt || null + }; + } + + const stages = Array.from(new Set([ + ...(Array.isArray(existing.stages) ? existing.stages : []), + ...(Array.isArray(incoming.stages) ? incoming.stages : []) + ])); + + return { + markerSource: incoming.markerSource || existing.markerSource || 'plugin-file', + pluginId: incoming.pluginId || existing.pluginId || 'unknown', + pluginName: incoming.pluginName || existing.pluginName || incoming.pluginId || existing.pluginId || 'unknown', + pluginFile: incoming.pluginFile || existing.pluginFile || null, + jobId: this.normalizeQueueJobId(incoming.jobId || existing.jobId), + firstMarkedAt: existing.firstMarkedAt || incoming.firstMarkedAt || incoming.lastMarkedAt || existing.lastMarkedAt || null, + lastMarkedAt: incoming.lastMarkedAt || existing.lastMarkedAt || null, + lastStage: incoming.lastStage || existing.lastStage || (stages.length > 0 ? stages[stages.length - 1] : null), + stages, + byStage + }; + } + + withPluginExecutionMeta(info = null, executionState = null) { + const baseInfo = info && typeof info === 'object' && !Array.isArray(info) + ? { ...info } + : {}; + const mergedExecution = this.mergePluginExecutionState(baseInfo.pluginExecution, executionState); + if (!mergedExecution) { + return baseInfo; + } + baseInfo.pluginExecution = mergedExecution; + return baseInfo; + } + + async applyPluginExecutionMarker(marker = null, executionState = null) { + const mergedExecution = this.mergePluginExecutionState(null, executionState || marker); + const jobId = this.normalizeQueueJobId(mergedExecution?.jobId || marker?.jobId); + if (!mergedExecution || !jobId) { + return; + } + + const previousJobProgress = this.jobProgress.get(jobId) || {}; + const nextJobProgress = { + ...previousJobProgress, + state: previousJobProgress.state || this.snapshot.state, + progress: previousJobProgress.progress ?? this.snapshot.progress ?? 0, + eta: previousJobProgress.eta ?? null, + statusText: previousJobProgress.statusText ?? this.snapshot.statusText ?? null, + context: { + ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : {}), + pluginExecution: this.mergePluginExecutionState(previousJobProgress.context?.pluginExecution, mergedExecution) + } + }; + this.jobProgress.set(jobId, nextJobProgress); + + const snapshotJobId = this.normalizeQueueJobId(this.snapshot.activeJobId || this.snapshot.context?.jobId); + if (snapshotJobId === jobId) { + this.snapshot = { + ...this.snapshot, + context: { + ...(this.snapshot.context && typeof this.snapshot.context === 'object' + ? this.snapshot.context + : {}), + pluginExecution: this.mergePluginExecutionState(this.snapshot.context?.pluginExecution, mergedExecution) + } + }; + await this.persistSnapshot(false); + } + + const cdDriveEntry = this._getCdDriveByJobId(jobId); + if (cdDriveEntry?.devicePath) { + const existingDrive = this.cdDrives.get(cdDriveEntry.devicePath); + if (existingDrive) { + this.cdDrives.set(cdDriveEntry.devicePath, { + ...existingDrive, + context: { + ...(existingDrive.context && typeof existingDrive.context === 'object' + ? existingDrive.context + : {}), + pluginExecution: this.mergePluginExecutionState(existingDrive.context?.pluginExecution, mergedExecution) + } + }); + } + } + + wsService.broadcast('PIPELINE_PROGRESS', { + state: nextJobProgress.state || this.snapshot.state, + activeJobId: jobId, + progress: nextJobProgress.progress ?? 0, + eta: nextJobProgress.eta ?? null, + statusText: nextJobProgress.statusText ?? null, + contextPatch: { + pluginExecution: nextJobProgress.context.pluginExecution + } + }); + } + + async buildPluginContext(pluginId, extra = {}) { + const { PluginContext } = require('../plugins/PluginContext'); + const normalizedJobId = this.normalizeQueueJobId(extra?.jobId ?? extra?.job?.id); + const emitProgress = typeof extra?.emitProgress === 'function' + ? extra.emitProgress + : () => {}; + const emitState = typeof extra?.emitState === 'function' + ? extra.emitState + : () => {}; + return new PluginContext({ + settings: settingsService, + db: await getDb(), + logger: require('./logger').child(`PLUGIN:${String(pluginId || 'unknown').trim().toUpperCase() || 'UNKNOWN'}`), + websocket: wsService, + processRunner: { spawnTrackedProcess }, + emitProgress, + emitState, + onPluginExecution: (marker, aggregateState) => { + void this.applyPluginExecutionMarker(marker, aggregateState); + }, + extra: { + ...extra, + jobId: normalizedJobId, + pluginId + } + }); + } + + async runPluginReviewScan(plugin, job, { + jobId, + rawPath = null, + deviceInfo = null, + reviewMode = 'rip', + source = 'HANDBRAKE_SCAN', + silent = false + } = {}) { + if (!plugin || typeof plugin.review !== 'function') { + const error = new Error('Plugin-Review nicht verfügbar.'); + error.statusCode = 500; + throw error; + } + + const pluginCtx = await this.buildPluginContext(plugin.id, { + jobId, + rawJobDir: rawPath || null, + deviceInfo: deviceInfo || null, + reviewMode: String(reviewMode || 'rip').trim().toLowerCase() || 'rip', + runCommand: this.runCommand.bind(this), + reviewSource: String(source || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN', + reviewStage: 'MEDIAINFO_CHECK', + reviewSilent: Boolean(silent) + }); + + const pluginResult = await plugin.review(job, pluginCtx); + const scanLines = Array.isArray(pluginResult?.scanLines) ? pluginResult.scanLines : []; + const scanAnalysisLines = Array.isArray(pluginResult?.scanAnalysisLines) && pluginResult.scanAnalysisLines.length > 0 + ? pluginResult.scanAnalysisLines + : scanLines; + return { + scanLines, + scanAnalysisLines, + runInfo: pluginResult?.runInfo && typeof pluginResult.runInfo === 'object' + ? pluginResult.runInfo + : null, + sourceArg: String(pluginResult?.sourceArg || '').trim() || null, + pluginExecution: this.sanitizePluginExecutionState(pluginCtx.getPluginExecution()) + }; + } + + async runMakeMkvSeriesSecondChance({ + jobId, + mediaProfile, + deviceInfo = null, + rawPath = null, + seriesOptions = null, + silent = true + } = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); + if (!normalizedJobId || !isSeriesDiscMediaProfile(normalizedMediaProfile)) { + return { + runInfo: null, + scanLines: [], + playlistAnalysis: null, + structuredSeriesAnalysis: null, + reason: 'invalid_input' + }; + } + + const scanLines = []; + let runInfo = null; + try { + await this.ensureMakeMKVRegistration(normalizedJobId, 'ANALYZING').catch((error) => { + logger.warn('dvd-series:makemkv-second-chance:registration-failed', { + jobId: normalizedJobId, + mediaProfile: normalizedMediaProfile, + error: errorToMeta(error) + }); + }); + + const settings = await settingsService.getEffectiveSettingsMap(normalizedMediaProfile); + const analyzeConfig = rawPath + ? await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { + mediaProfile: normalizedMediaProfile, + disableMinLengthFilter: true, + settingsMap: settings + }) + : await settingsService.buildMakeMKVAnalyzeConfig(deviceInfo, { + mediaProfile: normalizedMediaProfile, + disableMinLengthFilter: true, + settingsMap: settings + }); + + runInfo = await this.runCommand({ + jobId: normalizedJobId, + stage: 'ANALYZING', + source: 'MAKEMKV_ANALYZE_SERIES_SECOND_CHANCE', + cmd: analyzeConfig.cmd, + args: analyzeConfig.args, + parser: parseMakeMkvProgress, + collectLines: scanLines, + silent: Boolean(silent) + }); + } catch (error) { + logger.warn('dvd-series:makemkv-second-chance:failed', { + jobId: normalizedJobId, + mediaProfile: normalizedMediaProfile, + source: rawPath ? 'raw_path' : 'device', + error: errorToMeta(error) + }); + return { + runInfo: error?.runInfo || runInfo || null, + scanLines, + playlistAnalysis: null, + structuredSeriesAnalysis: null, + reason: 'command_failed' + }; + } + + const playlistAnalysis = analyzePlaylistObfuscation(scanLines, 0, {}); + const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromPlaylistAnalysis( + playlistAnalysis, + seriesOptions && typeof seriesOptions === 'object' ? seriesOptions : {} + ); + return { + runInfo, + scanLines, + playlistAnalysis, + structuredSeriesAnalysis, + reason: structuredSeriesAnalysis ? 'ok' : 'no_structured_signal' + }; + } + + isRipSuccessful(job = null) { + if (Number(job?.rip_successful || 0) === 1) { + return true; + } + if (isJobFinished(job)) { + return true; + } + const mkInfo = this.safeParseJson(job?.makemkv_info_json); + return String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + } + + isEncodeSuccessful(job = null) { + const handBrakeInfo = this.safeParseJson(job?.handbrake_info_json); + const directSuccess = String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + if (directSuccess) { + return true; + } + + const seriesBatchSummary = handBrakeInfo?.seriesBatch && typeof handBrakeInfo.seriesBatch === 'object' + ? handBrakeInfo.seriesBatch + : null; + const seriesBatchMode = String(handBrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch'; + if (!seriesBatchMode || !seriesBatchSummary) { + return false; + } + + const totalCount = Number(seriesBatchSummary?.totalCount || 0); + const finishedCount = Number(seriesBatchSummary?.finishedCount || 0); + const errorCount = Number(seriesBatchSummary?.errorCount || 0); + const cancelledCount = Number(seriesBatchSummary?.cancelledCount || 0); + return totalCount > 0 + && finishedCount >= totalCount + && errorCount <= 0 + && cancelledCount <= 0; + } + + resolveDesiredRawFolderState(job = null) { + if (!this.isRipSuccessful(job)) { + return RAW_FOLDER_STATES.INCOMPLETE; + } + if (this.isEncodeSuccessful(job)) { + return RAW_FOLDER_STATES.COMPLETE; + } + return RAW_FOLDER_STATES.RIP_COMPLETE; + } + + resolveCurrentRawPath(rawBaseDir, storedRawPath, extraBaseDirs = []) { + const stored = String(storedRawPath || '').trim(); + if (!stored) { + return null; + } + const folderName = path.basename(stored); + const currentBaseDir = path.dirname(stored); + const allBaseDirs = [currentBaseDir, rawBaseDir, ...extraBaseDirs].filter(Boolean); + const uniqueBaseDirs = Array.from(new Set(allBaseDirs.map((item) => String(item).trim()).filter(Boolean))); + const variantFolderNames = Array.from( + new Set( + [ + folderName, + applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.RIP_COMPLETE), + applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.INCOMPLETE), + applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.COMPLETE) + ].map((item) => String(item || '').trim()).filter(Boolean) + ) + ); + const candidates = []; + const pushCandidate = (candidatePath) => { + const normalized = String(candidatePath || '').trim(); + if (!normalized || candidates.includes(normalized)) { + return; + } + candidates.push(normalized); + }; + pushCandidate(stored); + for (const baseDir of uniqueBaseDirs) { + for (const variantFolderName of variantFolderNames) { + pushCandidate(path.join(baseDir, variantFolderName)); + } + } + const existingDirectories = []; + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + existingDirectories.push(candidate); + } + } catch (_error) { + // ignore fs errors + } + } + if (existingDirectories.length === 0) { + return null; + } + + for (const candidate of existingDirectories) { + try { + if (hasBluRayBackupStructure(candidate) || findPreferredRawInput(candidate)) { + return candidate; + } + } catch (_error) { + // ignore fs errors + } + } + + return existingDirectories[0]; + } + + buildRawPathLookupConfig(settingsMap = {}, mediaProfile = null) { + const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; + const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); + const effectiveSettings = settingsService.resolveEffectiveToolSettings(sourceMap, normalizedMediaProfile); + const preferredDefaultRawDir = normalizedMediaProfile === 'cd' + ? settingsService.DEFAULT_CD_DIR + : normalizedMediaProfile === 'audiobook' + ? settingsService.DEFAULT_AUDIOBOOK_RAW_DIR + : settingsService.DEFAULT_RAW_DIR; + const uniqueRawDirs = Array.from( + new Set( + [ + effectiveSettings?.raw_dir, + effectiveSettings?.series_raw_dir, + sourceMap?.raw_dir, + sourceMap?.raw_dir_bluray, + sourceMap?.raw_dir_bluray_series, + sourceMap?.raw_dir_dvd, + sourceMap?.raw_dir_dvd_series, + sourceMap?.raw_dir_cd, + sourceMap?.raw_dir_audiobook, + preferredDefaultRawDir, + settingsService.DEFAULT_RAW_DIR, + settingsService.DEFAULT_CD_DIR, + settingsService.DEFAULT_AUDIOBOOK_RAW_DIR + ] + .map((item) => String(item || '').trim()) + .filter(Boolean) + ) + ); + + return { + effectiveSettings, + rawBaseDir: uniqueRawDirs[0] || String(preferredDefaultRawDir || '').trim() || null, + rawExtraDirs: uniqueRawDirs.slice(1) + }; + } + + resolveCurrentRawPathForSettings(settingsMap = {}, mediaProfile = null, storedRawPath = null) { + const stored = String(storedRawPath || '').trim(); + if (!stored) { + return null; + } + const { rawBaseDir, rawExtraDirs } = this.buildRawPathLookupConfig(settingsMap, mediaProfile); + return this.resolveCurrentRawPath(rawBaseDir, stored, rawExtraDirs); + } + + async resolveUsableRawInputForReadyJob(jobId, job, encodePlan = null) { + const plan = encodePlan && typeof encodePlan === 'object' + ? encodePlan + : this.safeParseJson(job?.encode_plan_json); + const mkInfo = this.safeParseJson(job?.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + makemkvInfo: mkInfo, + encodePlan: plan, + rawPath: job?.raw_path + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const resolvedRawPath = this.resolveCurrentRawPathForSettings( + settings, + mediaProfile, + job?.raw_path + ) || String(job?.raw_path || '').trim() || null; + if (!resolvedRawPath || !fs.existsSync(resolvedRawPath)) { + return { + mediaProfile, + resolvedRawPath, + rawState: resolveRawFolderStateFromPath(resolvedRawPath), + inputPath: null, + hasUsableRawInput: false + }; + } + + const rawState = resolveRawFolderStateFromPath(resolvedRawPath); + if (rawState === RAW_FOLDER_STATES.INCOMPLETE) { + return { + mediaProfile, + resolvedRawPath, + rawState, + inputPath: null, + hasUsableRawInput: false + }; + } + + let inputPath = null; + try { + if (hasBluRayBackupStructure(resolvedRawPath)) { + inputPath = resolvedRawPath; + } else { + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); + inputPath = findPreferredRawInput(resolvedRawPath, { + playlistAnalysis: playlistDecision.playlistAnalysis, + selectedPlaylistId: playlistDecision.selectedPlaylist + })?.path || null; + } + } catch (_error) { + inputPath = null; + } + + return { + mediaProfile, + resolvedRawPath, + rawState, + inputPath, + hasUsableRawInput: Boolean(inputPath) + }; + } + + async validateStartupEncodeRecoveryReadiness(job = null) { + const resolvedJob = job && typeof job === 'object' ? job : null; + const jobId = this.normalizeQueueJobId(resolvedJob?.id); + if (!jobId) { + return { + ok: false, + code: 'invalid_job', + message: 'Startup-Recovery nicht möglich: ungültiger Job.' + }; + } + + const mkInfo = this.safeParseJson(resolvedJob?.makemkv_info_json); + const encodePlan = this.safeParseJson(resolvedJob?.encode_plan_json); + const sourceTag = String(mkInfo?.source || '').trim().toLowerCase(); + const mediaProfile = this.resolveMediaProfileForJob(resolvedJob, { + makemkvInfo: mkInfo, + encodePlan + }); + const isOrphanRawImportJob = sourceTag === 'orphan_raw_import'; + const isDiscRipJob = isSeriesDiscMediaProfile(mediaProfile) && !isOrphanRawImportJob; + + if (!isDiscRipJob) { + return { + ok: true, + code: 'not_disc_rip_job', + mediaProfile, + resolvedRawPath: String(resolvedJob?.raw_path || '').trim() || null, + rawState: resolveRawFolderStateFromPath(resolvedJob?.raw_path) + }; + } + + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const resolvedRawPath = this.resolveCurrentRawPathForSettings( + settings, + mediaProfile, + resolvedJob?.raw_path + ) || String(resolvedJob?.raw_path || '').trim() || null; + if (!resolvedRawPath) { + return { + ok: false, + code: 'missing_raw_path', + mediaProfile, + resolvedRawPath: null, + rawState: RAW_FOLDER_STATES.INCOMPLETE, + message: `Server-Neustart: Rip ist unvollständig (RAW-Pfad fehlt). Bitte Rip vom Laufwerk neu starten.` + }; + } + + let rawExists = false; + let rawIsDirectory = false; + try { + rawExists = fs.existsSync(resolvedRawPath); + rawIsDirectory = rawExists && fs.statSync(resolvedRawPath).isDirectory(); + } catch (_error) { + rawExists = false; + rawIsDirectory = false; + } + if (!rawExists || !rawIsDirectory) { + return { + ok: false, + code: 'raw_path_missing_on_disk', + mediaProfile, + resolvedRawPath, + rawState: resolveRawFolderStateFromPath(resolvedRawPath), + message: `Server-Neustart: Rip ist unvollständig (RAW-Ordner fehlt: ${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + const rawState = resolveRawFolderStateFromPath(resolvedRawPath); + if (rawState === RAW_FOLDER_STATES.INCOMPLETE) { + return { + ok: false, + code: 'raw_state_incomplete', + mediaProfile, + resolvedRawPath, + rawState, + message: `Server-Neustart: Rip ist unvollständig (RAW-Ordner ist als Incomplete markiert: ${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + const ripMarkedSuccessful = Number(resolvedJob?.rip_successful || 0) === 1 + || String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + if (!ripMarkedSuccessful) { + return { + ok: false, + code: 'rip_not_marked_successful', + mediaProfile, + resolvedRawPath, + rawState, + message: 'Server-Neustart: Rip ist nicht als erfolgreich markiert. Bitte Rip vom Laufwerk neu starten.' + }; + } + + let hasUsableRawInput = false; + try { + hasUsableRawInput = Boolean( + hasBluRayBackupStructure(resolvedRawPath) || findPreferredRawInput(resolvedRawPath) + ); + } catch (_error) { + hasUsableRawInput = false; + } + if (!hasUsableRawInput) { + return { + ok: false, + code: 'raw_input_missing', + mediaProfile, + resolvedRawPath, + rawState, + message: `Server-Neustart: Rip enthält keine verwertbaren Dateien (${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + return { + ok: true, + code: 'ok', + mediaProfile, + resolvedRawPath, + rawState + }; + } + + async alignRawFolderJobId(rawPath, targetJobId) { + const sourceRawPath = String(rawPath || '').trim(); + const normalizedTargetJobId = this.normalizeQueueJobId(targetJobId); + if (!sourceRawPath || !normalizedTargetJobId) { + return sourceRawPath || null; + } + + const normalizedSourceRawPath = normalizeComparablePath(sourceRawPath); + const folderName = path.basename(normalizedSourceRawPath); + const baseName = stripRawStatePrefix(folderName); + if (!/\s-\sRAW\s-\sjob-\d+\s*$/i.test(baseName)) { + return normalizedSourceRawPath; + } + + const metadataBase = baseName.replace(/\s-\sRAW\s-\sjob-\d+\s*$/i, '').trim(); + if (!metadataBase) { + return normalizedSourceRawPath; + } + + const rawState = resolveRawFolderStateFromPath(normalizedSourceRawPath); + const nextFolderName = buildRawDirName(metadataBase, normalizedTargetJobId, { state: rawState }); + const normalizedTargetRawPath = normalizeComparablePath( + path.join(path.dirname(normalizedSourceRawPath), nextFolderName) + ); + if (!normalizedTargetRawPath || normalizedTargetRawPath === normalizedSourceRawPath) { + return normalizedSourceRawPath; + } + + try { + if (!fs.existsSync(normalizedSourceRawPath) || !fs.statSync(normalizedSourceRawPath).isDirectory()) { + return normalizedSourceRawPath; + } + if (fs.existsSync(normalizedTargetRawPath)) { + logger.warn('raw-path:align-job-id:target-exists', { + targetJobId: normalizedTargetJobId, + sourceRawPath: normalizedSourceRawPath, + targetRawPath: normalizedTargetRawPath + }); + return normalizedSourceRawPath; + } + + fs.renameSync(normalizedSourceRawPath, normalizedTargetRawPath); + await historyService.updateRawPathByOldPath(normalizedSourceRawPath, normalizedTargetRawPath); + logger.info('raw-path:align-job-id:renamed', { + targetJobId: normalizedTargetJobId, + from: normalizedSourceRawPath, + to: normalizedTargetRawPath + }); + return normalizedTargetRawPath; + } catch (error) { + logger.warn('raw-path:align-job-id:failed', { + targetJobId: normalizedTargetJobId, + sourceRawPath: normalizedSourceRawPath, + targetRawPath: normalizedTargetRawPath, + error: errorToMeta(error) + }); + return normalizedSourceRawPath; + } + } + + async migrateRawFolderNamingOnStartup(db) { + const settings = await settingsService.getSettingsMap(); + const rawBaseDir = String(settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '').trim(); + const rawExtraDirs = [ + settings?.raw_dir_bluray, + settings?.raw_dir_bluray_series, + settings?.raw_dir_dvd, + settings?.raw_dir_dvd_series, + settings?.raw_dir_cd, + settings?.raw_dir_audiobook, + settingsService.DEFAULT_CD_DIR, + settingsService.DEFAULT_AUDIOBOOK_RAW_DIR + ].map((d) => String(d || '').trim()).filter(Boolean); + const allRawDirs = [rawBaseDir, settingsService.DEFAULT_RAW_DIR, ...rawExtraDirs] + .filter((d, i, arr) => arr.indexOf(d) === i && d && fs.existsSync(d)); + if (allRawDirs.length === 0) { + return; + } + + const rows = await db.all(` + SELECT id, title, year, detected_title, raw_path, status, last_state, rip_successful, + makemkv_info_json, handbrake_info_json, encode_plan_json, media_type, job_kind + FROM jobs + WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' + AND (media_type IS NULL OR media_type <> 'converter') + AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%') + `); + const jobRows = Array.isArray(rows) ? rows : []; + const existingJobIdRows = await db.all('SELECT id FROM jobs'); + const existingJobIdSet = new Set( + (Array.isArray(existingJobIdRows) ? existingJobIdRows : []) + .map((row) => Number(row?.id)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + + const linkedRawPathSet = new Set(); + const addLinkedRawPath = (candidatePath) => { + const normalizedCandidate = normalizeComparablePath(candidatePath); + if (!normalizedCandidate) { + return; + } + linkedRawPathSet.add(normalizedCandidate); + for (const rawRoot of allRawDirs) { + if (!isPathInsideDirectory(rawRoot, normalizedCandidate)) { + continue; + } + const relative = String(path.relative(rawRoot, normalizedCandidate) || '').trim(); + if (!relative || relative === '.' || relative.startsWith('..')) { + continue; + } + const topSegment = relative + .split(path.sep) + .find((segment) => String(segment || '').trim() && segment !== '.'); + if (!topSegment) { + continue; + } + linkedRawPathSet.add(normalizeComparablePath(path.join(rawRoot, topSegment))); + } + }; + + let renamedCount = 0; + let pathUpdateCount = 0; + let ripFlagUpdateCount = 0; + let conflictCount = 0; + let missingCount = 0; + let orphanScanCount = 0; + let orphanCandidateCount = 0; + let orphanRenamedCount = 0; + let orphanConflictCount = 0; + let orphanFailedCount = 0; + let orphanAlreadyNormalizedCount = 0; + let orphanSkippedLinkedCount = 0; + let orphanSkippedKnownJobIdCount = 0; + const discoveredByJobId = new Map(); + + for (const scanDir of allRawDirs) { + try { + const dirEntries = fs.readdirSync(scanDir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (!entry.isDirectory()) { + continue; + } + const mappedJobId = extractRawFolderJobId(entry.name); + if (!mappedJobId) { + continue; + } + const candidatePath = path.join(scanDir, entry.name); + let mtimeMs = 0; + try { + mtimeMs = Number(fs.statSync(candidatePath).mtimeMs || 0); + } catch (_error) { + // ignore fs errors and keep zero mtime + } + const current = discoveredByJobId.get(mappedJobId); + if (!current || mtimeMs > current.mtimeMs) { + discoveredByJobId.set(mappedJobId, { + path: candidatePath, + mtimeMs + }); + } + } + } catch (scanError) { + logger.warn('startup:raw-dir-migrate:scan-failed', { + scanDir, + error: errorToMeta(scanError) + }); + } + } + + for (const row of jobRows) { + const jobId = Number(row?.id); + if (!Number.isFinite(jobId) || jobId <= 0) { + continue; + } + const encodePlan = this.safeParseJson(row?.encode_plan_json); + const jobKind = String(row?.job_kind || '').trim().toLowerCase(); + const isSeriesEpisodeSubJob = ( + isSeriesBatchChildPlan(encodePlan) + || Boolean(encodePlan?.seriesBatchVirtualEpisode) + || jobKind === 'dvd_series_episode' + || jobKind === 'dvd_series_virtual_episode' + ); + if (isSeriesEpisodeSubJob) { + continue; + } + + const currentRawPath = this.resolveCurrentRawPath(rawBaseDir, row.raw_path, rawExtraDirs) + || discoveredByJobId.get(jobId)?.path + || null; + if (!currentRawPath) { + missingCount += 1; + continue; + } + addLinkedRawPath(row.raw_path); + addLinkedRawPath(currentRawPath); + + const currentRawState = resolveRawFolderStateFromPath(currentRawPath); + let hasUsableRawInput = false; + try { + hasUsableRawInput = Boolean( + hasBluRayBackupStructure(currentRawPath) || findPreferredRawInput(currentRawPath) + ); + } catch (_error) { + hasUsableRawInput = false; + } + const ripSuccessful = this.isRipSuccessful(row); + const strictRipSuccessful = ripSuccessful + && currentRawState !== RAW_FOLDER_STATES.INCOMPLETE + && hasUsableRawInput; + if (strictRipSuccessful && Number(row?.rip_successful || 0) !== 1) { + await historyService.updateJob(jobId, { rip_successful: 1 }); + ripFlagUpdateCount += 1; + } + + // Keep renamed folder in the same base dir as the current path + const currentBaseDir = path.dirname(currentRawPath); + const currentFolderName = stripRawStatePrefix(path.basename(currentRawPath)); + const folderYearMatch = currentFolderName.match(/\((19|20)\d{2}\)/); + const fallbackYear = folderYearMatch + ? Number(String(folderYearMatch[0]).replace(/[()]/g, '')) + : null; + const folderSeriesSeasonDisc = extractSeriesSeasonDiscFromRawFolderName(currentFolderName); + const mkInfo = this.safeParseJson(row?.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : null; + const selectedMetadata = resolveSelectedMetadataForJob(row, analyzeContext, null); + const metadataBase = buildRawMetadataBase({ + title: row.title || row.detected_title || null, + year: row.year || null, + fallbackYear, + detected_title: row.detected_title || null, + media_type: row.media_type || null, + is_multipart_movie: Number(row?.is_multipart_movie || 0) === 1 ? 1 : 0, + job_kind: row?.job_kind || null + }, jobId, { + mediaProfile: row.media_type || null, + analyzeContext, + selectedMetadata, + seriesSeasonNumber: folderSeriesSeasonDisc.seasonNumber, + seriesDiscNumber: folderSeriesSeasonDisc.discNumber + }); + const desiredRawFolderState = strictRipSuccessful + ? (this.isEncodeSuccessful(row) ? RAW_FOLDER_STATES.COMPLETE : RAW_FOLDER_STATES.RIP_COMPLETE) + : RAW_FOLDER_STATES.INCOMPLETE; + const desiredRawPath = path.join( + currentBaseDir, + buildRawDirName(metadataBase, jobId, { state: desiredRawFolderState }) + ); + + let finalRawPath = currentRawPath; + if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(desiredRawPath)) { + if (fs.existsSync(desiredRawPath)) { + conflictCount += 1; + logger.warn('startup:raw-dir-migrate:target-exists', { + jobId, + currentRawPath, + desiredRawPath + }); + } else { + try { + fs.renameSync(currentRawPath, desiredRawPath); + finalRawPath = desiredRawPath; + renamedCount += 1; + addLinkedRawPath(finalRawPath); + } catch (renameError) { + logger.warn('startup:raw-dir-migrate:rename-failed', { + jobId, + currentRawPath, + desiredRawPath, + error: errorToMeta(renameError) + }); + continue; + } + } + } + + if (normalizeComparablePath(row.raw_path) !== normalizeComparablePath(finalRawPath)) { + await historyService.updateRawPathByOldPath(row.raw_path, finalRawPath); + pathUpdateCount += 1; + } + addLinkedRawPath(finalRawPath); + } + + for (const scanDir of allRawDirs) { + let dirEntries = []; + try { + dirEntries = fs.readdirSync(scanDir, { withFileTypes: true }); + } catch (scanError) { + logger.warn('startup:raw-orphan-normalize:scan-failed', { + scanDir, + error: errorToMeta(scanError) + }); + continue; + } + + for (const entry of dirEntries) { + if (!entry?.isDirectory?.()) { + continue; + } + const rawFolderName = String(entry.name || '').trim(); + if (!rawFolderName || rawFolderName.startsWith('.')) { + continue; + } + orphanScanCount += 1; + + const currentRawPath = normalizeComparablePath(path.join(scanDir, rawFolderName)); + if (!currentRawPath) { + continue; + } + if (linkedRawPathSet.has(currentRawPath)) { + orphanSkippedLinkedCount += 1; + continue; + } + + const folderJobId = extractRawFolderJobId(rawFolderName); + if (folderJobId && existingJobIdSet.has(folderJobId)) { + orphanSkippedKnownJobIdCount += 1; + addLinkedRawPath(currentRawPath); + continue; + } + + orphanCandidateCount += 1; + const cleanedBaseName = stripRawStatePrefix(rawFolderName); + const targetFolderName = applyRawFolderStateToName(cleanedBaseName, RAW_FOLDER_STATES.RIP_COMPLETE); + if (!targetFolderName) { + continue; + } + const targetRawPath = normalizeComparablePath(path.join(scanDir, targetFolderName)); + if (!targetRawPath || targetRawPath === currentRawPath) { + orphanAlreadyNormalizedCount += 1; + addLinkedRawPath(currentRawPath); + continue; + } + + if (fs.existsSync(targetRawPath)) { + orphanConflictCount += 1; + logger.warn('startup:raw-orphan-normalize:target-exists', { + scanDir, + currentRawPath, + targetRawPath, + folderJobId, + folderJobIdExists: existingJobIdSet.has(Number(folderJobId || 0)) + }); + continue; + } + + try { + fs.renameSync(currentRawPath, targetRawPath); + orphanRenamedCount += 1; + addLinkedRawPath(targetRawPath); + logger.info('startup:raw-orphan-normalize:renamed', { + from: currentRawPath, + to: targetRawPath, + folderJobId, + folderJobIdExists: existingJobIdSet.has(Number(folderJobId || 0)) + }); + } catch (renameError) { + orphanFailedCount += 1; + logger.warn('startup:raw-orphan-normalize:rename-failed', { + scanDir, + currentRawPath, + targetRawPath, + error: errorToMeta(renameError) + }); + } + } + } + + if ( + renamedCount > 0 + || pathUpdateCount > 0 + || ripFlagUpdateCount > 0 + || conflictCount > 0 + || missingCount > 0 + || orphanCandidateCount > 0 + || orphanRenamedCount > 0 + || orphanConflictCount > 0 + || orphanFailedCount > 0 + ) { + logger.info('startup:raw-dir-migrate:done', { + renamedCount, + pathUpdateCount, + ripFlagUpdateCount, + conflictCount, + missingCount, + orphanScanCount, + orphanCandidateCount, + orphanRenamedCount, + orphanConflictCount, + orphanFailedCount, + orphanAlreadyNormalizedCount, + orphanSkippedLinkedCount, + orphanSkippedKnownJobIdCount, + scannedDirs: allRawDirs + }); + } + } + + async init() { + const db = await getDb(); + try { + await this.migrateRawFolderNamingOnStartup(db); + } catch (migrationError) { + logger.warn('init:raw-dir-migrate-failed', { + error: errorToMeta(migrationError) + }); + } + const row = await db.get('SELECT * FROM pipeline_state WHERE id = 1'); + + if (row) { + this.snapshot = { + state: row.state, + activeJobId: row.active_job_id, + progress: Number(row.progress || 0), + eta: row.eta, + statusText: row.status_text, + context: this.safeParseJson(row.context_json) + }; + logger.info('init:loaded-snapshot', { snapshot: this.snapshot }); + } + + try { + await this.recoverStaleRunningJobsOnStartup(db); + } catch (recoveryError) { + logger.warn('init:stale-running-recovery-failed', { + error: errorToMeta(recoveryError) + }); + } + + try { + await this.normalizeMultipartOutputsOnStartup(db); + } catch (multipartOutputNormalizeError) { + logger.warn('init:multipart-output-normalize-failed', { + error: errorToMeta(multipartOutputNormalizeError) + }); + } + + try { + await this.syncAllSeriesContainerStatusesOnStartup(db); + } catch (seriesContainerRecoveryError) { + logger.warn('init:series-container-recovery-failed', { + error: errorToMeta(seriesContainerRecoveryError) + }); + } + + try { + await this._restoreDriveLocksFromJobs(); + } catch (driveLockRecoveryError) { + logger.warn('init:drive-lock-recovery-failed', { + error: errorToMeta(driveLockRecoveryError) + }); + } + try { + await this._forceUnlockStaleDriveLocks(); + } catch (driveLockCleanupError) { + logger.warn('init:drive-lock-cleanup-failed', { + error: errorToMeta(driveLockCleanupError) + }); + } + + // Always start with a clean ripper/session snapshot after server restart. + const hasContextKeys = this.snapshot.context + && typeof this.snapshot.context === 'object' + && Object.keys(this.snapshot.context).length > 0; + if (this.snapshot.state !== 'IDLE' || this.snapshot.activeJobId || hasContextKeys) { + await this.resetFrontendState('server_restart', { + force: true, + keepDetectedDevice: false + }); + } + await this.emitQueueChanged(); + void this.pumpQueue(); + } + + async recoverStaleRunningJobsOnStartup(db) { + const staleRows = await db.all(` + SELECT id, status, last_state, raw_path, rip_successful, makemkv_info_json, encode_plan_json, media_type, job_kind + FROM jobs + WHERE COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') + AND status IN ('ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', + 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') + ORDER BY updated_at ASC, id ASC + `); + const rows = Array.isArray(staleRows) ? staleRows : []; + if (rows.length === 0) { + return { + scanned: 0, + preparedReadyToEncode: 0, + markedError: 0, + blockedIncompleteRip: 0, + skipped: 0 + }; + } + + let preparedReadyToEncode = 0; + let markedError = 0; + let blockedIncompleteRip = 0; + let skipped = 0; + + for (const row of rows) { + const jobId = this.normalizeQueueJobId(row?.id); + if (!jobId) { + skipped += 1; + continue; + } + const rawStage = String(row?.status || row?.last_state || '').trim().toUpperCase(); + const stage = RUNNING_STATES.has(rawStage) ? rawStage : 'ENCODING'; + const message = `Server-Neustart erkannt während ${stage}. Laufender Prozess wurde beendet.`; + + if (stage === 'ENCODING') { + let recoveryValidation; + try { + recoveryValidation = await this.validateStartupEncodeRecoveryReadiness(row); + } catch (validationError) { + logger.warn('startup:recover-stale-encoding:validation-failed', { + jobId, + error: errorToMeta(validationError) + }); + recoveryValidation = { + ok: false, + code: 'validation_failed', + message: `Server-Neustart: Rip-Validierung fehlgeschlagen (${validationError?.message || 'unknown'}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + if (!recoveryValidation?.ok) { + const recoveryMessage = String(recoveryValidation?.message || '').trim() + || `${message} Rip ist unvollständig. Bitte Rip vom Laufwerk neu starten.`; + await historyService.updateJob(jobId, { + status: 'ERROR', + last_state: 'RIPPING', + end_time: nowIso(), + error_message: recoveryMessage, + rip_successful: 0 + }); + try { + await historyService.appendLog(jobId, 'SYSTEM', recoveryMessage); + } catch (_error) { + // keep recovery path even if log append fails + } + markedError += 1; + blockedIncompleteRip += 1; + continue; + } + + try { + await historyService.appendLog(jobId, 'SYSTEM', message); + } catch (_error) { + // keep recovery path even if log append fails + } + try { + await this.restartEncodeWithLastSettings(jobId, { + immediate: true, + triggerReason: 'server_restart' + }); + preparedReadyToEncode += 1; + continue; + } catch (error) { + logger.warn('startup:recover-stale-encoding:restart-failed', { + jobId, + error: errorToMeta(error) + }); + try { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Startup-Recovery Encode fehlgeschlagen, setze Job auf ERROR: ${error?.message || 'unknown'}` + ); + } catch (_logError) { + // ignore logging fallback errors + } + } + } + + if (stage === 'CD_ENCODING') { + // CD was encoding WAVs — try to restart the encode from existing WAV files + try { + await historyService.appendLog(jobId, 'SYSTEM', message); + } catch (_error) { + // keep recovery path even if log append fails + } + try { + await this.reencodeFromRaw(jobId, { immediate: true }); + preparedReadyToEncode += 1; + continue; + } catch (error) { + logger.warn('startup:recover-stale-cd-encoding:restart-failed', { + jobId, + error: errorToMeta(error) + }); + try { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Startup-Recovery CD-Encode fehlgeschlagen, setze Job auf ERROR: ${error?.message || 'unknown'}` + ); + } catch (_logError) { + // ignore logging fallback errors + } + } + } + + if (stage === 'RIPPING' || stage === 'MEDIAINFO_CHECK') { + let recoveryValidation; + try { + recoveryValidation = await this.validateStartupEncodeRecoveryReadiness(row); + } catch (validationError) { + logger.warn('startup:recover-stale-review:validation-failed', { + jobId, + stage, + error: errorToMeta(validationError) + }); + recoveryValidation = { + ok: false, + code: 'validation_failed', + message: `Server-Neustart: Rip-Validierung fehlgeschlagen (${validationError?.message || 'unknown'}). Bitte Rip vom Laufwerk neu starten.` + }; + } + + if (recoveryValidation?.ok) { + const reviewRecoveryMessage = stage === 'MEDIAINFO_CHECK' + ? 'Server-Neustart während Titel-/Spurprüfung erkannt. Rip ist bereits fertig; Review kann aus dem RAW-Backup neu gestartet werden.' + : 'Server-Neustart nach abgeschlossenem Rip erkannt. Review kann aus dem RAW-Backup neu gestartet werden.'; + await historyService.updateJob(jobId, { + status: 'ERROR', + last_state: 'MEDIAINFO_CHECK', + end_time: nowIso(), + error_message: reviewRecoveryMessage, + raw_path: recoveryValidation?.resolvedRawPath || row?.raw_path || null, + rip_successful: 1 + }); + try { + await historyService.appendLog(jobId, 'SYSTEM', reviewRecoveryMessage); + } catch (_error) { + // keep recovery path even if log append fails + } + markedError += 1; + continue; + } + + const recoveryMessage = String(recoveryValidation?.message || '').trim() + || `${message} Rip ist unvollständig. Bitte Rip vom Laufwerk neu starten.`; + await historyService.updateJob(jobId, { + status: 'ERROR', + last_state: 'RIPPING', + end_time: nowIso(), + error_message: recoveryMessage, + rip_successful: 0 + }); + try { + await historyService.appendLog(jobId, 'SYSTEM', recoveryMessage); + } catch (_error) { + // keep recovery path even if log append fails + } + markedError += 1; + blockedIncompleteRip += 1; + continue; + } + + await historyService.updateJob(jobId, { + status: 'ERROR', + last_state: stage, + end_time: nowIso(), + error_message: message + }); + try { + await historyService.appendLog(jobId, 'SYSTEM', message); + } catch (_error) { + // ignore logging failures during startup recovery + } + markedError += 1; + } + + logger.warn('startup:recover-stale-running-jobs', { + scanned: rows.length, + preparedReadyToEncode, + markedError, + blockedIncompleteRip, + skipped + }); + return { + scanned: rows.length, + preparedReadyToEncode, + markedError, + blockedIncompleteRip, + skipped + }; + } + + safeParseJson(raw) { + if (!raw) { + return {}; + } + + try { + return JSON.parse(raw); + } catch (error) { + logger.warn('safeParseJson:failed', { raw, error: errorToMeta(error) }); + return {}; + } + } + + getSnapshot() { + const jobProgress = {}; + for (const [id, data] of this.jobProgress) { + jobProgress[id] = data; + } + const cdDrives = {}; + for (const [devicePath, driveState] of this.cdDrives) { + cdDrives[devicePath] = driveState; + } + const detectedDiscs = {}; + for (const [path, device] of diskDetectionService.detectedDiscs) { + detectedDiscs[path] = device; + } + const driveLocks = diskDetectionService.getActiveLocks(); + return { + ...this.snapshot, + jobProgress, + queue: this.lastQueueSnapshot, + cdDrives, + detectedDiscs, + driveLocks + }; + } + + _broadcastPipelineStateChanged() { + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + return snapshotPayload; + } + + _setCdDriveState(devicePath, updates) { + const existing = this.cdDrives.get(devicePath) || { + devicePath, + state: 'DISC_DETECTED', + jobId: null, + device: null, + progress: 0, + eta: null, + statusText: null, + context: {} + }; + const next = { + ...existing, + ...updates, + devicePath + }; + this.cdDrives.set(devicePath, next); + + // If a drive is detached from a job (or reassigned), clear stale live + // progress for the previous job so ripper views do not stay in CD_ENCODING. + const previousJobId = Number(existing?.jobId || 0); + const nextJobId = Number(next?.jobId || 0); + if (Number.isFinite(previousJobId) && previousJobId > 0) { + const previousJobStillBound = Number.isFinite(nextJobId) && nextJobId > 0 && nextJobId === previousJobId; + if (!previousJobStillBound) { + this.jobProgress.delete(previousJobId); + } + } + + // Sync jobProgress so per-job queries work + const resolvedJobId = Number(next.jobId || 0); + if (Number.isFinite(resolvedJobId) && resolvedJobId > 0) { + const prev = this.jobProgress.get(resolvedJobId) || {}; + const contextPatch = next.context && typeof next.context === 'object' ? next.context : null; + const mergedContext = contextPatch + ? { ...(prev.context || {}), ...contextPatch } + : (prev.context || null); + const nextJobProgress = { + ...prev, + state: next.state, + progress: next.progress ?? prev.progress ?? 0, + eta: next.eta ?? null, + statusText: next.statusText ?? null + }; + if (mergedContext && Object.keys(mergedContext).length > 0) { + nextJobProgress.context = mergedContext; + } + this.jobProgress.set(resolvedJobId, nextJobProgress); + } + + // Manage disc-polling suspension while any CD drive is actively ripping + const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']); + const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state)); + if (anyActive) { + diskDetectionService.suspendPolling(); + } else { + diskDetectionService.resumePolling(); + } + + logger.info('cd:drive:state', { devicePath, state: next.state, jobId: next.jobId }); + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + void this.emitQueueChanged(); + } + + _removeCdDrive(devicePath) { + const drive = this.cdDrives.get(devicePath); + if (!drive) { + return; + } + const jobId = Number(drive.jobId || 0); + this.cdDrives.delete(devicePath); + if (Number.isFinite(jobId) && jobId > 0) { + this.jobProgress.delete(jobId); + } + // Resume polling if no other active drives + const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']); + const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state)); + if (!anyActive) { + diskDetectionService.resumePolling(); + } + logger.info('cd:drive:removed', { devicePath }); + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + } + + _getCdDriveByJobId(jobId) { + const normalizedId = Number(jobId); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + return null; + } + for (const [devicePath, drive] of this.cdDrives) { + if (Number(drive.jobId) === normalizedId) { + return { ...drive, devicePath }; + } + } + return null; + } + + _releaseCdDrive(devicePath, options = {}) { + const normalizedPath = String(devicePath || '').trim(); + if (!normalizedPath) { + return; + } + + const existing = this.cdDrives.get(normalizedPath) || null; + const fallbackDevice = options?.device && typeof options.device === 'object' + ? options.device + : (existing?.device && typeof existing.device === 'object' + ? existing.device + : { path: normalizedPath, mediaProfile: 'cd' }); + const releasedDevice = { + ...fallbackDevice, + path: normalizedPath, + mediaProfile: 'cd' + }; + + this._setCdDriveState(normalizedPath, { + state: 'DISC_DETECTED', + jobId: null, + device: releasedDevice, + progress: 0, + eta: null, + statusText: null, + context: { + device: releasedDevice, + devicePath: normalizedPath, + mediaProfile: 'cd' + } + }); + } + + normalizeDrivePath(value) { + return String(value || '').trim(); + } + + _resolveDriveRealPath(value) { + const normalized = this.normalizeDrivePath(value); + if (!normalized || !normalized.startsWith('/')) { + return null; + } + try { + if (fs.realpathSync && typeof fs.realpathSync.native === 'function') { + return fs.realpathSync.native(normalized); + } + return fs.realpathSync(normalized); + } catch (_error) { + return null; + } + } + + _isSameDrivePath(pathA, pathB) { + const a = this.normalizeDrivePath(pathA); + const b = this.normalizeDrivePath(pathB); + if (!a || !b) { + return false; + } + if (a === b) { + return true; + } + + const aReal = this._resolveDriveRealPath(a); + const bReal = this._resolveDriveRealPath(b); + if (aReal && bReal && aReal === bReal) { + return true; + } + + const aBase = path.basename(a); + const bBase = path.basename(b); + if (aBase && bBase && aBase === bBase && /^sr\d+$/i.test(aBase)) { + return true; + } + return false; + } + + _buildDriveLockOwner(jobId, extra = {}) { + const normalizedJobId = Number(jobId); + return { + jobId: Number.isFinite(normalizedJobId) && normalizedJobId > 0 ? Math.trunc(normalizedJobId) : null, + stage: String(extra?.stage || '').trim().toUpperCase() || null, + source: String(extra?.source || '').trim() || null, + mediaProfile: normalizeMediaProfile(extra?.mediaProfile) || null, + reason: String(extra?.reason || '').trim() || null, + acquiredAt: nowIso() + }; + } + + _getDriveLockByJobId(jobId) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return null; + } + return this.driveLocksByJob.get(Math.trunc(normalizedJobId)) || null; + } + + _getDriveLockByPath(devicePath) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath) { + return null; + } + for (const [jobId, lock] of this.driveLocksByJob.entries()) { + if (this._isSameDrivePath(lock?.devicePath, normalizedPath)) { + return { + ...lock, + jobId: Number(jobId) + }; + } + } + return null; + } + + _acquireDriveLockForJob(devicePath, jobId, extra = {}) { + const normalizedPath = this.normalizeDrivePath(devicePath); + const normalizedJobId = Number(jobId); + if (!normalizedPath || !Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return false; + } + + const targetJobId = Math.trunc(normalizedJobId); + const existing = this._getDriveLockByJobId(targetJobId); + if (existing && this._isSameDrivePath(existing.devicePath, normalizedPath)) { + return true; + } + if (existing) { + this._releaseDriveLockForJob(targetJobId, { reason: 'rebind' }); + } + + const owner = this._buildDriveLockOwner(targetJobId, extra); + diskDetectionService.lockDevice(normalizedPath, owner); + this.driveLocksByJob.set(targetJobId, { + devicePath: normalizedPath, + owner + }); + logger.info('drive-lock:acquired', { + devicePath: normalizedPath, + jobId: targetJobId, + stage: owner.stage, + source: owner.source, + reason: owner.reason + }); + this._broadcastPipelineStateChanged(); + return true; + } + + _releaseDriveLockForJob(jobId, options = {}) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return false; + } + const targetJobId = Math.trunc(normalizedJobId); + const existing = this.driveLocksByJob.get(targetJobId); + if (!existing) { + return false; + } + + diskDetectionService.unlockDevice(existing.devicePath, existing.owner || null); + this.driveLocksByJob.delete(targetJobId); + logger.info('drive-lock:released', { + devicePath: existing.devicePath, + jobId: targetJobId, + reason: String(options?.reason || '').trim() || null + }); + this._broadcastPipelineStateChanged(); + return true; + } + + _buildDriveLockedError(devicePath, lock = null) { + const normalizedPath = this.normalizeDrivePath(devicePath) || ''; + const lockedByJobId = Number(lock?.jobId || lock?.owner?.jobId || 0) || null; + const stage = String(lock?.owner?.stage || '').trim().toUpperCase() || null; + const message = lockedByJobId + ? `Laufwerk ${normalizedPath} ist gesperrt (Job #${lockedByJobId}${stage ? `, ${stage}` : ''}).` + : `Laufwerk ${normalizedPath} ist derzeit gesperrt.`; + const error = new Error(message); + error.statusCode = 409; + error.details = [{ + field: 'devicePath', + message + }]; + return error; + } + + _isProcessHandleRunning(processHandle) { + if (!processHandle || typeof processHandle !== 'object') { + return false; + } + const child = processHandle.child; + // Conservative fallback for orchestrating/shared handles without direct child. + if (!child || typeof child !== 'object') { + return true; + } + const pid = Number(child.pid); + if (!Number.isFinite(pid) || pid <= 0) { + return false; + } + if (child.exitCode !== null && child.exitCode !== undefined) { + return false; + } + if (child.signalCode !== null && child.signalCode !== undefined) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (String(error?.code || '').toUpperCase() === 'EPERM') { + return true; + } + return false; + } + } + + _isActiveProcessForJob(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + return false; + } + const processHandle = this.activeProcesses.get(normalizedJobId) || null; + if (!processHandle) { + return false; + } + const running = this._isProcessHandleRunning(processHandle); + if (!running && options?.cleanupStale) { + this.activeProcesses.delete(normalizedJobId); + this.syncPrimaryActiveProcess(); + logger.warn('process-handle:stale-removed', { jobId: normalizedJobId }); + } + return running; + } + + _hasForeignInteractiveSession(activeJobId = null) { + const normalizedActiveJobId = this.normalizeQueueJobId(activeJobId); + if (!normalizedActiveJobId) { + return false; + } + const snapshotActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); + if (!snapshotActiveJobId || snapshotActiveJobId === normalizedActiveJobId) { + return false; + } + const PIPELINE_INTERACTIVE_STATES = new Set([ + 'ANALYZING', + 'METADATA_LOOKUP', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'MEDIAINFO_CHECK' + ]); + const snapshotState = String(this.snapshot.state || '').trim().toUpperCase(); + return PIPELINE_INTERACTIVE_STATES.has(snapshotState); + } + + async _findProtectedDriveLockForAnalyze(devicePath) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath) { + return null; + } + + const existingLock = this._getDriveLockByPath(normalizedPath); + const ownerJobId = this.normalizeQueueJobId(existingLock?.owner?.jobId || existingLock?.jobId); + // Only an actually running process may protect the drive from force-unlock. + // Stale ERROR/CANCELLED/RIPPING remnants must not block analyze forever. + if (ownerJobId && this._isActiveProcessForJob(ownerJobId, { cleanupStale: true })) { + return existingLock; + } + return null; + } + + async _ensureDriveUnlockedForAnalyze(devicePath) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath) { + return; + } + const hasLock = Boolean(diskDetectionService.isDeviceLocked(normalizedPath) || this._getDriveLockByPath(normalizedPath)); + if (!hasLock) { + return; + } + + const protectedLock = await this._findProtectedDriveLockForAnalyze(normalizedPath); + if (protectedLock) { + throw this._buildDriveLockedError(normalizedPath, protectedLock); + } + + await this.forceUnlockDrive(normalizedPath, { + reason: 'analyze_stale_lock_clear' + }); + const stillLocked = Boolean(diskDetectionService.isDeviceLocked(normalizedPath) || this._getDriveLockByPath(normalizedPath)); + if (stillLocked) { + throw this._buildDriveLockedError(normalizedPath, this._getDriveLockByPath(normalizedPath)); + } + } + + _shouldKeepDriveLockForJobRow(job = null) { + const status = String(job?.status || '').trim().toUpperCase(); + const lastState = String(job?.last_state || '').trim().toUpperCase(); + const ripSuccessful = Number(job?.rip_successful || 0) === 1; + const mkInfo = this.safeParseJson(job?.makemkv_info_json); + const isOrphanRawImportJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + if (isOrphanRawImportJob) { + return false; + } + if (!this.normalizeDrivePath(job?.disc_device)) { + return false; + } + if (status === 'RIPPING' || status === 'CD_RIPPING') { + return true; + } + if ((status === 'ERROR' || status === 'CANCELLED') && !ripSuccessful) { + return lastState === 'RIPPING' || lastState === 'CD_RIPPING'; + } + return false; + } + + _shouldPreserveSuccessfulRipJobForAnalyze(job = null) { + // Never auto-delete jobs once a rip completed successfully. They may still + // require manual review/playlist selection and must remain recoverable. + return Number(job?.rip_successful || 0) === 1; + } + + async _restoreDriveLocksFromJobs() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT id, disc_device, status, last_state, rip_successful, job_kind, makemkv_info_json + FROM jobs + WHERE disc_device IS NOT NULL + AND TRIM(disc_device) <> '' + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') + AND status IN ('RIPPING', 'CD_RIPPING', 'ERROR', 'CANCELLED') + ` + ); + let restored = 0; + for (const row of (Array.isArray(rows) ? rows : [])) { + if (!this._shouldKeepDriveLockForJobRow(row)) { + continue; + } + const jobId = Number(row?.id); + const devicePath = this.normalizeDrivePath(row?.disc_device); + if (!Number.isFinite(jobId) || jobId <= 0 || !devicePath) { + continue; + } + const acquired = this._acquireDriveLockForJob(devicePath, jobId, { + stage: String(row?.status || row?.last_state || '').trim().toUpperCase() || null, + source: 'startup_recovery', + reason: 'pending_rip_recovery' + }); + if (acquired) { + restored += 1; + } + } + if (restored > 0) { + logger.warn('drive-lock:restored', { restored }); + } + } + + async _cleanupReplaceableDriveJobsForAnalyze(devicePath) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath) { + return []; + } + await this._ensureDriveUnlockedForAnalyze(normalizedPath); + + const db = await getDb(); + const rows = await db.all( + ` + SELECT * + FROM jobs + WHERE disc_device = ? + ORDER BY updated_at DESC, id DESC + `, + [normalizedPath] + ); + const replaceableStates = new Set([ + 'ANALYZING', + 'METADATA_LOOKUP', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'READY_TO_START', + 'MEDIAINFO_CHECK', + // READY_TO_ENCODE intentionally excluded: user has confirmed encoding, + // a new analyze must not silently delete a queued encode job. + 'CD_ANALYZING', + 'CD_METADATA_SELECTION', + // CD_READY_TO_RIP intentionally excluded: user has confirmed track + // selection, a new analyze must not discard that. + 'ERROR', + 'CANCELLED' + ]); + const deleted = []; + const replaceableRows = Array.isArray(rows) ? rows : []; + const containerRows = []; + + for (const row of replaceableRows) { + const jobId = Number(row?.id); + if (!Number.isFinite(jobId) || jobId <= 0) { + continue; + } + const normalizedJobId = Math.trunc(jobId); + const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase(); + if (!replaceableStates.has(rowState)) { + continue; + } + if (this._shouldPreserveSuccessfulRipJobForAnalyze(row)) { + logger.info('analyze:drive:preserve-successful-rip-job', { + devicePath: normalizedPath, + jobId: normalizedJobId, + status: rowState + }); + continue; + } + if (this.isContainerHistoryJob(row)) { + containerRows.push(row); + continue; + } + if (this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true })) { + const error = new Error(`Analyse nicht möglich: Job #${normalizedJobId} für ${normalizedPath} ist noch aktiv.`); + error.statusCode = 409; + throw error; + } + if (this._shouldKeepDriveLockForJobRow(row)) { + const lockForJob = this._getDriveLockByJobId(normalizedJobId); + const hasPhysicalLock = Boolean(diskDetectionService.isDeviceLocked(normalizedPath)); + const hasProtectedProcess = this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true }); + if (hasProtectedProcess || lockForJob || hasPhysicalLock) { + throw this._buildDriveLockedError(normalizedPath, lockForJob); + } + logger.warn('analyze:stale-lock-protection-cleared', { + devicePath: normalizedPath, + jobId: normalizedJobId, + status: rowState + }); + } + await historyService.deleteJob(normalizedJobId, 'none', { includeRelated: false }); + await this.onJobsDeleted([normalizedJobId], { suppressQueueRefresh: true }); + deleted.push(normalizedJobId); + } + + // Delete now-empty container anchors only after child rows were processed. + for (const row of containerRows) { + const jobId = Number(row?.id); + if (!Number.isFinite(jobId) || jobId <= 0) { + continue; + } + const normalizedJobId = Math.trunc(jobId); + const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase(); + if (!replaceableStates.has(rowState)) { + continue; + } + if (this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true })) { + const error = new Error(`Analyse nicht möglich: Job #${normalizedJobId} für ${normalizedPath} ist noch aktiv.`); + error.statusCode = 409; + throw error; + } + const hasChildren = await db.get( + ` + SELECT id + FROM jobs + WHERE parent_job_id = ? + LIMIT 1 + `, + [normalizedJobId] + ); + if (hasChildren) { + logger.info('analyze:drive:skip-container-cleanup', { + devicePath: normalizedPath, + containerJobId: normalizedJobId, + reason: 'children_present' + }); + continue; + } + await historyService.deleteJob(normalizedJobId, 'none', { includeRelated: false }); + await this.onJobsDeleted([normalizedJobId], { suppressQueueRefresh: true }); + deleted.push(normalizedJobId); + } + + if (deleted.length > 0) { + logger.info('analyze:drive:replace-existing-jobs', { + devicePath: normalizedPath, + deletedJobIds: deleted + }); + } + return deleted; + } + + _forceUnlockDriveLocksForDeletedJobs(devicePaths = [], deletedJobIds = []) { + const normalizedPaths = Array.from(new Set( + (Array.isArray(devicePaths) ? devicePaths : []) + .map((value) => this.normalizeDrivePath(value)) + .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) + )); + if (normalizedPaths.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') { + return []; + } + + const deletedSet = new Set( + (Array.isArray(deletedJobIds) ? deletedJobIds : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + const activeLocks = diskDetectionService.getActiveLocks(); + const forceUnlockedPaths = []; + + for (const devicePath of normalizedPaths) { + const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null; + if (!lock) { + continue; + } + const owners = Array.isArray(lock?.owners) ? lock.owners : []; + const ownerJobIds = owners + .map((owner) => Number(owner?.jobId)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + const hasForeignOwner = ownerJobIds.some((jobId) => !deletedSet.has(jobId)); + if (hasForeignOwner) { + logger.warn('drive-lock:force-unlock:skipped-foreign-owner', { + devicePath, + ownerJobIds + }); + continue; + } + const clearedCount = Number(diskDetectionService.forceUnlockDevice(devicePath, { + reason: 'job_deleted', + deletedJobIds: Array.from(deletedSet) + }) || 0); + if (clearedCount > 0) { + forceUnlockedPaths.push(devicePath); + } + } + + if (forceUnlockedPaths.length > 0) { + logger.warn('drive-lock:force-unlocked-after-delete', { + devicePaths: forceUnlockedPaths + }); + } + return forceUnlockedPaths; + } + + async _rescanDrivesAfterDelete(devicePaths = []) { + const normalizedPaths = Array.from(new Set( + (Array.isArray(devicePaths) ? devicePaths : []) + .map((value) => this.normalizeDrivePath(value)) + .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) + )); + if (normalizedPaths.length === 0) { + return; + } + + const retryDelaysMs = [0, 450, 1250]; + for (const devicePath of normalizedPaths) { + let lastResult = null; + for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) { + const waitMs = retryDelaysMs[attempt]; + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + try { + lastResult = await diskDetectionService.rescanDriveAndEmit(devicePath); + } catch (error) { + logger.warn('job-delete:drive-rescan:failed', { + devicePath, + attempt: attempt + 1, + error: errorToMeta(error) + }); + continue; + } + + const detectedProfile = String(lastResult?.device?.mediaProfile || '').trim().toLowerCase(); + const present = Boolean(lastResult?.present); + const locked = Boolean(lastResult?.locked); + if (locked) { + continue; + } + if (!present) { + continue; + } + if (detectedProfile && detectedProfile !== 'other' && detectedProfile !== 'unknown') { + break; + } + } + logger.info('job-delete:drive-rescan:done', { + devicePath, + emitted: lastResult?.emitted || 'none', + present: Boolean(lastResult?.present), + mediaProfile: lastResult?.device?.mediaProfile || null, + locked: Boolean(lastResult?.locked) + }); + } + } + + _forceStopActiveProcessForJob(jobId, options = {}) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return false; + } + + const targetJobId = Math.trunc(normalizedJobId); + const processHandle = this.activeProcesses.get(targetJobId) || null; + if (!processHandle) { + return false; + } + + const childPid = Number(processHandle?.child?.pid); + try { + processHandle?.cancel?.(); + } catch (_error) { + // ignore cancellation race errors + } + + if (Number.isFinite(childPid) && childPid > 0) { + try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ } + try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ } + } + try { + processHandle?.child?.kill?.('SIGKILL'); + } catch (_error) { + // noop + } + + logger.warn('job-delete:active-process-killed', { + jobId: targetJobId, + reason: String(options?.reason || '').trim() || null, + pid: Number.isFinite(childPid) && childPid > 0 ? childPid : null + }); + return true; + } + + async onJobsDeleted(jobIds = [], options = {}) { + const normalizedIds = Array.isArray(jobIds) + ? jobIds + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + : []; + if (normalizedIds.length === 0) { + return { cleaned: 0, removedQueueEntries: 0 }; + } + const resetDriveState = Boolean(options?.resetDriveState); + const extraDevicePaths = Array.isArray(options?.devicePaths) + ? options.devicePaths + .map((value) => this.normalizeDrivePath(value)) + .filter((value) => Boolean(value)) + : []; + const affectedDevicePaths = new Set(extraDevicePaths); + + const queueIds = new Set(normalizedIds.map((value) => String(value))); + const previousQueueLength = this.queueEntries.length; + this.queueEntries = this.queueEntries.filter((entry) => !queueIds.has(String(Number(entry?.jobId || 0)))); + let removedQueueEntries = Math.max(0, previousQueueLength - this.queueEntries.length); + for (const deletedJobId of normalizedIds) { + removedQueueEntries += this.removeDetachedQueueAutomationEntriesForJob(deletedJobId); + } + + for (const jobId of normalizedIds) { + this.cancelRequestedByJob.add(jobId); + this._forceStopActiveProcessForJob(jobId, { reason: 'job_deleted' }); + const lockForJob = this._getDriveLockByJobId(jobId); + if (lockForJob?.devicePath) { + affectedDevicePaths.add(this.normalizeDrivePath(lockForJob.devicePath)); + } + this._releaseDriveLockForJob(jobId, { reason: 'job_deleted' }); + const cdDriveForJob = this._getCdDriveByJobId(jobId); + if (cdDriveForJob?.devicePath) { + affectedDevicePaths.add(this.normalizeDrivePath(cdDriveForJob.devicePath)); + if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) { + this._removeCdDrive(cdDriveForJob.devicePath); + } else { + if (resetDriveState) { + this._removeCdDrive(cdDriveForJob.devicePath); + } else { + this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null }); + } + } + } else { + this.jobProgress.delete(jobId); + } + this.activeProcesses.delete(jobId); + } + // Keep the cancellation marker briefly so any late async step in the old + // pipeline thread aborts before spawning follow-up processes. + for (const jobId of normalizedIds) { + setTimeout(() => { + this.cancelRequestedByJob.delete(jobId); + }, 120000); + } + const forceUnlockedPaths = this._forceUnlockDriveLocksForDeletedJobs(Array.from(affectedDevicePaths), normalizedIds); + const staleLockClearedPaths = await this._forceUnlockStaleDriveLocks(Array.from(affectedDevicePaths)); + + let driveResetChanged = false; + if (resetDriveState) { + for (const devicePath of affectedDevicePaths) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath || normalizedPath.startsWith('__virtual__')) { + continue; + } + const removedDisc = diskDetectionService.detectedDiscs.get(normalizedPath) || null; + if (removedDisc) { + diskDetectionService.detectedDiscs.delete(normalizedPath); + wsService.broadcast('DISC_REMOVED', { device: removedDisc }); + driveResetChanged = true; + } + if (this.detectedDisc && this.normalizeDrivePath(this.detectedDisc.path) === normalizedPath) { + this.detectedDisc = null; + driveResetChanged = true; + } + } + } + this.syncPrimaryActiveProcess(); + + if (driveResetChanged) { + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + } + + if (!options?.suppressQueueRefresh) { + await this.emitQueueChanged(); + void this.pumpQueue(); + } + + if (resetDriveState || forceUnlockedPaths.length > 0 || staleLockClearedPaths.length > 0) { + void this._rescanDrivesAfterDelete(Array.from(affectedDevicePaths)); + } + return { + cleaned: normalizedIds.length, + removedQueueEntries + }; + } + + async _forceUnlockStaleDriveLocks(devicePaths = []) { + const activeLocks = Array.isArray(diskDetectionService.getActiveLocks?.()) + ? diskDetectionService.getActiveLocks() + : []; + const normalizedPaths = Array.from(new Set( + (Array.isArray(devicePaths) ? devicePaths : []) + .map((value) => this.normalizeDrivePath(value)) + .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) + )); + const pathsToCheck = normalizedPaths.length > 0 + ? normalizedPaths + : Array.from(new Set( + activeLocks + .map((entry) => this.normalizeDrivePath(entry?.path)) + .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) + )); + if (pathsToCheck.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') { + return []; + } + + const locksToCheck = pathsToCheck + .map((devicePath) => { + const lock = activeLocks.find((entry) => this._isSameDrivePath(entry?.path, devicePath)) || null; + return lock ? { devicePath, lock } : null; + }) + .filter(Boolean); + if (locksToCheck.length === 0) { + return []; + } + + const cleared = []; + for (const entry of locksToCheck) { + const owners = Array.isArray(entry.lock?.owners) ? entry.lock.owners : []; + const hasActiveOwner = owners.some((owner) => { + const jobId = Number(owner?.jobId); + if (!Number.isFinite(jobId) || jobId <= 0) { + return false; + } + const normalizedJobId = Math.trunc(jobId); + return this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true }); + }); + if (hasActiveOwner) { + continue; + } + const clearedJobIds = []; + for (const [jobId, lock] of this.driveLocksByJob.entries()) { + if (!this._isSameDrivePath(lock?.devicePath, entry.devicePath)) { + continue; + } + this.driveLocksByJob.delete(jobId); + clearedJobIds.push(Number(jobId)); + } + + const clearedCount = Number(diskDetectionService.forceUnlockDevice(entry.devicePath, { + reason: 'stale_lock', + deletedJobIds: clearedJobIds + }) || 0); + if (clearedCount > 0 || clearedJobIds.length > 0) { + cleared.push(entry.devicePath); + } + } + + if (cleared.length > 0) { + logger.warn('drive-lock:stale-unlocked', { devicePaths: cleared }); + this._broadcastPipelineStateChanged(); + } + return cleared; + } + + normalizeParallelJobsLimit(rawValue) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 1) { + return 1; + } + return Math.max(1, Math.min(12, Math.trunc(value))); + } + + normalizeQueueJobId(rawValue) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); + } + + async resolveExistingJobForAction(jobId, action = 'job_action') { + const resolved = await historyService.getJobByIdOrReplacement(jobId); + if (resolved?.job) { + if (resolved.replaced && resolved.requestedJobId !== resolved.resolvedJobId) { + logger.warn('job:resolved-replacement', { + action, + requestedJobId: resolved.requestedJobId, + resolvedJobId: resolved.resolvedJobId + }); + } + return resolved; + } + + const missingJobId = Number(resolved?.requestedJobId || jobId); + const error = new Error(`Job ${missingJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + isJobRunningStatus(status) { + return RUNNING_STATES.has(String(status || '').trim().toUpperCase()); + } + + syncPrimaryActiveProcess() { + if (this.activeProcesses.size === 0) { + this.activeProcess = null; + return; + } + const first = Array.from(this.activeProcesses.values())[0] || null; + this.activeProcess = first; + } + + async getMaxParallelJobs() { + const settings = await settingsService.getSettingsMap(); + return this.normalizeParallelJobsLimit(settings?.pipeline_max_parallel_jobs); + } + + async getMaxParallelCdEncodes() { + const settings = await settingsService.getSettingsMap(); + return this.normalizeParallelJobsLimit(settings?.pipeline_max_parallel_cd_encodes ?? 2); + } + + async forceUnlockDrive(devicePath, options = {}) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath || normalizedPath.startsWith('__virtual__')) { + return { unlocked: 0, path: normalizedPath || null }; + } + + const activeLocks = Array.isArray(diskDetectionService.getActiveLocks?.()) + ? diskDetectionService.getActiveLocks() + : []; + const matchingLockPaths = Array.from(new Set([ + normalizedPath, + ...activeLocks + .map((entry) => this.normalizeDrivePath(entry?.path)) + .filter((lockPath) => this._isSameDrivePath(lockPath, normalizedPath)) + ])); + + // Remove any in-memory job lock mappings for this drive (including alias paths) + const clearedJobIds = []; + for (const [jobId, lock] of this.driveLocksByJob.entries()) { + if (matchingLockPaths.some((candidatePath) => this._isSameDrivePath(lock?.devicePath, candidatePath))) { + this.driveLocksByJob.delete(jobId); + clearedJobIds.push(Number(jobId)); + } + } + + let unlocked = 0; + for (const lockPath of matchingLockPaths) { + unlocked += Number(diskDetectionService.forceUnlockDevice(lockPath, { + reason: String(options?.reason || 'manual_force_unlock').trim() || 'manual_force_unlock', + deletedJobIds: Array.isArray(options?.relatedJobIds) ? options.relatedJobIds : [] + }) || 0); + } + + if (clearedJobIds.length > 0 || unlocked > 0) { + logger.warn('drive-lock:force-unlock:manual', { + devicePath: normalizedPath, + matchingLockPaths, + unlocked, + clearedJobIds + }); + } + + this._broadcastPipelineStateChanged(); + void this._rescanDrivesAfterDelete(matchingLockPaths); + + return { unlocked, path: normalizedPath, matchingLockPaths, clearedJobIds }; + } + + async getMaxTotalEncodes() { + const settings = await settingsService.getSettingsMap(); + const value = Number(settings?.pipeline_max_total_encodes); + return Number.isFinite(value) && value >= 1 ? Math.min(24, Math.trunc(value)) : 3; + } + + async getCdBypassesQueue() { + const settings = await settingsService.getSettingsMap(); + const value = settings?.pipeline_cd_bypasses_queue; + return value === 'true' || value === true; + } + + normalizeQueuePoolType(rawValue) { + const value = String(rawValue || '').trim().toLowerCase(); + if (value === 'audio' || value === 'cd') { + return 'audio'; + } + if (value === 'film' || value === 'video') { + return 'film'; + } + return null; + } + + resolveQueuePoolTypeForJob(job = null) { + if (!job || typeof job !== 'object') { + return 'film'; + } + + const explicitMediaType = String(job?.mediaType || job?.media_type || '').trim().toLowerCase(); + const encodePlan = this.extractQueueJobPlan(job) || this.safeParseJson(job?.encode_plan_json) || {}; + const makemkvInfo = job?.makemkvInfo || this.safeParseJson(job?.makemkv_info_json); + const converterMediaType = String(encodePlan?.converterMediaType || '').trim().toLowerCase(); + + if (explicitMediaType === 'cd') { + return 'audio'; + } + if (explicitMediaType === 'audiobook') { + return 'audio'; + } + if (explicitMediaType === 'converter' && converterMediaType === 'audio') { + return 'audio'; + } + + const resolvedMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo, + mediaProfile: explicitMediaType || null + }); + if (resolvedMediaProfile === 'cd') { + return 'audio'; + } + if (resolvedMediaProfile === 'audiobook') { + return 'audio'; + } + if (resolvedMediaProfile === 'converter' && converterMediaType === 'audio') { + return 'audio'; + } + + return 'film'; + } + + resolveQueuePoolTypeForEntry(entry = null, jobsById = null) { + const explicitPoolType = this.normalizeQueuePoolType(entry?.poolType); + if (explicitPoolType) { + return explicitPoolType; + } + if (String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_CD) { + return 'audio'; + } + const jobId = Number(entry?.jobId); + if (Number.isFinite(jobId) && jobId > 0) { + const sourceJob = jobsById instanceof Map + ? (jobsById.get(Math.trunc(jobId)) || null) + : null; + if (sourceJob) { + return this.resolveQueuePoolTypeForJob(sourceJob); + } + } + return 'film'; + } + + buildRunningPoolUsage(runningJobs = []) { + const rows = Array.isArray(runningJobs) ? runningJobs : []; + let filmRunning = 0; + let audioRunning = 0; + for (const job of rows) { + if (this.isContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) { + continue; + } + const status = String(job?.status || job?.last_state || '').trim().toUpperCase(); + if (status !== 'ENCODING' && status !== 'CD_ENCODING') { + continue; + } + const poolType = this.resolveQueuePoolTypeForJob(job); + if (poolType === 'audio') { + audioRunning += 1; + } else { + filmRunning += 1; + } + } + return { + filmRunning, + audioRunning, + totalRunning: filmRunning + audioRunning + }; + } + + isSeriesBatchParentQueueAnchor(jobLike = null) { + const plan = this.extractQueueJobPlan(jobLike); + return isSeriesBatchParentPlan(plan); + } + + isSeriesContainerHistoryJob(jobLike = null) { + const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); + return jobKind === 'dvd_series_container'; + } + + isMultipartMovieContainerHistoryJob(jobLike = null) { + const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); + return jobKind === 'multipart_movie_container'; + } + + isMultipartMovieMergeHistoryJob(jobLike = null) { + const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); + return jobKind === 'multipart_movie_merge'; + } + + isMultipartMovieDiscChildHistoryJob(jobLike = null) { + const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); + return jobKind === 'multipart_movie_child' + || ( + Number(jobLike?.is_multipart_movie || 0) === 1 + && this.normalizeQueueJobId(jobLike?.parent_job_id) + && jobKind !== 'multipart_movie_merge' + ); + } + + isContainerHistoryJob(jobLike = null) { + return this.isSeriesContainerHistoryJob(jobLike) || this.isMultipartMovieContainerHistoryJob(jobLike); + } + + _deriveSeriesContainerStateFromChildren(childJobs = []) { + const rows = Array.isArray(childJobs) ? childJobs : []; + if (rows.length === 0) { + return null; + } + + const normalizedRows = []; + const stateCounts = new Map(); + for (const row of rows) { + const state = String(row?.status || row?.last_state || '').trim().toUpperCase(); + if (!state) { + continue; + } + normalizedRows.push({ row, state }); + stateCounts.set(state, (stateCounts.get(state) || 0) + 1); + } + if (normalizedRows.length === 0) { + return null; + } + + const hasState = (state) => stateCounts.has(state); + const runningPriority = ['RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'ANALYZING', 'METADATA_LOOKUP', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']; + let nextState = null; + for (const candidate of runningPriority) { + if (hasState(candidate)) { + nextState = candidate; + break; + } + } + + if (!nextState) { + if (hasState('ERROR')) { + nextState = 'ERROR'; + } else if (hasState('WAITING_FOR_USER_DECISION')) { + nextState = 'WAITING_FOR_USER_DECISION'; + } else if (hasState('READY_TO_START')) { + nextState = 'READY_TO_START'; + } else if (hasState('READY_TO_ENCODE')) { + nextState = 'READY_TO_ENCODE'; + } else if (hasState('METADATA_SELECTION')) { + nextState = 'METADATA_SELECTION'; + } else if (hasState('CANCELLED')) { + nextState = 'CANCELLED'; + } else if (hasState('FINISHED')) { + nextState = 'FINISHED'; + } else { + nextState = normalizedRows[0]?.state || null; + } + } + + if (!nextState) { + return null; + } + + const terminalStates = new Set(['FINISHED', 'ERROR', 'CANCELLED']); + const isTerminal = terminalStates.has(nextState); + let endTime = null; + if (isTerminal) { + let latestEndTime = null; + let latestEndTimeMs = null; + for (const item of normalizedRows) { + if (!terminalStates.has(item.state)) { + continue; + } + const rawEndTime = String(item?.row?.end_time || '').trim(); + if (!rawEndTime) { + continue; + } + const parsedMs = Date.parse(rawEndTime); + if (!Number.isFinite(parsedMs)) { + continue; + } + if (latestEndTimeMs === null || parsedMs > latestEndTimeMs) { + latestEndTimeMs = parsedMs; + latestEndTime = rawEndTime; + } + } + endTime = latestEndTime || nowIso(); + } + + let errorMessage = null; + if (nextState === 'ERROR' || nextState === 'CANCELLED') { + const matchingRows = normalizedRows + .filter((item) => item.state === nextState) + .sort((left, right) => { + const rightMs = Date.parse(String(right?.row?.end_time || '')) || 0; + const leftMs = Date.parse(String(left?.row?.end_time || '')) || 0; + return rightMs - leftMs; + }); + errorMessage = matchingRows + .map((item) => String(item?.row?.error_message || '').trim()) + .find(Boolean) || null; + } + + return { + state: nextState, + isTerminal, + endTime, + errorMessage + }; + } + + async syncSeriesContainerStatusFromChildren(parentJobId, options = {}) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + return null; + } + + const db = await getDb(); + const parentJob = await db.get( + ` + SELECT id, status, last_state, end_time, error_message, job_kind + FROM jobs + WHERE id = ? + `, + [normalizedParentJobId] + ); + const isSeriesContainer = this.isSeriesContainerHistoryJob(parentJob); + const isMultipartContainer = this.isMultipartMovieContainerHistoryJob(parentJob); + if (!parentJob || (!isSeriesContainer && !isMultipartContainer)) { + return null; + } + + const sourceChildRows = Array.isArray(options?.childJobs) + ? options.childJobs + : await historyService.listChildJobs(normalizedParentJobId); + const childRows = (Array.isArray(sourceChildRows) ? sourceChildRows : []) + .filter((row) => Number(row?.id) > 0 && !this.isContainerHistoryJob(row)) + .filter((row) => { + if (isSeriesContainer) { + const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' + ? row.encodePlan + : this.safeParseJson(row?.encode_plan_json); + const rowJobKind = String(row?.job_kind || row?.jobKind || '').trim().toLowerCase(); + const isSeriesEpisodeSubJob = ( + isSeriesBatchChildPlan(encodePlan) + || Boolean(encodePlan?.seriesBatchVirtualEpisode) + || rowJobKind === 'dvd_series_episode' + || rowJobKind === 'dvd_series_virtual_episode' + ); + return !isSeriesEpisodeSubJob; + } + if (isMultipartContainer) { + return this.isMultipartMovieDiscChildHistoryJob(row) || this.isMultipartMovieMergeHistoryJob(row); + } + return true; + }); + if (childRows.length === 0) { + return { + parentJobId: normalizedParentJobId, + updated: false, + childCount: 0, + state: String(parentJob.status || parentJob.last_state || '').trim().toUpperCase() || null + }; + } + + const aggregate = this._deriveSeriesContainerStateFromChildren(childRows); + if (!aggregate || !aggregate.state) { + return null; + } + + const nextStatus = aggregate.state; + const nextLastState = aggregate.state; + const nextEndTime = aggregate.isTerminal ? aggregate.endTime : null; + const nextErrorMessage = aggregate.errorMessage || null; + + const currentStatus = String(parentJob?.status || '').trim().toUpperCase(); + const currentLastState = String(parentJob?.last_state || '').trim().toUpperCase(); + const currentEndTime = String(parentJob?.end_time || '').trim() || null; + const currentErrorMessage = String(parentJob?.error_message || '').trim() || null; + + const changed = currentStatus !== nextStatus + || currentLastState !== nextLastState + || currentEndTime !== nextEndTime + || currentErrorMessage !== nextErrorMessage; + if (!changed) { + return { + parentJobId: normalizedParentJobId, + updated: false, + childCount: childRows.length, + state: nextStatus + }; + } + + await historyService.updateJob(normalizedParentJobId, { + status: nextStatus, + last_state: nextLastState, + end_time: nextEndTime, + error_message: nextErrorMessage + }); + + return { + parentJobId: normalizedParentJobId, + updated: true, + childCount: childRows.length, + state: nextStatus + }; + } + + async syncAllSeriesContainerStatusesOnStartup(db = null) { + const database = db || await getDb(); + const rows = await database.all(` + SELECT id + FROM jobs + WHERE COALESCE(job_kind, '') IN ('dvd_series_container', 'multipart_movie_container') + ORDER BY id ASC + `); + const containerRows = Array.isArray(rows) ? rows : []; + let updated = 0; + + for (const row of containerRows) { + const parentJobId = this.normalizeQueueJobId(row?.id); + if (!parentJobId) { + continue; + } + try { + const result = await this.syncSeriesContainerStatusFromChildren(parentJobId); + if (result?.updated) { + updated += 1; + } + } catch (error) { + logger.warn('series-container:startup-sync:failed', { + parentJobId, + error: errorToMeta(error) + }); + } + } + + if (updated > 0) { + logger.info('series-container:startup-sync:updated', { + scanned: containerRows.length, + updated + }); + } + + return { + scanned: containerRows.length, + updated + }; + } + + async resolveMultipartReviewLockOptions(jobOrId, options = {}) { + const baseOptions = options && typeof options === 'object' + ? { ...options } + : {}; + const existingPreviousEncodePlan = baseOptions?.previousEncodePlan && typeof baseOptions.previousEncodePlan === 'object' + ? baseOptions.previousEncodePlan + : null; + const existingLock = baseOptions?.multipartSettingsLock && typeof baseOptions.multipartSettingsLock === 'object' + ? baseOptions.multipartSettingsLock + : null; + const preloadedJob = jobOrId && typeof jobOrId === 'object' + ? jobOrId + : null; + const normalizedJobId = this.normalizeQueueJobId(preloadedJob?.id || jobOrId); + if (!normalizedJobId) { + return baseOptions; + } + const job = preloadedJob || await historyService.getJobById(normalizedJobId).catch(() => null); + if (!job || !this.isMultipartMovieDiscChildHistoryJob(job)) { + return baseOptions; + } + const parentJobId = this.normalizeQueueJobId(job?.parent_job_id); + if (!parentJobId) { + return baseOptions; + } + + let sourcePlan = existingPreviousEncodePlan; + let sourceJobId = this.normalizeQueueJobId(existingLock?.sourceJobId); + let sourceDiscNumber = normalizePositiveInteger(existingLock?.sourceDiscNumber || null); + const shouldResolveSourceFromSiblings = ( + !sourcePlan + || !Array.isArray(sourcePlan?.titles) + || sourcePlan.titles.length === 0 + || !sourceJobId + || sourceJobId === normalizedJobId + ); + if (shouldResolveSourceFromSiblings) { + const childRows = await historyService.listChildJobs(parentJobId).catch(() => []); + const candidates = (Array.isArray(childRows) ? childRows : []) + .filter((row) => this.isMultipartMovieDiscChildHistoryJob(row)) + .filter((row) => this.normalizeQueueJobId(row?.id) !== normalizedJobId) + .map((row) => { + const candidatePlan = this.safeParseJson(row?.encode_plan_json); + const hasTitles = Array.isArray(candidatePlan?.titles) && candidatePlan.titles.length > 0; + if (!hasTitles) { + return null; + } + const status = String(row?.status || row?.last_state || '').trim().toUpperCase(); + const reviewConfirmed = Number(row?.encode_review_confirmed || 0) === 1 || Boolean(candidatePlan?.reviewConfirmed); + return { + row, + plan: candidatePlan, + reviewConfirmed, + finished: status === 'FINISHED', + discNumber: resolveDiscNumberFromJobLike(row) || Number.MAX_SAFE_INTEGER, + id: this.normalizeQueueJobId(row?.id) || Number.MAX_SAFE_INTEGER + }; + }) + .filter(Boolean) + .sort((left, right) => { + if (left.reviewConfirmed !== right.reviewConfirmed) { + return left.reviewConfirmed ? -1 : 1; + } + if (left.finished !== right.finished) { + return left.finished ? -1 : 1; + } + if (left.discNumber !== right.discNumber) { + return left.discNumber - right.discNumber; + } + return left.id - right.id; + }); + const source = candidates[0] || null; + if (!source && (!sourcePlan || !Array.isArray(sourcePlan?.titles) || sourcePlan.titles.length === 0)) { + return baseOptions; + } + if (source) { + sourcePlan = source.plan; + sourceJobId = source.id; + sourceDiscNumber = Number.isFinite(source.discNumber) && source.discNumber > 0 + ? source.discNumber + : null; + } + } + + if (!sourcePlan || !Array.isArray(sourcePlan?.titles) || sourcePlan.titles.length === 0) { + return baseOptions; + } + + return { + ...baseOptions, + previousEncodePlan: sourcePlan, + multipartSettingsLock: { + enabled: true, + sourceJobId: sourceJobId || null, + sourceDiscNumber: sourceDiscNumber || null + } + }; + } + + isMultipartMergeJobCompletedState(jobLike = null) { + const state = String(jobLike?.status || jobLike?.last_state || '').trim().toUpperCase(); + return state === 'FINISHED'; + } + + async normalizeMultipartOutputsOnStartup(db = null) { + const database = db || await getDb(); + const rows = await database.all(` + SELECT + child.id, + child.parent_job_id, + child.job_kind, + child.is_multipart_movie, + child.output_path, + child.status, + child.last_state, + child.disc_number, + child.makemkv_info_json, + parent.job_kind AS parent_job_kind, + parent.title AS parent_title, + parent.detected_title AS parent_detected_title + FROM jobs child + LEFT JOIN jobs parent ON parent.id = child.parent_job_id + WHERE child.parent_job_id IS NOT NULL + AND child.output_path IS NOT NULL + AND TRIM(child.output_path) <> '' + AND ( + COALESCE(child.job_kind, '') = 'multipart_movie_child' + OR COALESCE(child.is_multipart_movie, 0) = 1 + ) + ORDER BY child.parent_job_id ASC, child.id ASC + `); + const childRows = Array.isArray(rows) ? rows : []; + if (childRows.length === 0) { + const mergeOutputNormalization = await this.normalizeMultipartMergeOutputsOnStartup(database); + return { + scanned: 0, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0, + mergePlansRefreshed: 0, + mergePlanRefreshFailed: 0, + mergeOutputs: mergeOutputNormalization + }; + } + + let updated = 0; + let unchanged = 0; + let skipped = 0; + let failed = 0; + let mergePlansRefreshed = 0; + let mergePlanRefreshFailed = 0; + const refreshContainerIds = new Set(); + const containerRowsById = new Map(); + + for (const row of childRows) { + const childJobId = this.normalizeQueueJobId(row?.id); + const parentJobId = this.normalizeQueueJobId(row?.parent_job_id); + if (!childJobId || !parentJobId) { + skipped += 1; + continue; + } + const parentKind = String(row?.parent_job_kind || '').trim().toLowerCase(); + if (parentKind !== 'multipart_movie_container') { + skipped += 1; + continue; + } + if (!this.isMultipartMovieDiscChildHistoryJob(row)) { + skipped += 1; + continue; + } + + const outputPath = String(row?.output_path || '').trim(); + if (!outputPath || !fs.existsSync(outputPath)) { + skipped += 1; + continue; + } + refreshContainerIds.add(parentJobId); + if (!containerRowsById.has(parentJobId)) { + containerRowsById.set(parentJobId, { + id: parentJobId, + job_kind: 'multipart_movie_container', + title: row?.parent_title || null, + detected_title: row?.parent_detected_title || null + }); + } + + try { + let rowUpdated = false; + const result = await this.ensureMultipartChildOutputHasDiscSuffix(childJobId, { childJob: row }); + if (result?.updated) { + rowUpdated = true; + } + const refreshedChild = result?.updated + ? await historyService.getJobById(childJobId).catch(() => ({ ...row, output_path: result?.outputPath || row.output_path })) + : row; + const incompleteMergeResult = await this.ensureMultipartChildOutputUsesIncompleteMergePrefix(childJobId, { + childJob: refreshedChild, + parentJob: containerRowsById.get(parentJobId) || null + }); + if (incompleteMergeResult?.updated) { + rowUpdated = true; + } + if (rowUpdated) { + updated += 1; + } else { + unchanged += 1; + } + } catch (error) { + failed += 1; + logger.warn('startup:multipart-output-normalize:rename-failed', { + childJobId, + parentJobId, + outputPath, + error: errorToMeta(error) + }); + } + } + + for (const containerJobId of refreshContainerIds) { + try { + await this.upsertMultipartMergeJobForContainer(containerJobId, { + containerJob: containerRowsById.get(containerJobId) || null, + createIfMissing: false + }); + mergePlansRefreshed += 1; + } catch (error) { + mergePlanRefreshFailed += 1; + logger.warn('startup:multipart-output-normalize:merge-plan-refresh-failed', { + containerJobId, + error: errorToMeta(error) + }); + } + } + + const mergeOutputNormalization = await this.normalizeMultipartMergeOutputsOnStartup(database); + + if ( + updated > 0 + || failed > 0 + || skipped > 0 + || mergePlansRefreshed > 0 + || mergePlanRefreshFailed > 0 + || Number(mergeOutputNormalization?.updated || 0) > 0 + || Number(mergeOutputNormalization?.failed || 0) > 0 + ) { + logger.info('startup:multipart-output-normalize:done', { + scanned: childRows.length, + updated, + unchanged, + skipped, + failed, + mergePlansRefreshed, + mergePlanRefreshFailed, + mergeOutputs: mergeOutputNormalization + }); + } + + return { + scanned: childRows.length, + updated, + unchanged, + skipped, + failed, + mergePlansRefreshed, + mergePlanRefreshFailed, + mergeOutputs: mergeOutputNormalization + }; + } + + async ensureMultipartChildOutputHasDiscSuffix(childJobId, options = {}) { + const normalizedChildJobId = this.normalizeQueueJobId(childJobId); + if (!normalizedChildJobId) { + return null; + } + const childJob = options?.childJob && Number(options.childJob?.id) === Number(normalizedChildJobId) + ? options.childJob + : await historyService.getJobById(normalizedChildJobId); + if (!childJob || !this.isMultipartMovieDiscChildHistoryJob(childJob)) { + return null; + } + + const sourceOutputPath = String(childJob?.output_path || '').trim(); + if (!sourceOutputPath) { + return { + jobId: normalizedChildJobId, + outputPath: null, + updated: false + }; + } + + const discSuffix = buildMultipartDiscOutputSuffix(childJob); + if (!discSuffix) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const sourceParsed = path.parse(sourceOutputPath); + if (String(sourceParsed.name || '').toLowerCase().endsWith(String(discSuffix).toLowerCase())) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + let targetOutputPath = withPathBaseNameSuffix(sourceOutputPath, discSuffix); + if (!targetOutputPath || normalizeComparablePath(targetOutputPath) === normalizeComparablePath(sourceOutputPath)) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const sourceExists = fs.existsSync(sourceOutputPath); + const targetExists = fs.existsSync(targetOutputPath); + if (sourceExists && targetExists) { + targetOutputPath = ensureUniqueOutputPath(targetOutputPath); + } + + if (sourceExists) { + ensureDir(path.dirname(targetOutputPath)); + moveFileWithFallback(sourceOutputPath, targetOutputPath); + } else if (!targetExists) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + await historyService.updateJob(normalizedChildJobId, { output_path: targetOutputPath }); + historyService.addJobOutputFolder(normalizedChildJobId, targetOutputPath).catch((error) => { + logger.warn('multipart:child-output:add-output-folder-failed', { + childJobId: normalizedChildJobId, + outputPath: targetOutputPath, + error: errorToMeta(error) + }); + }); + await historyService.appendLog( + normalizedChildJobId, + 'SYSTEM', + `Multipart-Output umbenannt: ${sourceOutputPath} -> ${targetOutputPath}` + ); + return { + jobId: normalizedChildJobId, + outputPath: targetOutputPath, + updated: true + }; + } + + async ensureMultipartChildOutputUsesIncompleteMergePrefix(childJobId, options = {}) { + const normalizedChildJobId = this.normalizeQueueJobId(childJobId); + if (!normalizedChildJobId) { + return null; + } + + const childJob = options?.childJob && Number(options.childJob?.id) === Number(normalizedChildJobId) + ? options.childJob + : await historyService.getJobById(normalizedChildJobId); + if (!childJob || !this.isMultipartMovieDiscChildHistoryJob(childJob)) { + return null; + } + + const sourceOutputPath = String(childJob?.output_path || '').trim(); + if (!sourceOutputPath) { + return { + jobId: normalizedChildJobId, + outputPath: null, + updated: false + }; + } + + const parentJobId = resolveMultipartContainerJobIdFromJob( + childJob, + options?.parentJobId + ); + if (!parentJobId) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const parentJob = options?.parentJob && Number(options.parentJob?.id) === Number(parentJobId) + ? options.parentJob + : await historyService.getJobById(parentJobId).catch(() => null); + if (!parentJob || !this.isMultipartMovieContainerHistoryJob(parentJob)) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const mediaProfile = this.resolveMediaProfileForJob(childJob, { + mediaProfile: childJob?.media_type || parentJob?.media_type || null + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, childJob, normalizedChildJobId); + let targetOutputPath = buildIncompleteMergeOutputPathFromJob( + settings, + childJob, + preferredFinalOutputPath, + normalizedChildJobId, + { + containerJobId: parentJobId, + containerTitle: parentJob?.title || parentJob?.detected_title || null + } + ); + + if (!targetOutputPath || normalizeComparablePath(targetOutputPath) === normalizeComparablePath(sourceOutputPath)) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + const sourceExists = fs.existsSync(sourceOutputPath); + const targetExists = fs.existsSync(targetOutputPath); + if (sourceExists && targetExists) { + targetOutputPath = ensureUniqueOutputPath(targetOutputPath); + } + + if (sourceExists) { + ensureDir(path.dirname(targetOutputPath)); + movePathWithFallback(sourceOutputPath, targetOutputPath); + removeDirectoryIfEmpty(path.dirname(sourceOutputPath)); + } else if (!targetExists) { + return { + jobId: normalizedChildJobId, + outputPath: sourceOutputPath, + updated: false + }; + } + + await historyService.updateJob(normalizedChildJobId, { output_path: targetOutputPath }); + historyService.addJobOutputFolder(normalizedChildJobId, targetOutputPath).catch((error) => { + logger.warn('multipart:child-output:add-output-folder-failed', { + childJobId: normalizedChildJobId, + outputPath: targetOutputPath, + error: errorToMeta(error) + }); + }); + await historyService.appendLog( + normalizedChildJobId, + 'SYSTEM', + `Multipart-Output in Incomplete-Merge-Ordner verschoben: ${sourceOutputPath} -> ${targetOutputPath}` + ); + return { + jobId: normalizedChildJobId, + outputPath: targetOutputPath, + updated: true + }; + } + + async normalizeMultipartMergeOutputsOnStartup(db = null) { + const database = db || await getDb(); + const rows = await database.all(` + SELECT + merge_job.id, + merge_job.parent_job_id, + merge_job.job_kind, + merge_job.media_type, + merge_job.output_path, + merge_job.status, + merge_job.last_state, + merge_job.encode_plan_json, + parent.job_kind AS parent_job_kind, + parent.title AS parent_title, + parent.detected_title AS parent_detected_title, + parent.media_type AS parent_media_type + FROM jobs merge_job + LEFT JOIN jobs parent ON parent.id = merge_job.parent_job_id + WHERE COALESCE(merge_job.job_kind, '') = 'multipart_movie_merge' + AND merge_job.parent_job_id IS NOT NULL + AND merge_job.output_path IS NOT NULL + AND TRIM(merge_job.output_path) <> '' + ORDER BY merge_job.id ASC + `); + const mergeRows = Array.isArray(rows) ? rows : []; + if (mergeRows.length === 0) { + return { + scanned: 0, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0 + }; + } + + let updated = 0; + let unchanged = 0; + let skipped = 0; + let failed = 0; + + for (const row of mergeRows) { + const mergeJobId = this.normalizeQueueJobId(row?.id); + const parentJobId = this.normalizeQueueJobId(row?.parent_job_id); + const sourceOutputPath = String(row?.output_path || '').trim(); + const parentKind = String(row?.parent_job_kind || '').trim().toLowerCase(); + if (!mergeJobId || !parentJobId || parentKind !== 'multipart_movie_container' || !sourceOutputPath) { + skipped += 1; + continue; + } + if (!fs.existsSync(sourceOutputPath)) { + skipped += 1; + continue; + } + + try { + const mergePlan = this.safeParseJson(row?.encode_plan_json) || {}; + const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); + const mediaProfile = this.resolveMediaProfileForJob(row, { + encodePlan: mergePlan, + mediaProfile: row?.media_type || row?.parent_media_type || null + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() + || buildFinalOutputPathFromJob(settings, row, mergeJobId); + const preferredFinalOutputPath = buildMultipartMergeOutputPath( + mergeOutputTemplatePath, + sourceItems + ) || mergeOutputTemplatePath; + const targetOutputPath = this.isMultipartMergeJobCompletedState(row) + ? preferredFinalOutputPath + : buildIncompleteMergeOutputPathFromJob( + settings, + row, + preferredFinalOutputPath, + mergeJobId, + { + containerJobId: parentJobId, + containerTitle: row?.parent_title || row?.parent_detected_title || null + } + ); + + if ( + !targetOutputPath + || normalizeComparablePath(targetOutputPath) === normalizeComparablePath(sourceOutputPath) + ) { + unchanged += 1; + continue; + } + + const sourceExists = fs.existsSync(sourceOutputPath); + const targetExists = fs.existsSync(targetOutputPath); + let effectiveTargetPath = targetOutputPath; + if (sourceExists && targetExists) { + effectiveTargetPath = ensureUniqueOutputPath(targetOutputPath); + } + + if (sourceExists) { + ensureDir(path.dirname(effectiveTargetPath)); + movePathWithFallback(sourceOutputPath, effectiveTargetPath); + removeDirectoryIfEmpty(path.dirname(sourceOutputPath)); + } else if (!targetExists) { + skipped += 1; + continue; + } + + await historyService.updateJob(mergeJobId, { output_path: effectiveTargetPath }); + historyService.addJobOutputFolder(mergeJobId, effectiveTargetPath).catch(() => {}); + await historyService.appendLog( + mergeJobId, + 'SYSTEM', + `Merge-Output beim Start korrigiert: ${sourceOutputPath} -> ${effectiveTargetPath}` + ); + updated += 1; + } catch (error) { + failed += 1; + logger.warn('startup:multipart-merge-output-normalize:failed', { + mergeJobId, + parentJobId, + outputPath: sourceOutputPath, + error: errorToMeta(error) + }); + } + } + + return { + scanned: mergeRows.length, + updated, + unchanged, + skipped, + failed + }; + } + + async upsertMultipartMergeJobForContainer(containerJobId, options = {}) { + const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId); + if (!normalizedContainerJobId) { + return null; + } + const containerJob = options?.containerJob && Number(options.containerJob?.id) === Number(normalizedContainerJobId) + ? options.containerJob + : await historyService.getJobById(normalizedContainerJobId); + if (!containerJob || !this.isMultipartMovieContainerHistoryJob(containerJob)) { + return null; + } + + const allChildren = await historyService.listChildJobs(normalizedContainerJobId); + const mergeJobs = (Array.isArray(allChildren) ? allChildren : []) + .filter((row) => this.isMultipartMovieMergeHistoryJob(row)) + .sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0)); + const discChildRows = (Array.isArray(allChildren) ? allChildren : []) + .filter((row) => this.isMultipartMovieDiscChildHistoryJob(row)) + .sort((left, right) => { + const leftDisc = resolveDiscNumberFromJobLike(left) || Number.MAX_SAFE_INTEGER; + const rightDisc = resolveDiscNumberFromJobLike(right) || Number.MAX_SAFE_INTEGER; + if (leftDisc !== rightDisc) { + return leftDisc - rightDisc; + } + return Number(left?.id || 0) - Number(right?.id || 0); + }); + + if (discChildRows.length < 2) { + return { + handled: false, + reason: 'not_enough_disc_children', + discChildCount: discChildRows.length + }; + } + + const refreshedDiscChildren = []; + for (const child of discChildRows) { + const normalizedChildId = this.normalizeQueueJobId(child?.id); + if (!normalizedChildId) { + continue; + } + await this.ensureMultipartChildOutputHasDiscSuffix(normalizedChildId, { childJob: child }).catch((error) => { + logger.warn('multipart:child-output:disc-suffix-failed', { + childJobId: normalizedChildId, + error: errorToMeta(error) + }); + }); + await this.ensureMultipartChildOutputUsesIncompleteMergePrefix(normalizedChildId, { + childJob: child, + parentJob: containerJob + }).catch((error) => { + logger.warn('multipart:child-output:incomplete-merge-prefix-failed', { + childJobId: normalizedChildId, + error: errorToMeta(error) + }); + }); + const refreshedChild = await historyService.getJobById(normalizedChildId).catch(() => child); + if (refreshedChild) { + refreshedDiscChildren.push(refreshedChild); + } + } + + const missingRequirements = []; + for (const child of refreshedDiscChildren) { + const childId = this.normalizeQueueJobId(child?.id); + const childState = String(child?.status || child?.last_state || '').trim().toUpperCase(); + const outputPath = String(child?.output_path || '').trim(); + if (childState !== 'FINISHED') { + missingRequirements.push({ + jobId: childId, + reason: 'state_not_finished', + state: childState + }); + continue; + } + if (!outputPath || !fs.existsSync(outputPath)) { + missingRequirements.push({ + jobId: childId, + reason: 'output_missing', + outputPath: outputPath || null + }); + } + } + if (missingRequirements.length > 0) { + return { + handled: false, + reason: 'disc_children_not_ready', + missingRequirements + }; + } + + const settings = await settingsService.getEffectiveSettingsMap( + this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null }) + ); + const templateOutputPath = withPathBaseNameSuffix( + buildFinalOutputPathFromJob(settings, containerJob, normalizedContainerJobId), + '_merged' + ); + const existingMergeJob = mergeJobs[0] || null; + const createIfMissing = options?.createIfMissing !== false; + const existingMergePlan = this.safeParseJson(existingMergeJob?.encode_plan_json) || {}; + + const defaultOrderIds = refreshedDiscChildren.map((child) => this.normalizeQueueJobId(child?.id)).filter(Boolean); + const existingOrderIds = normalizeReviewTitleIdList(existingMergePlan?.orderedSourceJobIds || []); + const orderedSourceJobIds = []; + const seenOrderIds = new Set(); + for (const sourceJobId of existingOrderIds) { + if (!defaultOrderIds.includes(Number(sourceJobId))) { + continue; + } + if (seenOrderIds.has(Number(sourceJobId))) { + continue; + } + seenOrderIds.add(Number(sourceJobId)); + orderedSourceJobIds.push(Number(sourceJobId)); + } + for (const sourceJobId of defaultOrderIds) { + if (seenOrderIds.has(Number(sourceJobId))) { + continue; + } + seenOrderIds.add(Number(sourceJobId)); + orderedSourceJobIds.push(Number(sourceJobId)); + } + + const discByJobId = new Map( + refreshedDiscChildren.map((child) => [ + Number(child.id), + { + discNumber: resolveDiscNumberFromJobLike(child), + outputPath: String(child?.output_path || '').trim() + } + ]) + ); + const sourceItems = orderedSourceJobIds + .map((sourceJobId) => { + const source = discByJobId.get(Number(sourceJobId)); + if (!source || !source.outputPath) { + return null; + } + return { + jobId: Number(sourceJobId), + discNumber: source.discNumber || null, + outputPath: source.outputPath + }; + }) + .filter(Boolean); + if (sourceItems.length < 2) { + return { + handled: false, + reason: 'insufficient_merge_inputs', + sourceCount: sourceItems.length + }; + } + const mergeOutputPath = buildMultipartMergeOutputPath(templateOutputPath, sourceItems) + || templateOutputPath; + + const mergePlan = { + mode: 'multipart_merge', + preRip: false, + reviewConfirmed: true, + reviewConfirmedAt: nowIso(), + mediaProfile: this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null }), + jobKind: 'multipart_movie_merge', + containerJobId: normalizedContainerJobId, + orderedSourceJobIds, + sourceItems, + mergeOutputPath, + deleteInputsAfterMerge: Boolean(existingMergePlan?.deleteInputsAfterMerge), + mergeStrategy: 'mkvmerge_append', + updatedAt: nowIso() + }; + + if (existingMergeJob) { + const existingStatus = String(existingMergeJob?.status || existingMergeJob?.last_state || '').trim().toUpperCase(); + const isRunning = existingStatus === 'ENCODING'; + const existingOutputPath = String(existingMergeJob?.output_path || '').trim(); + const existingOrderIds = normalizeReviewTitleIdList(existingMergePlan?.orderedSourceJobIds || []); + const existingSourceItems = normalizeMultipartMergeSourceItemsFromPlan(existingMergePlan); + const orderedSourceIdsChanged = existingOrderIds.length !== orderedSourceJobIds.length + || existingOrderIds.some((sourceJobId, index) => Number(sourceJobId) !== Number(orderedSourceJobIds[index])); + const mergeSourcesChanged = orderedSourceIdsChanged + || !areMultipartMergeSourceItemsEquivalent(existingSourceItems, sourceItems); + const shouldPreserveFinishedResult = ( + existingStatus === 'FINISHED' + && !mergeSourcesChanged + && existingOutputPath + && fs.existsSync(existingOutputPath) + ); + await historyService.updateJob(existingMergeJob.id, { + parent_job_id: normalizedContainerJobId, + title: containerJob.title || containerJob.detected_title || existingMergeJob.detected_title || null, + year: containerJob.year || null, + imdb_id: containerJob.imdb_id || null, + poster_url: containerJob.poster_url || null, + selected_from_omdb: 0, + omdb_json: null, + media_type: containerJob.media_type || null, + job_kind: 'multipart_movie_merge', + is_multipart_movie: 1, + metadata_fingerprint: containerJob.metadata_fingerprint || null, + makemkv_info_json: containerJob.makemkv_info_json || null, + encode_plan_json: JSON.stringify(mergePlan), + encode_review_confirmed: 1, + ...((isRunning || shouldPreserveFinishedResult) + ? {} + : { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + output_path: null, + encode_input_path: null, + handbrake_info_json: null, + error_message: null, + end_time: null + }) + }); + return { + handled: true, + mergeJobId: Number(existingMergeJob.id), + created: false, + sourceCount: sourceItems.length + }; + } + + if (!createIfMissing) { + return { + handled: false, + reason: 'merge_job_missing', + sourceCount: sourceItems.length + }; + } + + const createdMergeJob = await historyService.createJob({ + discDevice: null, + status: 'READY_TO_START', + detectedTitle: containerJob.title || containerJob.detected_title || null, + mediaType: containerJob.media_type || null, + jobKind: 'multipart_movie_merge' + }); + const createdMergeJobId = this.normalizeQueueJobId(createdMergeJob?.id); + if (!createdMergeJobId) { + const error = new Error('Merge-Job konnte nicht erstellt werden.'); + error.statusCode = 500; + throw error; + } + + await historyService.updateJob(createdMergeJobId, { + parent_job_id: normalizedContainerJobId, + title: containerJob.title || containerJob.detected_title || null, + year: containerJob.year || null, + imdb_id: containerJob.imdb_id || null, + poster_url: containerJob.poster_url || null, + selected_from_omdb: 0, + omdb_json: null, + media_type: containerJob.media_type || null, + job_kind: 'multipart_movie_merge', + is_multipart_movie: 1, + disc_number: null, + metadata_fingerprint: containerJob.metadata_fingerprint || null, + makemkv_info_json: containerJob.makemkv_info_json || null, + encode_plan_json: JSON.stringify(mergePlan), + encode_review_confirmed: 1 + }); + await historyService.appendLog( + createdMergeJobId, + 'SYSTEM', + `Merge-Job vorbereitet (${sourceItems.length} Datei(en)). Reihenfolge: ` + + sourceItems.map((item) => `Disc ${item.discNumber || '?'} (Job #${item.jobId})`).join(' | ') + ); + + return { + handled: true, + mergeJobId: createdMergeJobId, + created: true, + sourceCount: sourceItems.length + }; + } + + async updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds = []) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + const job = await historyService.getJobById(normalizedJobId); + if (!job || !this.isMultipartMovieMergeHistoryJob(job)) { + const error = new Error('Merge-Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + const currentState = String(job?.status || job?.last_state || '').trim().toUpperCase(); + if (currentState === 'ENCODING') { + const error = new Error('Merge-Reihenfolge kann während des laufenden Merges nicht geändert werden.'); + error.statusCode = 409; + throw error; + } + + const plan = this.safeParseJson(job.encode_plan_json) || {}; + const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(plan); + if (sourceItems.length < 2) { + const error = new Error('Merge-Job enthält keine vollständige Source-Liste.'); + error.statusCode = 409; + throw error; + } + const normalizedOrder = normalizeReviewTitleIdList( + Array.isArray(orderedSourceJobIds) ? orderedSourceJobIds : [] + ); + if (normalizedOrder.length === 0) { + const error = new Error('orderedSourceJobIds darf nicht leer sein.'); + error.statusCode = 400; + throw error; + } + const byJobId = new Map(sourceItems.map((item) => [Number(item.jobId), item])); + const nextItems = []; + const seen = new Set(); + for (const sourceJobId of normalizedOrder) { + const item = byJobId.get(Number(sourceJobId)); + if (!item || seen.has(Number(sourceJobId))) { + continue; + } + seen.add(Number(sourceJobId)); + nextItems.push(item); + } + for (const item of sourceItems) { + const sourceJobId = Number(item.jobId); + if (seen.has(sourceJobId)) { + continue; + } + seen.add(sourceJobId); + nextItems.push(item); + } + if (nextItems.length < 2) { + const error = new Error('Merge-Reihenfolge ist unvollständig.'); + error.statusCode = 400; + throw error; + } + + const nextPlan = { + ...plan, + orderedSourceJobIds: nextItems.map((item) => Number(item.jobId)), + sourceItems: nextItems, + updatedAt: nowIso() + }; + await historyService.updateJob(normalizedJobId, { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + end_time: null, + error_message: null, + output_path: null, + encode_plan_json: JSON.stringify(nextPlan) + }); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Merge-Reihenfolge aktualisiert: ${nextItems.map((item) => `Job #${item.jobId}`).join(' -> ')}` + ); + return historyService.getJobById(normalizedJobId); + } + + async updateMultipartMergeSettings(jobId, settings = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(normalizedJobId); + if (!job || !this.isMultipartMovieMergeHistoryJob(job)) { + const error = new Error('Merge-Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const currentState = String(job?.status || job?.last_state || '').trim().toUpperCase(); + if (currentState === 'ENCODING') { + const error = new Error('Merge-Optionen können während des laufenden Merges nicht geändert werden.'); + error.statusCode = 409; + throw error; + } + + const plan = this.safeParseJson(job.encode_plan_json) || {}; + const nextPlan = { ...plan }; + let changed = false; + + if (Object.prototype.hasOwnProperty.call(settings, 'deleteInputsAfterMerge')) { + const nextDeleteInputsAfterMerge = Boolean(settings?.deleteInputsAfterMerge); + if (Boolean(plan?.deleteInputsAfterMerge) !== nextDeleteInputsAfterMerge) { + nextPlan.deleteInputsAfterMerge = nextDeleteInputsAfterMerge; + changed = true; + } + } + + if (!changed) { + return job; + } + + nextPlan.updatedAt = nowIso(); + await historyService.updateJob(normalizedJobId, { + encode_plan_json: JSON.stringify(nextPlan) + }); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Merge-Option aktualisiert: Input-Dateien nach erfolgreichem Merge ${nextPlan.deleteInputsAfterMerge ? 'löschen' : 'behalten'}.` + ); + return historyService.getJobById(normalizedJobId); + } + + async cleanupMultipartMergeSourceOutputs(sourceItems = [], options = {}) { + const normalizedMergeJobId = this.normalizeQueueJobId(options?.mergeJobId); + const rows = Array.isArray(sourceItems) ? sourceItems : []; + const seenPaths = new Set(); + const deleted = []; + const missing = []; + const failed = []; + + for (const item of rows) { + const sourceJobId = this.normalizeQueueJobId(item?.jobId || item?.sourceJobId); + const outputPath = normalizeComparablePath(item?.outputPath || item?.path || null); + const discNumber = normalizePositiveInteger(item?.discNumber || null); + if (!outputPath || seenPaths.has(outputPath)) { + continue; + } + seenPaths.add(outputPath); + + try { + const existedBefore = fs.existsSync(outputPath); + if (!existedBefore) { + missing.push({ + sourceJobId: sourceJobId || null, + discNumber: discNumber || null, + outputPath + }); + } else { + const stat = fs.lstatSync(outputPath); + if (stat.isDirectory()) { + fs.rmSync(outputPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(outputPath); + } + deleted.push({ + sourceJobId: sourceJobId || null, + discNumber: discNumber || null, + outputPath + }); + } + + if (sourceJobId) { + const sourceJob = await historyService.getJobById(sourceJobId).catch(() => null); + const currentSourceOutputPath = normalizeComparablePath(sourceJob?.output_path || null); + if (currentSourceOutputPath && currentSourceOutputPath === outputPath) { + await historyService.updateJob(sourceJobId, { output_path: null }).catch(() => {}); + } + await historyService.removeJobOutputFolderFromJobs([sourceJobId], outputPath).catch(() => {}); + if (normalizedMergeJobId) { + await historyService.appendLog( + sourceJobId, + 'SYSTEM', + `Merge-Input nach erfolgreichem Merge gelöscht (Merge-Job #${normalizedMergeJobId}): ${outputPath}` + ).catch(() => {}); + } + } + } catch (cleanupError) { + failed.push({ + sourceJobId: sourceJobId || null, + discNumber: discNumber || null, + outputPath, + reason: cleanupError?.message || String(cleanupError) + }); + } + } + + return { + enabled: true, + deletedCount: deleted.length, + missingCount: missing.length, + failedCount: failed.length, + deleted, + missing, + failed + }; + } + + async restoreMultipartMergeJobForContainer(containerJobId, options = {}) { + const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId); + if (!normalizedContainerJobId) { + const error = new Error('Ungültige Container-Job-ID.'); + error.statusCode = 400; + throw error; + } + + const containerJob = options?.preloadedContainerJob && Number(options.preloadedContainerJob?.id) === Number(normalizedContainerJobId) + ? options.preloadedContainerJob + : await historyService.getJobById(normalizedContainerJobId); + if (!containerJob) { + const error = new Error(`Container-Job ${normalizedContainerJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + if (!this.isMultipartMovieContainerHistoryJob(containerJob)) { + const error = new Error(`Job #${normalizedContainerJobId} ist kein Multipart-Movie-Container.`); + error.statusCode = 409; + throw error; + } + + const allChildren = await historyService.listChildJobs(normalizedContainerJobId); + const discChildren = (Array.isArray(allChildren) ? allChildren : []) + .filter((child) => this.isMultipartMovieDiscChildHistoryJob(child)); + if (discChildren.length < 2) { + const error = new Error('Merge-Job kann erst ab mindestens 2 Discs wiederhergestellt werden.'); + error.statusCode = 409; + throw error; + } + + const missingOutputs = []; + for (const child of discChildren) { + const outputPath = String(child?.output_path || '').trim(); + if (!outputPath || !fs.existsSync(outputPath)) { + missingOutputs.push({ + jobId: Number(child?.id || 0) || null, + discNumber: resolveDiscNumberFromJobLike(child), + outputPath: outputPath || null + }); + } + } + if (missingOutputs.length > 0) { + const detail = missingOutputs + .map((item) => `Disc ${item.discNumber || '?'} / Job #${item.jobId || '?'}${item.outputPath ? ` (${item.outputPath})` : ''}`) + .join(' | '); + const error = new Error( + `Merge-Job kann nicht wiederhergestellt werden: Es fehlen Output-Dateien (${detail}).` + ); + error.statusCode = 409; + error.details = missingOutputs; + throw error; + } + + const upsertResult = await this.upsertMultipartMergeJobForContainer(normalizedContainerJobId, { + containerJob, + createIfMissing: true + }); + if (!upsertResult?.handled || !upsertResult?.mergeJobId) { + const error = new Error('Merge-Job konnte nicht wiederhergestellt werden.'); + error.statusCode = 409; + throw error; + } + return historyService.getJobById(upsertResult.mergeJobId); + } + + async analyzeMultipartMergeInputs(sourceItems = [], options = {}) { + const normalizedSourceItems = Array.isArray(sourceItems) ? sourceItems : []; + const ffprobeCommand = String(options?.ffprobeCommand || 'ffprobe').trim() || 'ffprobe'; + const sourceAnalyses = []; + for (const sourceItem of normalizedSourceItems) { + const sourceJobId = this.normalizeQueueJobId(sourceItem?.jobId || sourceItem?.sourceJobId); + const discNumber = normalizePositiveInteger(sourceItem?.discNumber); + const outputPath = String(sourceItem?.outputPath || sourceItem?.path || '').trim(); + if (!sourceJobId || !outputPath) { + continue; + } + const sourceMeta = { + sourceJobId, + discNumber: discNumber || null, + outputPath + }; + if (!fs.existsSync(outputPath)) { + sourceAnalyses.push({ + ...sourceMeta, + error: 'output_missing' + }); + continue; + } + try { + const probeResult = await this.runCapturedCommand(ffprobeCommand, [ + '-v', 'error', + '-show_chapters', + '-show_streams', + '-show_format', + '-print_format', 'json', + outputPath + ]); + const probeJson = this.safeParseJson(probeResult.stdout) || {}; + sourceAnalyses.push({ + ...sourceMeta, + probeJson, + formatDurationSeconds: Number.parseFloat(String(probeJson?.format?.duration || '').trim()) + }); + } catch (error) { + sourceAnalyses.push({ + ...sourceMeta, + error: error?.message || 'ffprobe_failed' + }); + } + } + + const chapterSourceGroups = sourceAnalyses + .filter((entry) => !entry.error && entry.probeJson) + .map((entry) => { + const probeJson = entry.probeJson || {}; + const probeChapters = Array.isArray(probeJson?.chapters) ? probeJson.chapters : []; + let fallbackDurationNanoseconds = 0; + for (const chapter of probeChapters) { + const endNanoseconds = parseDecimalSecondsToNanoseconds(chapter?.end_time ?? chapter?.start_time); + if (endNanoseconds != null && endNanoseconds > fallbackDurationNanoseconds) { + fallbackDurationNanoseconds = endNanoseconds; + } + } + const hasDuration = Number.isFinite(entry.formatDurationSeconds) && entry.formatDurationSeconds > 0; + const durationSeconds = hasDuration + ? entry.formatDurationSeconds + : (fallbackDurationNanoseconds > 0 ? (fallbackDurationNanoseconds / 1_000_000_000) : null); + return { + discNumber: entry.discNumber, + chapters: probeChapters, + durationSeconds + }; + }); + + const allInputsAnalyzed = sourceAnalyses.length === normalizedSourceItems.length + && sourceAnalyses.every((entry) => !entry.error); + const chapterPlan = allInputsAnalyzed + ? buildMultipartMergedChapterPlan(chapterSourceGroups) + : null; + const chapterPlanReason = chapterPlan + ? null + : (allInputsAnalyzed ? 'chapter_data_unavailable' : 'ffprobe_failed_or_input_missing'); + + return { + sourceAnalyses, + chapterPlan, + chapterPlanReason, + streamSummary: buildMultipartMergeStreamSummary(sourceAnalyses) + }; + } + + async getMultipartMergePreview(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + const mergeJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(normalizedJobId) + ? options.preloadedJob + : await historyService.getJobById(normalizedJobId); + if (!mergeJob || !this.isMultipartMovieMergeHistoryJob(mergeJob)) { + const error = new Error(`Merge-Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {}; + const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); + if (sourceItems.length < 2) { + const error = new Error('Merge-Vorschau nicht möglich: es werden mindestens 2 Source-Dateien benötigt.'); + error.statusCode = 409; + throw error; + } + + const mediaProfile = this.resolveMediaProfileForJob(mergeJob, { + encodePlan: mergePlan, + mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge'; + const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() + || buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId); + const expectedOutputPath = buildMultipartMergeOutputPath( + mergeOutputTemplatePath, + sourceItems + ) || mergeOutputTemplatePath; + const analysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); + const hasGeneratedChapterPlan = Boolean(analysis?.chapterPlan?.content); + + const commandArgs = ['-o', expectedOutputPath]; + if (hasGeneratedChapterPlan) { + commandArgs.push('--chapters', ''); + } + sourceItems.forEach((item, index) => { + if (index > 0) { + commandArgs.push('+'); + } + if (hasGeneratedChapterPlan) { + commandArgs.push('--no-chapters'); + } + commandArgs.push(item.outputPath); + }); + + const missingInputs = sourceItems + .filter((item) => !fs.existsSync(item.outputPath)) + .map((item) => ({ + sourceJobId: item.jobId, + discNumber: item.discNumber || null, + outputPath: item.outputPath + })); + + return { + jobId: normalizedJobId, + generatedAt: nowIso(), + outputPath: expectedOutputPath, + sourceItems, + command: { + cmd: mkvmergeCommand, + args: commandArgs, + preview: buildShellCommandPreview(mkvmergeCommand, commandArgs) + }, + chapters: { + strategy: hasGeneratedChapterPlan ? 'generated_prefixed' : 'source_native', + reason: analysis?.chapterPlanReason || null, + count: Array.isArray(analysis?.chapterPlan?.entries) ? analysis.chapterPlan.entries.length : 0, + entries: Array.isArray(analysis?.chapterPlan?.entries) ? analysis.chapterPlan.entries : [] + }, + media: { + ...analysis.streamSummary + }, + validation: { + missingInputs + } + }; + } + + async startMultipartMergeJob(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + this.ensureNotBusy('startMultipartMergeJob', normalizedJobId); + const mergeJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(normalizedJobId) + ? options.preloadedJob + : await historyService.getJobById(normalizedJobId); + if (!mergeJob || !this.isMultipartMovieMergeHistoryJob(mergeJob)) { + const error = new Error(`Merge-Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {}; + const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); + const deleteInputsAfterMerge = Boolean(mergePlan?.deleteInputsAfterMerge); + if (sourceItems.length < 2) { + const error = new Error('Merge-Start nicht möglich: es werden mindestens 2 Source-Dateien benötigt.'); + error.statusCode = 409; + throw error; + } + for (const item of sourceItems) { + if (!fs.existsSync(item.outputPath)) { + const error = new Error(`Merge-Input fehlt: ${item.outputPath}`); + error.statusCode = 409; + throw error; + } + } + + const mediaProfile = this.resolveMediaProfileForJob(mergeJob, { + encodePlan: mergePlan, + mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge'; + await this.ensureExternalToolAvailable(mkvmergeCommand, { label: 'mkvmerge' }); + const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() + || buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId); + const preferredFinalOutputPath = buildMultipartMergeOutputPath( + mergeOutputTemplatePath, + sourceItems + ) || mergeOutputTemplatePath; + const incompleteOutputPath = buildIncompleteMergeOutputPathFromJob( + settings, + mergeJob, + preferredFinalOutputPath, + normalizedJobId, + { + containerJobId: mergeJob?.parent_job_id, + containerTitle: mergeJob?.title || mergeJob?.detected_title || null + } + ); + ensureDir(path.dirname(incompleteOutputPath)); + + await this.setState('ENCODING', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: `Merge läuft (${sourceItems.length} Teile)`, + context: { + jobId: normalizedJobId, + mode: 'multipart_merge', + mediaProfile, + deleteInputsAfterMerge, + outputPath: incompleteOutputPath, + sourceItems + } + }); + await historyService.updateJob(normalizedJobId, { + status: 'ENCODING', + last_state: 'ENCODING', + output_path: incompleteOutputPath, + error_message: null, + end_time: null + }); + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Merge gestartet. Quellen: ${sourceItems.map((item) => item.outputPath).join(' | ')}` + ); + + const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + ensureDir(tempDir); + const mergeTmpDir = fs.mkdtempSync(path.join(tempDir, 'ripster-merge-')); + void (async () => { + let chaptersFilePath = null; + let runInfo = null; + try { + let chapterPreparationMode = 'source_native'; + let chapterPreparationEntries = 0; + const mergeInputAnalysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); + const erroredSources = Array.isArray(mergeInputAnalysis?.sourceAnalyses) + ? mergeInputAnalysis.sourceAnalyses.filter((entry) => Boolean(entry?.error)) + : []; + if (erroredSources.length > 0) { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Kapitel-Analyse übersprungen (${erroredSources.map((entry) => `${entry.outputPath}: ${entry.error}`).join(' | ')}).` + ); + } + const mergedChapterContent = String(mergeInputAnalysis?.chapterPlan?.content || '').trim(); + if (mergedChapterContent) { + chaptersFilePath = path.join(mergeTmpDir, `merge-job-${normalizedJobId}-chapters.txt`); + fs.writeFileSync(chaptersFilePath, `${mergedChapterContent}\n`, 'utf8'); + chapterPreparationMode = 'prefixed_from_inputs'; + chapterPreparationEntries = Array.isArray(mergeInputAnalysis?.chapterPlan?.entries) + ? mergeInputAnalysis.chapterPlan.entries.length + : 0; + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Merge-Kapitel vorbereitet (${chapterPreparationEntries} Einträge, Disc-Prefix aktiv).` + ); + } else { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + 'Merge-Kapitel konnten nicht normalisiert werden; verwende Kapitel direkt aus den Quellen.' + ); + } + + const mkvmergeArgs = ['-o', incompleteOutputPath]; + if (chaptersFilePath) { + mkvmergeArgs.push('--chapters', chaptersFilePath); + } + sourceItems.forEach((item, sourceIndex) => { + if (sourceIndex > 0) { + mkvmergeArgs.push('+'); + } + if (chaptersFilePath) { + mkvmergeArgs.push('--no-chapters'); + } + mkvmergeArgs.push(item.outputPath); + }); + + runInfo = await this.runCommand({ + jobId: normalizedJobId, + stage: 'ENCODING', + source: 'MKVMERGE', + cmd: mkvmergeCommand, + args: mkvmergeArgs, + parser: parseMkvmergeProgress + }); + const outputFinalization = finalizeOutputPathForCompletedEncode( + incompleteOutputPath, + preferredFinalOutputPath + ); + const finalizedOutputPath = outputFinalization.outputPath; + const outputOwner = String(settings?.movie_dir_owner || '').trim(); + chownRecursive(path.dirname(finalizedOutputPath), outputOwner); + if (outputFinalization.outputPathWithTimestamp) { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Finaler Merge-Output existierte bereits. Neuer Zielpfad: ${finalizedOutputPath}` + ); + } + await historyService.appendLog(normalizedJobId, 'SYSTEM', `Merge-Output finalisiert: ${finalizedOutputPath}`); + historyService.addJobOutputFolder(normalizedJobId, finalizedOutputPath).catch(() => {}); + + let sourceCleanupSummary = { + enabled: false, + deletedCount: 0, + missingCount: 0, + failedCount: 0, + deleted: [], + missing: [], + failed: [] + }; + if (deleteInputsAfterMerge) { + sourceCleanupSummary = await this.cleanupMultipartMergeSourceOutputs(sourceItems, { + mergeJobId: normalizedJobId + }); + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Merge-Input-Cleanup: gelöscht ${sourceCleanupSummary.deletedCount}, fehlend ${sourceCleanupSummary.missingCount}, Fehler ${sourceCleanupSummary.failedCount}.` + ); + if (sourceCleanupSummary.failedCount > 0) { + logger.warn('multipart-merge:source-cleanup-partial-failure', { + jobId: normalizedJobId, + failedCount: sourceCleanupSummary.failedCount, + failures: sourceCleanupSummary.failed + }); + } + } + + const handbrakeInfo = { + ...runInfo, + mode: 'multipart_merge', + tool: 'mkvmerge', + chapterPreparation: { + mode: chapterPreparationMode, + chapterCount: chapterPreparationEntries + }, + sourceItems, + sourceCleanup: sourceCleanupSummary, + outputPath: finalizedOutputPath + }; + await historyService.updateJob(normalizedJobId, { + handbrake_info_json: JSON.stringify(handbrakeInfo), + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + rip_successful: 1, + output_path: finalizedOutputPath, + error_message: null + }); + await historyService.generateJobNfo(normalizedJobId, { + mode: 'auto', + requireSettingEnabled: true + }).catch((nfoError) => { + logger.warn('job:nfo:auto-generate-failed', { + jobId: normalizedJobId, + mode: 'multipart_merge', + error: errorToMeta(nfoError) + }); + }); + if (mergeJob?.parent_job_id) { + await this.syncSeriesContainerStatusFromChildren(mergeJob.parent_job_id).catch((parentSyncError) => { + logger.warn('multipart-merge:parent-status-sync-failed', { + parentJobId: mergeJob.parent_job_id, + mergeJobId: normalizedJobId, + error: errorToMeta(parentSyncError) + }); + }); + } + if (Number(this.snapshot.activeJobId) === Number(normalizedJobId)) { + await this.setState('FINISHED', { + activeJobId: normalizedJobId, + progress: 100, + eta: null, + statusText: 'Merge abgeschlossen', + context: { + jobId: normalizedJobId, + mode: 'multipart_merge', + outputPath: finalizedOutputPath + } + }); + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === normalizedJobId) { + await this.setState('IDLE', { + finishingJobId: normalizedJobId, + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + }, 3000); + } else { + void this.pumpQueue(); + } + void this.notifyPushover('job_finished', { + title: 'Ripster - Merge abgeschlossen', + message: `${mergeJob.title || mergeJob.detected_title || `Job #${normalizedJobId}`} -> ${finalizedOutputPath}` + }); + } catch (error) { + if (runInfo) { + await historyService.updateJob(normalizedJobId, { + handbrake_info_json: JSON.stringify({ + ...runInfo, + mode: 'multipart_merge', + sourceItems + }) + }).catch(() => {}); + } + await this.failJob(normalizedJobId, 'ENCODING', error).catch((failError) => { + logger.error('multipart-merge:background-failJob-failed', { + jobId: normalizedJobId, + error: errorToMeta(failError) + }); + }); + } finally { + try { + fs.rmSync(mergeTmpDir, { recursive: true, force: true }); + } catch (_error) { + // ignore tmp cleanup errors + } + } + })(); + + return { + started: true, + stage: 'ENCODING', + outputPath: incompleteOutputPath + }; + } + + isSeriesBatchChildJob(jobLike = null) { + const plan = this.extractQueueJobPlan(jobLike); + return isSeriesBatchChildPlan(plan); + } + + resolveSeriesBatchParentJobId(jobLike = null) { + const plan = this.extractQueueJobPlan(jobLike); + const parentJobId = resolveSeriesBatchParentJobIdFromPlan( + plan, + jobLike?.parent_job_id + ); + return this.normalizeQueueJobId(parentJobId); + } + + async listSeriesBatchChildJobs(parentJobId) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + return []; + } + const db = await getDb(); + const rows = await db.all( + ` + SELECT * + FROM jobs + WHERE parent_job_id = ? + OR CAST(json_extract(encode_plan_json, '$.seriesBatchParentJobId') AS INTEGER) = ? + ORDER BY id ASC + `, + [normalizedParentJobId, normalizedParentJobId] + ); + return (Array.isArray(rows) ? rows : []).filter((row) => this.isSeriesBatchChildJob(row)); + } + + async updateSeriesBatchParentProgress(parentJobId, options = {}) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + return null; + } + + const parentJob = options?.parentJob && Number(options.parentJob?.id) === Number(normalizedParentJobId) + ? options.parentJob + : await historyService.getJobById(normalizedParentJobId); + if (!parentJob) { + return null; + } + if (!this.isSeriesBatchParentQueueAnchor(parentJob)) { + return null; + } + + const parentPlan = this.safeParseJson(parentJob.encode_plan_json); + if (!isSeriesBatchParentPlan(parentPlan) || !Array.isArray(parentPlan?.seriesBatchEpisodes)) { + this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + return null; + } + + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(parentPlan?.selectedTitleIds) + ? parentPlan.selectedTitleIds + : parentPlan.seriesBatchEpisodes.map((item) => item?.titleId) + ); + const episodes = buildSeriesBatchEpisodesFromPlan(parentJob, parentPlan, selectedTitleIds); + if (episodes.length === 0) { + this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + return null; + } + + const childJobs = await this.listSeriesBatchChildJobs(normalizedParentJobId); + const childByJobId = new Map(); + const childByTitleId = new Map(); + const childByEpisodeIndex = new Map(); + const sortedChildJobs = [...childJobs].sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0)); + for (const childJob of sortedChildJobs) { + const childId = normalizePositiveInteger(childJob?.id); + if (!childId) { + continue; + } + childByJobId.set(Number(childId), childJob); + const childPlan = this.safeParseJson(childJob?.encode_plan_json); + const childTitleId = normalizeReviewTitleId( + childPlan?.seriesBatchTitleId + ?? childPlan?.encodeInputTitleId + ?? null + ); + const childEpisodeIndex = normalizePositiveInteger( + childPlan?.seriesBatchChildIndex + ?? null + ); + if (childTitleId && !childByTitleId.has(Number(childTitleId))) { + childByTitleId.set(Number(childTitleId), childJob); + } + if (childEpisodeIndex && !childByEpisodeIndex.has(Number(childEpisodeIndex))) { + childByEpisodeIndex.set(Number(childEpisodeIndex), childJob); + } + } + + const normalizeEpisodeStatusFromChild = (childJob, fallbackStatus = 'QUEUED') => { + const rawStatus = String(childJob?.status || childJob?.last_state || '').trim().toUpperCase(); + if (rawStatus === 'FINISHED') { + return 'FINISHED'; + } + if (rawStatus === 'ERROR') { + return 'ERROR'; + } + if (rawStatus === 'CANCELLED') { + return 'CANCELLED'; + } + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'].includes(rawStatus)) { + return 'RUNNING'; + } + return normalizeSeriesEpisodeStatus(fallbackStatus, 'QUEUED'); + }; + + const syncedEpisodes = episodes.map((episode) => { + const episodeJobId = normalizePositiveInteger(episode?.jobId); + const episodeTitleId = normalizeReviewTitleId(episode?.titleId); + const episodeIndex = normalizePositiveInteger(episode?.episodeIndex); + const childJob = ( + (episodeJobId ? childByJobId.get(Number(episodeJobId)) : null) + || (episodeTitleId ? childByTitleId.get(Number(episodeTitleId)) : null) + || (episodeIndex ? childByEpisodeIndex.get(Number(episodeIndex)) : null) + || null + ); + + if (!childJob) { + return episode; + } + + const childId = normalizePositiveInteger(childJob?.id) || episodeJobId || null; + const nextStatus = normalizeEpisodeStatusFromChild(childJob, episode?.status); + const liveChildProgress = Number(this.jobProgress.get(Number(childId))?.progress); + const liveChildEta = this.jobProgress.get(Number(childId))?.eta ?? null; + const fallbackProgress = Number(episode?.progress || 0); + let nextProgress = Number.isFinite(fallbackProgress) ? fallbackProgress : 0; + if (nextStatus === 'FINISHED') { + nextProgress = 100; + } else if (nextStatus === 'RUNNING' && Number.isFinite(liveChildProgress)) { + nextProgress = Math.max(0, Math.min(100, Number(liveChildProgress))); + } else if (nextStatus === 'ERROR' || nextStatus === 'CANCELLED') { + nextProgress = Math.max(0, Math.min(100, nextProgress)); + } else { + nextProgress = Math.max(0, Math.min(100, nextProgress)); + } + + const childHandbrakeInfo = this.safeParseJson(childJob?.handbrake_info_json); + return { + ...episode, + jobId: childId, + status: nextStatus, + progress: Number(nextProgress.toFixed(2)), + eta: nextStatus === 'RUNNING' ? liveChildEta : null, + startedAt: String(childJob?.start_time || '').trim() || episode?.startedAt || null, + finishedAt: ( + nextStatus === 'FINISHED' + || nextStatus === 'ERROR' + || nextStatus === 'CANCELLED' + ) + ? (String(childJob?.end_time || '').trim() || episode?.finishedAt || null) + : null, + outputPath: String(childJob?.output_path || '').trim() || episode?.outputPath || null, + error: ( + nextStatus === 'ERROR' + || nextStatus === 'CANCELLED' + ) + ? (String(childJob?.error_message || '').trim() || episode?.error || null) + : null, + handbrakeInfo: childHandbrakeInfo && typeof childHandbrakeInfo === 'object' + ? childHandbrakeInfo + : (episode?.handbrakeInfo && typeof episode.handbrakeInfo === 'object' ? episode.handbrakeInfo : null) + }; + }); + + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, syncedEpisodes); + const totalCount = summary.totalCount; + const finishedCount = summary.finishedCount; + const cancelledCount = summary.cancelledCount; + const errorCount = summary.errorCount; + const runningCount = summary.runningCount; + const totalProgress = summary.overallProgress; + const allTerminal = runningCount === 0 && (finishedCount + cancelledCount + errorCount) === totalCount; + const summaryText = summary.summaryText; + const contextPatch = { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount, + finishedCount, + cancelledCount, + errorCount, + runningCount, + children: summary.children + } + }; + const nextSeriesBatchChildJobIds = normalizeReviewTitleIdList( + syncedEpisodes + .map((episode) => normalizePositiveInteger(episode?.jobId)) + .filter(Boolean) + ); + const nextCompletedTitleIds = normalizeReviewTitleIdList([ + ...(Array.isArray(parentPlan?.seriesBatchCompletedTitleIds) ? parentPlan.seriesBatchCompletedTitleIds : []), + ...syncedEpisodes + .filter((episode) => normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'FINISHED') + .map((episode) => episode?.titleId) + ]); + const nextPlan = { + ...parentPlan, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchEpisodes: syncedEpisodes, + seriesBatchChildJobIds: nextSeriesBatchChildJobIds, + seriesBatchCompletedTitleIds: nextCompletedTitleIds + }; + const previousHandbrakeInfo = this.safeParseJson(parentJob?.handbrake_info_json) || {}; + const nextHandbrakeInfo = { + ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), + mode: 'series_batch', + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + episodes: syncedEpisodes + } + }; + + const containerParentJobId = this.normalizeQueueJobId(parentJob?.parent_job_id); + + if (allTerminal) { + this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + for (const [childJobId, mappedParentJobId] of this.seriesBatchParentByChild.entries()) { + if (Number(mappedParentJobId) === Number(normalizedParentJobId)) { + this.seriesBatchParentByChild.delete(Number(childJobId)); + } + } + const seriesChildJobIdSet = new Set( + nextSeriesBatchChildJobIds + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + ); + const finalState = errorCount > 0 + ? 'ERROR' + : (cancelledCount > 0 ? 'CANCELLED' : 'FINISHED'); + let finalizedParentRawPath = String(parentJob?.raw_path || '').trim() || null; + if (finalState === 'FINISHED' && finalizedParentRawPath) { + const completedRawPath = buildCompletedRawPath(finalizedParentRawPath); + if (completedRawPath && completedRawPath !== finalizedParentRawPath) { + if (fs.existsSync(completedRawPath)) { + logger.warn('series-batch:raw-dir-finalize:target-exists', { + parentJobId: normalizedParentJobId, + sourceRawPath: finalizedParentRawPath, + targetRawPath: completedRawPath + }); + } else { + try { + fs.renameSync(finalizedParentRawPath, completedRawPath); + await historyService.updateRawPathByOldPath(finalizedParentRawPath, completedRawPath); + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `RAW-Ordner nach Abschluss aller Serien-Encodes finalisiert (Prefix entfernt): ${finalizedParentRawPath} -> ${completedRawPath}` + ); + finalizedParentRawPath = completedRawPath; + } catch (rawFinalizeError) { + logger.warn('series-batch:raw-dir-finalize:rename-failed', { + parentJobId: normalizedParentJobId, + sourceRawPath: finalizedParentRawPath, + targetRawPath: completedRawPath, + error: errorToMeta(rawFinalizeError) + }); + } + } + } + } + const finalErrorMessage = finalState === 'FINISHED' + ? null + : ( + finalState === 'ERROR' + ? `Serien-Batch beendet mit ${errorCount} Fehler(n).` + : 'Serien-Batch abgebrochen.' + ); + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: finalState, + last_state: finalState, + end_time: nowIso(), + error_message: finalErrorMessage, + ...(finalizedParentRawPath ? { raw_path: finalizedParentRawPath } : {}) + }); + // Remove stale queue entries that still target this parent batch. + this.queueEntries = this.queueEntries.filter((entry) => { + const action = String(entry?.action || '').trim().toUpperCase(); + const entryJobId = Number(entry?.jobId); + const isLegacyParentEntry = action === QUEUE_ACTIONS.START_SERIES_EPISODE + && entryJobId === Number(normalizedParentJobId); + const isSeriesChildEntry = (!entry?.type || entry.type === 'job') + && Number.isFinite(entryJobId) + && entryJobId > 0 + && seriesChildJobIdSet.has(entryJobId); + if (isLegacyParentEntry || isSeriesChildEntry) { + return false; + } + return true; + }); + if (Number(this.snapshot.activeJobId) === Number(normalizedParentJobId)) { + await this.setState(finalState, { + activeJobId: normalizedParentJobId, + progress: totalProgress, + eta: null, + statusText: summaryText, + context: { + ...(this.snapshot.context || {}), + jobId: normalizedParentJobId, + ...contextPatch + } + }); + } else { + await this.updateProgress(finalState, totalProgress, null, summaryText, normalizedParentJobId, { + contextPatch + }); + } + if (containerParentJobId) { + await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { + logger.warn('series-container:status-sync-from-batch-parent-final-failed', { + containerJobId: containerParentJobId, + batchParentJobId: normalizedParentJobId, + error: errorToMeta(syncError) + }); + }); + } + return { + parentJobId: normalizedParentJobId, + final: true, + state: finalState, + progress: totalProgress, + summaryText, + childCount: totalCount + }; + } + + const terminalCount = finishedCount + cancelledCount + errorCount; + const hasPendingChildren = terminalCount < totalCount; + const hasErrors = errorCount > 0; + const hasCancellations = cancelledCount > 0; + // Keep parent job in ENCODING while any child is still pending/running. + // Partial errors/cancellations are reflected via summary/error counters, + // but must not flip the parent state to terminal before all children end. + const nextState = hasPendingChildren + ? 'ENCODING' + : (hasErrors ? 'ERROR' : (hasCancellations ? 'CANCELLED' : 'READY_TO_ENCODE')); + const nextLastState = hasPendingChildren ? 'ENCODING' : nextState; + const nextErrorMessage = hasErrors + ? `Serien-Batch enthält ${errorCount} Fehler-Episode(n).` + : ( + hasCancellations + ? `Serien-Batch enthält ${cancelledCount} abgebrochene Episode(n).` + : null + ); + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: nextState, + last_state: nextLastState, + end_time: null, + error_message: nextErrorMessage + }); + await this.updateProgress(nextState, totalProgress, null, summaryText, normalizedParentJobId, { + contextPatch + }); + if (containerParentJobId) { + await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { + logger.warn('series-container:status-sync-from-batch-parent-progress-failed', { + containerJobId: containerParentJobId, + batchParentJobId: normalizedParentJobId, + error: errorToMeta(syncError) + }); + }); + } + return { + parentJobId: normalizedParentJobId, + final: false, + state: nextState, + progress: totalProgress, + summaryText, + childCount: totalCount + }; + } + + async startSeriesBatchFromPrepared(parentJobId, parentJob, options = {}) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + return { handled: false }; + } + const sourceJob = parentJob && Number(parentJob?.id) === Number(normalizedParentJobId) + ? parentJob + : await historyService.getJobById(normalizedParentJobId); + if (!sourceJob) { + return { handled: false }; + } + + const parentPlan = this.safeParseJson(sourceJob.encode_plan_json); + if (isSeriesBatchChildPlan(parentPlan)) { + return { handled: false }; + } + const planMode = String(parentPlan?.mode || '').trim().toLowerCase(); + const isPreRipPlan = planMode === 'pre_rip' || Boolean(parentPlan?.preRip); + if (isPreRipPlan) { + return { handled: false }; + } + const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { + encodePlan: parentPlan + }); + const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const activeContextForJob = Number(this.snapshot?.activeJobId) === Number(normalizedParentJobId) + ? (this.snapshot?.context || {}) + : null; + const selectedMetadata = resolveSelectedMetadataForJob(sourceJob, analyzeContext, activeContextForJob); + const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const selectedTitleIds = resolveSeriesBatchSelectedTitleIdsFromPlan(parentPlan); + if (!isSeriesDvd || selectedTitleIds.length <= 1) { + return { handled: false }; + } + + const allTitles = Array.isArray(parentPlan?.titles) ? parentPlan.titles : []; + const validSelectedTitleIds = selectedTitleIds.filter((titleId) => + allTitles.some((title) => Number(title?.id) === Number(titleId)) + ); + if (validSelectedTitleIds.length <= 1) { + return { handled: false }; + } + const nextEpisodes = buildSeriesBatchEpisodesFromPlan(sourceJob, parentPlan, validSelectedTitleIds); + if (nextEpisodes.length <= 1) { + return { handled: false }; + } + const preparedEpisodes = []; + for (let index = 0; index < nextEpisodes.length; index += 1) { + const episode = nextEpisodes[index]; + const childPlan = buildSeriesBatchChildPlan( + parentPlan, + episode.titleId, + normalizedParentJobId, + index, + nextEpisodes.length + ); + const episodeTitle = String(episode?.label || '').trim() + || resolveSeriesBatchChildDisplayTitle(sourceJob, parentPlan, episode.titleId, index); + + const childJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: 'READY_TO_ENCODE', + detectedTitle: episodeTitle, + mediaType: sourceJob.media_type || mediaProfile, + jobKind: 'dvd_series_child' + }); + const childJobId = this.normalizeQueueJobId(childJob?.id); + if (!childJobId) { + const error = new Error('Serien-Episode konnte nicht als Child-Job angelegt werden.'); + error.statusCode = 500; + throw error; + } + + await historyService.updateJob(childJobId, { + parent_job_id: Number(normalizedParentJobId), + media_type: sourceJob.media_type || mediaProfile, + job_kind: 'dvd_series_child', + title: episodeTitle, + detected_title: episodeTitle, + year: sourceJob.year ?? null, + imdb_id: sourceJob.imdb_id || null, + poster_url: sourceJob.poster_url || null, + omdb_json: null, + selected_from_omdb: 0, + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + end_time: null, + output_path: null, + disc_device: sourceJob.disc_device || null, + raw_path: sourceJob.raw_path || null, + rip_successful: Number(sourceJob.rip_successful || 0), + makemkv_info_json: sourceJob.makemkv_info_json || null, + handbrake_info_json: null, + mediainfo_info_json: sourceJob.mediainfo_info_json || null, + encode_plan_json: JSON.stringify({ + ...childPlan, + reviewConfirmed: true, + reviewConfirmedAt: String(childPlan?.reviewConfirmedAt || '').trim() || nowIso() + }), + encode_input_path: childPlan?.encodeInputPath || sourceJob.encode_input_path || sourceJob.raw_path || null, + encode_review_confirmed: 1 + }); + await historyService.appendLog( + childJobId, + 'SYSTEM', + `Serien-Episode als Queue-Subjob vorbereitet (Parent #${normalizedParentJobId}, Episode ${episode.episodeIndex}/${nextEpisodes.length}).` + ); + preparedEpisodes.push({ + ...episode, + jobId: Number(childJobId), + status: 'QUEUED', + progress: 0, + eta: null, + startedAt: null, + finishedAt: null, + outputPath: null, + error: null + }); + } + const nextParentPlan = { + ...parentPlan, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchParentJobId: null, + seriesBatchChildJobIds: normalizeReviewTitleIdList(preparedEpisodes.map((episode) => episode?.jobId)), + seriesBatchCompletedTitleIds: [], + seriesBatchDispatchedAt: nowIso(), + seriesBatchTotalChildren: preparedEpisodes.length, + seriesBatchEpisodes: preparedEpisodes, + reviewConfirmed: true + }; + const progressSummary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, preparedEpisodes); + const parentBatchState = preparedEpisodes.length > 0 ? 'ENCODING' : 'READY_TO_ENCODE'; + + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextParentPlan), + status: parentBatchState, + last_state: parentBatchState, + end_time: null, + error_message: null, + encode_review_confirmed: 1 + }); + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `Serien-Batch gestartet: ${preparedEpisodes.length} Episode(n) als reguläre Queue-Subjobs angelegt.` + ); + + let queuedChildren = 0; + let startedChildren = 0; + for (let index = 0; index < preparedEpisodes.length; index += 1) { + const episode = preparedEpisodes[index]; + const episodeJobId = this.normalizeQueueJobId(episode?.jobId); + if (!episodeJobId) { + continue; + } + const queueResult = await this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + episodeJobId, + () => this.startPreparedJob(episodeJobId, { immediate: true }), + { + poolType: 'film', + allowDuplicateJobEntries: true, + forceQueue: true, + uniqueEntryKey: `series_episode_child_${episodeJobId}`, + entryData: { + seriesEpisodeIndex: episode.episodeIndex, + seriesTitleId: episode.titleId, + seriesEpisodeLabel: episode.label + } + } + ); + if (queueResult?.queued) { + queuedChildren += 1; + } else if (queueResult?.started) { + startedChildren += 1; + } + } + + await this.updateProgress( + parentBatchState, + progressSummary.overallProgress, + null, + progressSummary.summaryText, + normalizedParentJobId, + { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: progressSummary.totalCount, + finishedCount: progressSummary.finishedCount, + cancelledCount: progressSummary.cancelledCount, + errorCount: progressSummary.errorCount, + runningCount: progressSummary.runningCount, + children: progressSummary.children + } + } + } + ); + const containerParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id); + if (containerParentJobId) { + await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { + logger.warn('series-container:status-sync-on-batch-start-failed', { + containerJobId: containerParentJobId, + batchParentJobId: normalizedParentJobId, + error: errorToMeta(syncError) + }); + }); + } + return { + handled: true, + result: { + started: startedChildren > 0, + stage: parentBatchState, + seriesBatch: true, + childCount: nextEpisodes.length, + queuedChildren, + startedChildren + } + }; + } + + async startSeriesBatchEpisode(parentJobId, options = {}) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + const error = new Error('Ungültige Serien-Job-ID.'); + error.statusCode = 400; + throw error; + } + + const sourceParentJob = await historyService.getJobById(normalizedParentJobId); + if (!sourceParentJob) { + const error = new Error(`Job ${normalizedParentJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const sourcePlan = this.safeParseJson(sourceParentJob.encode_plan_json); + if (!isSeriesBatchParentPlan(sourcePlan) || !Array.isArray(sourcePlan?.seriesBatchEpisodes)) { + const error = new Error(`Job ${normalizedParentJobId} ist kein Serien-Batch-Parent.`); + error.statusCode = 409; + throw error; + } + + const requestedEpisodeIndex = normalizePositiveInteger(options?.episodeIndex); + const requestedTitleId = normalizeReviewTitleId(options?.titleId); + const episodes = buildSeriesBatchEpisodesFromPlan( + sourceParentJob, + sourcePlan, + sourcePlan.selectedTitleIds || sourcePlan.seriesBatchEpisodes.map((item) => item?.titleId) + ); + const targetEpisodeIdx = episodes.findIndex((item) => ( + (requestedEpisodeIndex && Number(item?.episodeIndex) === Number(requestedEpisodeIndex)) + || (requestedTitleId && Number(item?.titleId) === Number(requestedTitleId)) + )); + const fallbackEpisodeIdx = episodes.findIndex((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'QUEUED'); + const episodeIndex = targetEpisodeIdx >= 0 ? targetEpisodeIdx : fallbackEpisodeIdx; + if (episodeIndex < 0) { + return { + started: false, + seriesBatch: true, + stage: 'FINISHED', + reason: 'no_queued_episode' + }; + } + const episode = episodes[episodeIndex]; + const episodeStatus = normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED'); + if (episodeStatus === 'FINISHED') { + return { + started: false, + seriesBatch: true, + stage: 'ENCODING', + reason: 'already_finished' + }; + } + + if (this.activeProcesses.has(Number(normalizedParentJobId))) { + const error = new Error(`Serien-Episode kann nicht gestartet werden: Job #${normalizedParentJobId} läuft bereits.`); + error.statusCode = 409; + throw error; + } + + const now = nowIso(); + const runningEpisodes = episodes.map((item, idx) => ( + idx === episodeIndex + ? { + ...item, + status: 'RUNNING', + progress: 0, + eta: null, + startedAt: now, + finishedAt: null, + error: null + } + : item + )); + const runningPlan = { + ...sourcePlan, + seriesBatchEpisodes: runningEpisodes, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchChildJobIds: [] + }; + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(runningPlan), + status: 'ENCODING', + last_state: 'ENCODING', + end_time: null, + error_message: null, + encode_review_confirmed: 1 + }); + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `Serien-Episode gestartet (${episode.episodeIndex}/${runningEpisodes.length}): ${episode.label}` + ); + + const runningSummary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, runningEpisodes); + await this.updateProgress('ENCODING', runningSummary.overallProgress, null, runningSummary.summaryText, normalizedParentJobId, { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: runningSummary.totalCount, + finishedCount: runningSummary.finishedCount, + cancelledCount: runningSummary.cancelledCount, + errorCount: runningSummary.errorCount, + runningCount: runningSummary.runningCount, + children: runningSummary.children + } + } + }); + + const episodePlanRaw = buildSeriesBatchChildPlan( + runningPlan, + episode.titleId, + normalizedParentJobId, + episodeIndex, + runningEpisodes.length + ); + const manualSelectionByTitle = runningPlan?.manualTrackSelectionByTitle + && typeof runningPlan.manualTrackSelectionByTitle === 'object' + ? runningPlan.manualTrackSelectionByTitle + : {}; + const episodeManualSelection = manualSelectionByTitle[String(episode.titleId)] + || manualSelectionByTitle[Number(episode.titleId)] + || null; + const episodeManualSelectionByTitle = episodeManualSelection && typeof episodeManualSelection === 'object' + ? { + [String(episode.titleId)]: { + ...episodeManualSelection, + titleId: Number(episode.titleId) + } + } + : {}; + const episodePlan = { + ...episodePlanRaw, + seriesBatchParent: false, + seriesBatchChild: false, + seriesBatchParentJobId: null, + seriesBatchChildIndex: null, + seriesBatchChildCount: null, + seriesBatchTitleId: null, + seriesBatchVirtualEpisode: true, + manualTrackSelection: episodeManualSelection || null, + manualTrackSelectionByTitle: episodeManualSelectionByTitle + }; + + try { + const episodeResult = await this.startEncodingFromPrepared(normalizedParentJobId, { + encodePlanOverride: episodePlan, + seriesBatchEpisodeContext: { + parentJobId: normalizedParentJobId, + episodeIndex: episode.episodeIndex, + episodeLabel: episode.label, + episodeCount: runningEpisodes.length + } + }); + + const postJob = await historyService.getJobById(normalizedParentJobId); + const postPlanRaw = this.safeParseJson(postJob?.encode_plan_json); + const postPlan = postPlanRaw && typeof postPlanRaw === 'object' ? postPlanRaw : runningPlan; + const postEpisodes = buildSeriesBatchEpisodesFromPlan( + postJob || sourceParentJob, + postPlan, + postPlan.selectedTitleIds || runningEpisodes.map((item) => item.titleId) + ).map((item, idx) => ( + idx === episodeIndex + ? { + ...item, + status: 'FINISHED', + progress: 100, + eta: null, + finishedAt: nowIso(), + error: null, + outputPath: episodeResult?.outputPath || item?.outputPath || null, + trackSelection: episodeResult?.trackSelection || item?.trackSelection || null, + handbrakeInfo: episodeResult?.handbrakeInfo || item?.handbrakeInfo || null + } + : item + )); + const nextPlan = { + ...postPlan, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchChildJobIds: [], + seriesBatchCompletedTitleIds: normalizeReviewTitleIdList([ + ...(Array.isArray(postPlan?.seriesBatchCompletedTitleIds) ? postPlan.seriesBatchCompletedTitleIds : []), + episode.titleId, + ...postEpisodes + .filter((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'FINISHED') + .map((item) => item?.titleId) + ]), + seriesBatchEpisodes: postEpisodes + }; + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, postEpisodes); + const allFinished = postEpisodes.length > 0 && postEpisodes.every((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'FINISHED'); + const anyErrors = postEpisodes.some((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'ERROR'); + const anyCancelled = postEpisodes.some((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'CANCELLED'); + + const previousHandbrakeInfo = this.safeParseJson(postJob?.handbrake_info_json) || {}; + const nextHandbrakeInfo = { + ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), + mode: 'series_batch', + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + episodes: postEpisodes + } + }; + + const nextState = allFinished + ? (anyErrors ? 'ERROR' : (anyCancelled ? 'CANCELLED' : 'FINISHED')) + : 'READY_TO_ENCODE'; + const nextLastState = allFinished ? nextState : 'ENCODING'; + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: nextState, + last_state: nextLastState, + end_time: allFinished ? nowIso() : null, + error_message: allFinished && nextState !== 'FINISHED' + ? (nextState === 'ERROR' ? 'Serien-Batch beendet mit Fehlern.' : 'Serien-Batch abgebrochen.') + : null, + output_path: episodeResult?.outputPath || postJob?.output_path || null + }); + + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `Serien-Episode abgeschlossen (${episode.episodeIndex}/${postEpisodes.length}): ${episode.label}${episodeResult?.outputPath ? ` -> ${episodeResult.outputPath}` : ''}` + ); + + await this.updateProgress(nextState, summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + children: summary.children + } + } + }); + + if (allFinished && nextState === 'FINISHED') { + void this.notifyPushover('job_finished', { + title: 'Ripster - Job abgeschlossen', + message: `${sourceParentJob.title || sourceParentJob.detected_title || `Job #${normalizedParentJobId}`} (${summary.finishedCount} Episode(n))` + }); + const parentMediaProfile = this.resolveMediaProfileForJob(sourceParentJob, { encodePlan: nextPlan }); + const parentSettings = await settingsService.getEffectiveSettingsMap(parentMediaProfile); + void this.ejectDriveIfEnabled(parentSettings); + + if (Number(this.snapshot.activeJobId) === Number(normalizedParentJobId)) { + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === normalizedParentJobId) { + await this.setState('IDLE', { + finishingJobId: normalizedParentJobId, + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + }, 3000); + } + } + + void this.emitQueueChanged(); + void this.pumpQueue(); + return { + started: true, + seriesBatch: true, + stage: nextState, + finished: allFinished, + outputPath: episodeResult?.outputPath || null + }; + } catch (error) { + const cancelled = this.cancelRequestedByJob.has(Number(normalizedParentJobId)) + || String(error?.runInfo?.status || '').trim().toUpperCase() === 'CANCELLED'; + const postJob = await historyService.getJobById(normalizedParentJobId); + const postPlanRaw = this.safeParseJson(postJob?.encode_plan_json); + const postPlan = postPlanRaw && typeof postPlanRaw === 'object' ? postPlanRaw : runningPlan; + const postEpisodes = buildSeriesBatchEpisodesFromPlan( + postJob || sourceParentJob, + postPlan, + postPlan.selectedTitleIds || runningEpisodes.map((item) => item.titleId) + ).map((item, idx) => { + const itemStatus = normalizeSeriesEpisodeStatus(item?.status, 'QUEUED'); + if (idx === episodeIndex) { + return { + ...item, + status: cancelled ? 'CANCELLED' : 'ERROR', + progress: itemStatus === 'FINISHED' ? 100 : 0, + eta: null, + finishedAt: nowIso(), + error: error?.message || (cancelled ? 'Vom Benutzer abgebrochen.' : 'Encode fehlgeschlagen.') + }; + } + if (itemStatus === 'QUEUED' || itemStatus === 'RUNNING') { + return { + ...item, + status: cancelled ? 'CANCELLED' : 'ERROR', + progress: 0, + eta: null, + finishedAt: nowIso(), + error: cancelled ? 'Batch abgebrochen.' : 'Batch wegen Fehler gestoppt.' + }; + } + return item; + }); + const nextPlan = { + ...postPlan, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchChildJobIds: [], + seriesBatchCompletedTitleIds: normalizeReviewTitleIdList( + Array.isArray(postPlan?.seriesBatchCompletedTitleIds) ? postPlan.seriesBatchCompletedTitleIds : [] + ), + seriesBatchEpisodes: postEpisodes + }; + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, postEpisodes); + const nextState = cancelled ? 'CANCELLED' : 'ERROR'; + + const previousHandbrakeInfo = this.safeParseJson(postJob?.handbrake_info_json) || {}; + const nextHandbrakeInfo = { + ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), + mode: 'series_batch', + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + episodes: postEpisodes + } + }; + + const queueBefore = this.queueEntries.length; + this.queueEntries = this.queueEntries.filter((entry) => !( + Number(entry?.jobId) === Number(normalizedParentJobId) + && String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_SERIES_EPISODE + )); + const removedSeriesEntries = Math.max(0, queueBefore - this.queueEntries.length); + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: nextState, + last_state: nextState, + end_time: nowIso(), + error_message: error?.message || (cancelled ? 'Vom Benutzer abgebrochen.' : 'Serien-Batch fehlgeschlagen.') + }); + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `Serien-Batch ${cancelled ? 'abgebrochen' : 'fehlgeschlagen'} nach Episode ${episode.episodeIndex}/${postEpisodes.length} (${episode.label}). Queue-Einträge entfernt: ${removedSeriesEntries}.` + ); + await this.updateProgress(nextState, summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + children: summary.children + } + } + }); + await this.emitQueueChanged(); + throw error; + } + } + + findQueueEntryIndexByJobId(jobId) { + return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId)); + } + + removeDetachedQueueAutomationEntriesForJob(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + return 0; + } + + const allowedPhases = Array.isArray(options?.phases) + ? new Set( + options.phases + .map((value) => String(value || '').trim().toLowerCase()) + .filter((value) => value === 'pre_encode' || value === 'post_encode') + ) + : null; + const hasPhaseFilter = Boolean(allowedPhases && allowedPhases.size > 0); + + const queueLengthBefore = this.queueEntries.length; + this.queueEntries = this.queueEntries.filter((entry) => { + const entryType = String(entry?.type || '').trim().toLowerCase(); + if (entryType !== 'script' && entryType !== 'chain') { + return true; + } + const sourceJobId = this.normalizeQueueJobId(entry?.sourceJobId); + if (sourceJobId !== normalizedJobId) { + return true; + } + if (!hasPhaseFilter) { + return false; + } + const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase(); + return !allowedPhases.has(sourcePhase); + }); + + return Math.max(0, queueLengthBefore - this.queueEntries.length); + } + + normalizeQueueChainIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = Math.trunc(value); + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; + } + + normalizeQueueAutomationItems(rawItems = []) { + const items = Array.isArray(rawItems) ? rawItems : []; + const seen = new Set(); + const output = []; + for (const item of items) { + if (!item || typeof item !== 'object') { + continue; + } + const type = String(item.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') { + continue; + } + const id = Number(item.id ?? item.scriptId ?? item.chainId); + if (!Number.isFinite(id) || id <= 0) { + continue; + } + const normalizedId = Math.trunc(id); + const key = `${type}:${normalizedId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push({ + type, + id: normalizedId, + name: String(item.name || '').trim() || null + }); + } + return output; + } + + extractDetachedQueueAutomationFromPlan(plan = null) { + const sourcePlan = plan && typeof plan === 'object' ? plan : {}; + const buildNameMap = (items = []) => { + const map = new Map(); + for (const item of (Array.isArray(items) ? items : [])) { + if (!item || typeof item !== 'object') { + continue; + } + const id = Number(item.id ?? item.scriptId ?? item.chainId); + const name = String(item.name || item.scriptName || item.chainName || '').trim(); + if (!Number.isFinite(id) || id <= 0 || !name) { + continue; + } + map.set(Math.trunc(id), name); + } + return map; + }; + const buildFallbackItems = (scriptIds, chainIds, scriptNameMap, chainNameMap) => { + const items = []; + for (const scriptId of normalizeScriptIdList(scriptIds || [])) { + items.push({ + type: 'script', + id: scriptId, + name: scriptNameMap.get(scriptId) || null + }); + } + for (const chainId of this.normalizeQueueChainIdList(chainIds || [])) { + items.push({ + type: 'chain', + id: chainId, + name: chainNameMap.get(chainId) || null + }); + } + return this.normalizeQueueAutomationItems(items); + }; + + const preFromItems = this.normalizeQueueAutomationItems(sourcePlan.preEncodeItems || []); + const postFromItems = this.normalizeQueueAutomationItems(sourcePlan.postEncodeItems || []); + const preScriptNameMap = buildNameMap(sourcePlan.preEncodeScripts || []); + const postScriptNameMap = buildNameMap(sourcePlan.postEncodeScripts || []); + const preChainNameMap = buildNameMap(sourcePlan.preEncodeChains || []); + const postChainNameMap = buildNameMap(sourcePlan.postEncodeChains || []); + + const preItems = preFromItems.length > 0 + ? preFromItems + : buildFallbackItems( + sourcePlan.preEncodeScriptIds || [], + sourcePlan.preEncodeChainIds || [], + preScriptNameMap, + preChainNameMap + ); + const postItems = postFromItems.length > 0 + ? postFromItems + : buildFallbackItems( + sourcePlan.postEncodeScriptIds || [], + sourcePlan.postEncodeChainIds || [], + postScriptNameMap, + postChainNameMap + ); + + return { preItems, postItems }; + } + + buildDetachedQueueEntryFromAutomationItem(item, options = {}) { + const source = item && typeof item === 'object' ? item : {}; + const phase = String(options?.phase || '').trim().toLowerCase() === 'post' ? 'post' : 'pre'; + const jobId = Number(options?.jobId); + const sourceJobId = Number.isFinite(jobId) && jobId > 0 ? Math.trunc(jobId) : null; + if (source.type === 'script') { + return { + id: this.queueEntrySeq++, + type: 'script', + scriptId: Number(source.id), + scriptName: String(source.name || '').trim() || null, + sourceJobId, + sourcePhase: phase === 'pre' ? 'pre_encode' : 'post_encode', + enqueuedAt: nowIso() + }; + } + return { + id: this.queueEntrySeq++, + type: 'chain', + chainId: Number(source.id), + chainName: String(source.name || '').trim() || null, + sourceJobId, + sourcePhase: phase === 'pre' ? 'pre_encode' : 'post_encode', + enqueuedAt: nowIso() + }; + } + + extractQueueJobPlan(row) { + const source = row && typeof row === 'object' ? row : null; + if (!source) { + return null; + } + if (source.encodePlan && typeof source.encodePlan === 'object') { + return source.encodePlan; + } + if (source.encode_plan_json) { + try { + const parsed = JSON.parse(source.encode_plan_json); + if (parsed && typeof parsed === 'object') { + return parsed; + } + } catch (_) { + // ignore parse errors for queue decorations + } + } + return null; + } + + async buildQueueJobScriptMeta(rows = []) { + const list = Array.isArray(rows) ? rows : []; + const byJobId = new Map(); + const allScriptIds = new Set(); + const allChainIds = new Set(); + const scriptNameHints = new Map(); + const chainNameHints = new Map(); + + const addScriptHints = (items) => { + for (const item of (Array.isArray(items) ? items : [])) { + if (!item || typeof item !== 'object') { + continue; + } + const id = normalizeScriptIdList([item.id ?? item.scriptId])[0] || null; + const name = String(item.name || item.scriptName || '').trim(); + if (!id) { + continue; + } + allScriptIds.add(id); + if (name) { + scriptNameHints.set(id, name); + } + } + }; + + const addChainHints = (items) => { + for (const item of (Array.isArray(items) ? items : [])) { + if (!item || typeof item !== 'object') { + continue; + } + const id = this.normalizeQueueChainIdList([item.id ?? item.chainId])[0] || null; + const name = String(item.name || item.chainName || '').trim(); + if (!id) { + continue; + } + allChainIds.add(id); + if (name) { + chainNameHints.set(id, name); + } + } + }; + + for (const row of list) { + const jobId = this.normalizeQueueJobId(row?.id); + if (!jobId) { + continue; + } + const plan = this.extractQueueJobPlan(row); + if (!plan) { + continue; + } + + const preScriptIds = normalizeScriptIdList([ + ...normalizeScriptIdList(plan?.preEncodeScriptIds || []), + ...normalizeScriptIdList((Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts : []).map((item) => item?.id ?? item?.scriptId)) + ]); + const postScriptIds = normalizeScriptIdList([ + ...normalizeScriptIdList(plan?.postEncodeScriptIds || []), + ...normalizeScriptIdList((Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts : []).map((item) => item?.id ?? item?.scriptId)) + ]); + const preChainIds = this.normalizeQueueChainIdList([ + ...this.normalizeQueueChainIdList(plan?.preEncodeChainIds || []), + ...this.normalizeQueueChainIdList((Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains : []).map((item) => item?.id ?? item?.chainId)) + ]); + const postChainIds = this.normalizeQueueChainIdList([ + ...this.normalizeQueueChainIdList(plan?.postEncodeChainIds || []), + ...this.normalizeQueueChainIdList((Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains : []).map((item) => item?.id ?? item?.chainId)) + ]); + + addScriptHints(plan?.preEncodeScripts); + addScriptHints(plan?.postEncodeScripts); + addChainHints(plan?.preEncodeChains); + addChainHints(plan?.postEncodeChains); + + for (const id of preScriptIds) allScriptIds.add(id); + for (const id of postScriptIds) allScriptIds.add(id); + for (const id of preChainIds) allChainIds.add(id); + for (const id of postChainIds) allChainIds.add(id); + + byJobId.set(jobId, { + preScriptIds, + postScriptIds, + preChainIds, + postChainIds + }); + } + + if (byJobId.size === 0) { + return new Map(); + } + + const scriptNameById = new Map(); + const chainNameById = new Map(); + for (const [id, name] of scriptNameHints.entries()) { + scriptNameById.set(id, name); + } + for (const [id, name] of chainNameHints.entries()) { + chainNameById.set(id, name); + } + + if (allScriptIds.size > 0) { + const scriptService = require('./scriptService'); + try { + const scripts = await scriptService.resolveScriptsByIds(Array.from(allScriptIds), { strict: false }); + for (const script of scripts) { + const id = Number(script?.id); + const name = String(script?.name || '').trim(); + if (Number.isFinite(id) && id > 0 && name) { + scriptNameById.set(id, name); + } + } + } catch (error) { + logger.warn('queue:script-summary:resolve-failed', { error: errorToMeta(error) }); + } + } + + if (allChainIds.size > 0) { + const scriptChainService = require('./scriptChainService'); + try { + const chains = await scriptChainService.getChainsByIds(Array.from(allChainIds)); + for (const chain of chains) { + const id = Number(chain?.id); + const name = String(chain?.name || '').trim(); + if (Number.isFinite(id) && id > 0 && name) { + chainNameById.set(id, name); + } + } + } catch (error) { + logger.warn('queue:chain-summary:resolve-failed', { error: errorToMeta(error) }); + } + } + + const output = new Map(); + for (const [jobId, data] of byJobId.entries()) { + const preScripts = data.preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`); + const postScripts = data.postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`); + const preChains = data.preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`); + const postChains = data.postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`); + const hasScripts = preScripts.length > 0 || postScripts.length > 0; + const hasChains = preChains.length > 0 || postChains.length > 0; + output.set(jobId, { + hasScripts, + hasChains, + summary: { + preScripts, + postScripts, + preChains, + postChains + } + }); + } + return output; + } + + async getQueueSnapshot() { + const [maxParallelJobs, maxParallelCdEncodes, maxTotalEncodes, cdBypassesQueue] = await Promise.all([ + this.getMaxParallelJobs(), + this.getMaxParallelCdEncodes(), + this.getMaxTotalEncodes(), + this.getCdBypassesQueue() + ]); + const [runningJobs, idleJobsRaw] = await Promise.all([ + historyService.getRunningJobs(), + historyService.getQueueIdleJobs() + ]); + const visibleRunningJobs = (Array.isArray(runningJobs) ? runningJobs : []).filter((job) => ( + !this.isContainerHistoryJob(job) + && !this.isSeriesBatchParentQueueAnchor(job) + )); + const runningPoolUsage = this.buildRunningPoolUsage(runningJobs); + const runningEncodeCount = runningPoolUsage.filmRunning; + const runningCdCount = runningPoolUsage.audioRunning; + const queuedJobIds = this.queueEntries + .filter((entry) => !entry.type || entry.type === 'job') + .map((entry) => Number(entry.jobId)) + .filter((id) => Number.isFinite(id) && id > 0); + const queuedJobIdSet = new Set(queuedJobIds); + const runningJobIdSet = new Set( + visibleRunningJobs + .map((job) => Number(job?.id)) + .filter((id) => Number.isFinite(id) && id > 0) + ); + const idleJobs = (Array.isArray(idleJobsRaw) ? idleJobsRaw : []).filter((job) => { + if (this.isContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) { + return false; + } + const jobId = Number(job?.id); + if (!Number.isFinite(jobId) || jobId <= 0) { + return false; + } + return !queuedJobIdSet.has(jobId) && !runningJobIdSet.has(jobId); + }); + const queuedRows = queuedJobIds.length > 0 + ? await historyService.getJobsByIds(queuedJobIds) + : []; + const queuedById = new Map(queuedRows.map((row) => [Number(row.id), row])); + const queuedChainNameById = new Map(); + const queuedChainIdsNeedingName = this.normalizeQueueChainIdList( + this.queueEntries + .filter((entry) => String(entry?.type || '').trim().toLowerCase() === 'chain') + .filter((entry) => !String(entry?.chainName || '').trim()) + .map((entry) => entry?.chainId) + ); + if (queuedChainIdsNeedingName.length > 0) { + const scriptChainService = require('./scriptChainService'); + try { + const chains = await scriptChainService.getChainsByIds(queuedChainIdsNeedingName); + for (const chain of (Array.isArray(chains) ? chains : [])) { + const chainId = Number(chain?.id); + const chainName = String(chain?.name || '').trim(); + if (!Number.isFinite(chainId) || chainId <= 0 || !chainName) { + continue; + } + queuedChainNameById.set(Math.trunc(chainId), chainName); + } + } catch (error) { + logger.warn('queue:snapshot:chain-name-resolve-failed', { + chainIds: queuedChainIdsNeedingName, + error: errorToMeta(error) + }); + } + } + const scriptMetaByJobId = new Map(); + const resolveRunningSeriesEpisodeSubtitle = (job) => { + if (!this.isSeriesBatchParentQueueAnchor(job)) { + return null; + } + const plan = this.safeParseJson(job?.encode_plan_json); + const episodes = Array.isArray(plan?.seriesBatchEpisodes) ? plan.seriesBatchEpisodes : []; + if (episodes.length === 0) { + return null; + } + const runningEpisode = episodes.find((episode) => ( + normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'RUNNING' + )) || null; + const candidate = runningEpisode || episodes.find((episode) => ( + normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'QUEUED' + )) || null; + const label = String(candidate?.label || '').trim(); + return label || null; + }; + const resolveRunningSeriesSeasonDiscSubtitle = (job) => { + const status = String(job?.status || job?.last_state || '').trim().toUpperCase(); + if (status !== 'RIPPING') { + return null; + } + + const encodePlan = this.safeParseJson(job?.encode_plan_json); + const makemkvInfo = this.safeParseJson(job?.makemkv_info_json); + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, null); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo + }); + const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + if (!isSeriesDvd) { + return null; + } + + const seasonNumber = normalizePositiveInteger( + selectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ); + const discNumber = normalizePositiveInteger( + selectedMetadata?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + const subtitleParts = []; + if (seasonNumber) { + subtitleParts.push(`Staffel ${seasonNumber}`); + } + if (discNumber) { + subtitleParts.push(`Disk ${discNumber}`); + } + return subtitleParts.length > 0 ? subtitleParts.join(' | ') : null; + }; + const resolveRunningQueueSubtitle = (job) => ( + resolveRunningSeriesEpisodeSubtitle(job) + || resolveRunningSeriesSeasonDiscSubtitle(job) + || null + ); + + const queue = { + maxParallelJobs, + maxParallelCdEncodes, + maxTotalEncodes, + cdBypassesQueue, + runningCount: runningEncodeCount, + runningCdCount, + idleCount: idleJobs.length, + runningJobs: visibleRunningJobs.map((job) => ({ + jobId: Number(job.id), + title: job.title || job.detected_title || `Job #${job.id}`, + subtitle: resolveRunningQueueSubtitle(job), + status: job.status, + lastState: job.last_state || null, + poolType: this.resolveQueuePoolTypeForJob(job), + hasScripts: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasScripts), + hasChains: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasChains), + scriptSummary: scriptMetaByJobId.get(Number(job.id))?.summary || null + })), + idleJobs: idleJobs.map((job) => ({ + jobId: Number(job.id), + title: job.title || job.detected_title || `Job #${job.id}`, + status: job.status, + lastState: job.last_state || null, + poolType: this.resolveQueuePoolTypeForJob(job), + hasScripts: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasScripts), + hasChains: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasChains), + scriptSummary: scriptMetaByJobId.get(Number(job.id))?.summary || null + })), + queuedJobs: this.queueEntries.map((entry, index) => { + const entryType = entry.type || 'job'; + const base = { + entryId: entry.id, + position: index + 1, + type: entryType, + enqueuedAt: entry.enqueuedAt + }; + const sourceJobId = Number(entry?.sourceJobId); + const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase(); + const sourcePhaseLabel = sourcePhase === 'post_encode' + ? 'Post-Encode' + : (sourcePhase === 'pre_encode' ? 'Pre-Encode' : null); + const sourceSubtitle = Number.isFinite(sourceJobId) && sourceJobId > 0 + ? `${sourcePhaseLabel ? `${sourcePhaseLabel} ` : ''}aus Job #${Math.trunc(sourceJobId)}` + : null; + + if (entryType === 'script') { + return { + ...base, + scriptId: entry.scriptId, + sourceJobId: Number.isFinite(sourceJobId) && sourceJobId > 0 ? Math.trunc(sourceJobId) : null, + sourcePhase: sourcePhaseLabel, + subtitle: sourceSubtitle, + title: entry.scriptName || `Skript #${entry.scriptId}`, + status: 'QUEUED' + }; + } + if (entryType === 'chain') { + const chainId = Number(entry.chainId); + const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null; + const resolvedChainName = String(entry.chainName || '').trim() + || (normalizedChainId ? queuedChainNameById.get(normalizedChainId) : null) + || null; + return { + ...base, + chainId: normalizedChainId, + chainName: resolvedChainName, + sourceJobId: Number.isFinite(sourceJobId) && sourceJobId > 0 ? Math.trunc(sourceJobId) : null, + sourcePhase: sourcePhaseLabel, + subtitle: sourceSubtitle, + title: resolvedChainName || `Kette #${normalizedChainId || '-'}`, + status: 'QUEUED' + }; + } + if (entryType === 'wait') { + return { ...base, waitSeconds: entry.waitSeconds, title: `Warten ${entry.waitSeconds}s`, status: 'QUEUED' }; + } + + // type === 'job' + const row = queuedById.get(Number(entry.jobId)); + const scriptMeta = scriptMetaByJobId.get(Number(entry.jobId)) || null; + const seriesEpisodeLabel = String(entry?.seriesEpisodeLabel || '').trim(); + const displayTitleBase = row?.title || row?.detected_title || `Job #${entry.jobId}`; + const displayTitle = displayTitleBase; + const subtitle = seriesEpisodeLabel || null; + return { + ...base, + jobId: Number(entry.jobId), + action: entry.action, + actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action, + poolType: this.resolveQueuePoolTypeForEntry(entry, queuedById), + title: displayTitle, + subtitle, + status: row?.status || null, + lastState: row?.last_state || null, + hasScripts: Boolean(scriptMeta?.hasScripts), + hasChains: Boolean(scriptMeta?.hasChains), + scriptSummary: scriptMeta?.summary || null + }; + }), + queuedCount: this.queueEntries.length, + updatedAt: nowIso() + }; + + return queue; + } + + async emitQueueChanged() { + try { + this.lastQueueSnapshot = await this.getQueueSnapshot(); + wsService.broadcast('PIPELINE_QUEUE_CHANGED', this.lastQueueSnapshot); + } catch (error) { + logger.warn('queue:emit:failed', { error: errorToMeta(error) }); + } + } + + async reorderQueue(orderedEntryIds = []) { + const incoming = Array.isArray(orderedEntryIds) + ? orderedEntryIds.map((value) => Number(value)).filter((v) => Number.isFinite(v) && v > 0) + : []; + if (incoming.length !== this.queueEntries.length) { + const error = new Error('Queue-Reihenfolge ungültig: Anzahl passt nicht.'); + error.statusCode = 400; + throw error; + } + + const currentIdSet = new Set(this.queueEntries.map((entry) => entry.id)); + const incomingSet = new Set(incoming); + if (incomingSet.size !== incoming.length || incoming.some((id) => !currentIdSet.has(id))) { + const error = new Error('Queue-Reihenfolge ungültig: IDs passen nicht zur aktuellen Queue.'); + error.statusCode = 400; + throw error; + } + + const byEntryId = new Map(this.queueEntries.map((entry) => [entry.id, entry])); + this.queueEntries = incoming.map((id) => byEntryId.get(id)).filter(Boolean); + await this.emitQueueChanged(); + return this.lastQueueSnapshot; + } + + async enqueueNonJobEntry(type, params = {}, insertAfterEntryId = null) { + const validTypes = new Set(['script', 'chain', 'wait']); + if (!validTypes.has(type)) { + const error = new Error(`Unbekannter Queue-Eintragstyp: ${type}`); + error.statusCode = 400; + throw error; + } + + let entry; + if (type === 'script') { + const scriptId = Number(params.scriptId); + if (!Number.isFinite(scriptId) || scriptId <= 0) { + const error = new Error('scriptId fehlt oder ist ungültig.'); + error.statusCode = 400; + throw error; + } + const scriptService = require('./scriptService'); + let script; + try { script = await scriptService.getScriptById(scriptId); } catch (_) { /* ignore */ } + entry = { id: this.queueEntrySeq++, type: 'script', scriptId, scriptName: script?.name || null, enqueuedAt: nowIso() }; + } else if (type === 'chain') { + const chainId = Number(params.chainId); + if (!Number.isFinite(chainId) || chainId <= 0) { + const error = new Error('chainId fehlt oder ist ungültig.'); + error.statusCode = 400; + throw error; + } + const scriptChainService = require('./scriptChainService'); + let chain; + try { chain = await scriptChainService.getChainById(chainId); } catch (_) { /* ignore */ } + entry = { id: this.queueEntrySeq++, type: 'chain', chainId, chainName: chain?.name || null, enqueuedAt: nowIso() }; + } else { + const waitSeconds = Math.round(Number(params.waitSeconds)); + if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) { + const error = new Error('waitSeconds muss zwischen 1 und 3600 liegen.'); + error.statusCode = 400; + throw error; + } + entry = { id: this.queueEntrySeq++, type: 'wait', waitSeconds, enqueuedAt: nowIso() }; + } + + if (insertAfterEntryId != null) { + const idx = this.queueEntries.findIndex((e) => e.id === Number(insertAfterEntryId)); + if (idx >= 0) { + this.queueEntries.splice(idx + 1, 0, entry); + } else { + this.queueEntries.push(entry); + } + } else { + this.queueEntries.push(entry); + } + + await this.emitQueueChanged(); + // Manual script/chain/wait entries must stay queued first so users can + // reorder/remove them before execution. They are picked up by later queue + // pump cycles (e.g. after running work finishes or another queue trigger). + return { entryId: entry.id, type, position: this.queueEntries.indexOf(entry) + 1 }; + } + + async removeQueueEntry(entryId) { + const normalizedId = Number(entryId); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + const error = new Error('Ungültige entryId.'); + error.statusCode = 400; + throw error; + } + const idx = this.queueEntries.findIndex((e) => e.id === normalizedId); + if (idx < 0) { + const error = new Error(`Queue-Eintrag #${normalizedId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + this.queueEntries.splice(idx, 1); + await this.emitQueueChanged(); + return this.lastQueueSnapshot; + } + + async enqueueOrStartAction(action, jobId, startNow, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID für Queue-Aktion.'); + error.statusCode = 400; + throw error; + } + if (!Object.values(QUEUE_ACTIONS).includes(action)) { + const error = new Error(`Unbekannte Queue-Aktion '${action}'.`); + error.statusCode = 400; + throw error; + } + if (typeof startNow !== 'function') { + const error = new Error('Queue-Aktion kann nicht gestartet werden (startNow fehlt).'); + error.statusCode = 500; + throw error; + } + + const optionPreloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === normalizedJobId + ? options.preloadedJob + : null; + let resolvedQueueJob = optionPreloadedJob || null; + let poolType = this.normalizeQueuePoolType(options?.poolType); + if (!poolType) { + resolvedQueueJob = resolvedQueueJob || await historyService.getJobById(normalizedJobId).catch(() => null); + poolType = this.resolveQueuePoolTypeForJob(resolvedQueueJob); + } + + let detachedPreItems = []; + let detachedPostItems = []; + if (options?.detachAttachedAutomation !== false) { + if (action === QUEUE_ACTIONS.START_PREPARED) { + resolvedQueueJob = resolvedQueueJob || await historyService.getJobById(normalizedJobId).catch(() => null); + const queueJobPlan = this.safeParseJson(resolvedQueueJob?.encode_plan_json); + const detachedItems = this.extractDetachedQueueAutomationFromPlan(queueJobPlan); + detachedPreItems = detachedItems.preItems; + detachedPostItems = detachedItems.postItems; + } else if (action === QUEUE_ACTIONS.START_CD) { + const ripConfig = options?.entryData?.ripConfig && typeof options.entryData.ripConfig === 'object' + ? options.entryData.ripConfig + : {}; + detachedPreItems = this.normalizeQueueAutomationItems([ + ...normalizeScriptIdList(ripConfig.selectedPreEncodeScriptIds || []).map((id) => ({ type: 'script', id })), + ...this.normalizeQueueChainIdList(ripConfig.selectedPreEncodeChainIds || []).map((id) => ({ type: 'chain', id })) + ]); + detachedPostItems = this.normalizeQueueAutomationItems([ + ...normalizeScriptIdList(ripConfig.selectedPostEncodeScriptIds || []).map((id) => ({ type: 'script', id })), + ...this.normalizeQueueChainIdList(ripConfig.selectedPostEncodeChainIds || []).map((id) => ({ type: 'chain', id })) + ]); + } + } + + const allowDuplicateJobEntries = Boolean(options?.allowDuplicateJobEntries); + const uniqueEntryKey = String(options?.uniqueEntryKey || '').trim() || null; + if (!allowDuplicateJobEntries) { + const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); + if (existingQueueIndex >= 0) { + return { + queued: true, + started: false, + queuePosition: existingQueueIndex + 1, + action, + poolType + }; + } + } else if (uniqueEntryKey) { + const existingQueueIndex = this.queueEntries.findIndex((entry) => ( + Number(entry?.jobId) === normalizedJobId + && String(entry?.action || '').trim().toUpperCase() === String(action || '').trim().toUpperCase() + && String(entry?.uniqueEntryKey || '').trim() === uniqueEntryKey + )); + if (existingQueueIndex >= 0) { + return { + queued: true, + started: false, + queuePosition: existingQueueIndex + 1, + action, + poolType + }; + } + } + + const [maxFilm, maxCd, maxTotal, cdBypass, runningJobs] = await Promise.all([ + this.getMaxParallelJobs(), + this.getMaxParallelCdEncodes(), + this.getMaxTotalEncodes(), + this.getCdBypassesQueue(), + historyService.getRunningJobs() + ]); + const { filmRunning, audioRunning, totalRunning } = this.buildRunningPoolUsage(runningJobs); + const queueJobEntries = this.queueEntries.filter((entry) => !entry.type || entry.type === 'job'); + const queuedPoolTypeCount = queueJobEntries.filter( + (entry) => this.resolveQueuePoolTypeForEntry(entry) === poolType + ).length; + const hasBlockingAudioQueueEntry = this.queueEntries.some((entry) => { + if (entry?.type && entry.type !== 'job') { + return true; + } + return this.resolveQueuePoolTypeForEntry(entry) === 'audio'; + }); + const laneRunning = poolType === 'audio' ? audioRunning : filmRunning; + const laneCap = poolType === 'audio' ? maxCd : maxFilm; + const forceQueue = Boolean(options?.forceQueue); + const hasDetachedAutomationEntries = detachedPreItems.length > 0 || detachedPostItems.length > 0; + const shouldQueue = forceQueue || hasDetachedAutomationEntries + ? true + : (poolType === 'audio' && cdBypass + ? (hasBlockingAudioQueueEntry || laneRunning >= laneCap || totalRunning >= maxTotal) + : (this.queueEntries.length > 0 || laneRunning >= laneCap || totalRunning >= maxTotal)); + if (!shouldQueue) { + const result = await startNow(); + await this.emitQueueChanged(); + return { + queued: false, + started: true, + action, + poolType, + ...(result && typeof result === 'object' ? result : {}) + }; + } + + const preDetachedEntries = detachedPreItems.map((item) => this.buildDetachedQueueEntryFromAutomationItem(item, { + jobId: normalizedJobId, + phase: 'pre' + })); + const postDetachedEntries = detachedPostItems.map((item) => this.buildDetachedQueueEntryFromAutomationItem(item, { + jobId: normalizedJobId, + phase: 'post' + })); + const jobQueueEntry = { + id: this.queueEntrySeq++, + jobId: normalizedJobId, + action, + poolType, + uniqueEntryKey, + ...(options?.entryData && typeof options.entryData === 'object' ? options.entryData : {}), + enqueuedAt: nowIso() + }; + this.queueEntries.push(...preDetachedEntries, jobQueueEntry, ...postDetachedEntries); + const queuePosition = this.queueEntries.findIndex((entry) => entry.id === jobQueueEntry.id) + 1; + const detachedCount = preDetachedEntries.length + postDetachedEntries.length; + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `In Queue aufgenommen: ${QUEUE_ACTION_LABELS[action] || action}` + + (detachedCount > 0 + ? ` (Automationen als eigene Queue-Einträge: Pre=${preDetachedEntries.length}, Post=${postDetachedEntries.length}).` + : '') + ); + await this.emitQueueChanged(); + void this.pumpQueue(); + + return { + queued: true, + started: false, + queuePosition, + action, + poolType, + queuedPoolTypeCount, + detachedAutomationQueued: detachedCount + }; + } + + async enqueueOrStartCdAction(jobId, ripConfig, startNow) { + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_CD, + jobId, + startNow, + { + poolType: 'audio', + entryData: { ripConfig: ripConfig || {} } + } + ); + } + + async dispatchNonJobEntry(entry) { + const type = entry?.type; + logger.info('queue:non-job:dispatch', { type, entryId: entry?.id }); + + if (type === 'wait') { + const seconds = Math.max(1, Number(entry.waitSeconds || 1)); + logger.info('queue:wait:start', { seconds }); + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + logger.info('queue:wait:done', { seconds }); + return; + } + + if (type === 'script') { + const scriptService = require('./scriptService'); + let script; + try { script = await scriptService.getScriptById(entry.scriptId); } catch (_) { /* ignore */ } + if (!script) { + logger.warn('queue:script:not-found', { scriptId: entry.scriptId }); + return; + } + const activityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'queue', + scriptId: script.id, + currentStep: 'Queue-Ausfuehrung' + }); + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { source: 'queue', scriptId: script.id, scriptName: script.name }); + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + const processHandle = spawnTrackedProcess({ + cmd: prepared.cmd, + args: prepared.args, + context: { source: 'queue', scriptId: script.id }, + onStdoutLine: (line) => { + const next = appendTailText(stdout, line); + stdout = next.value; + stdoutTruncated = stdoutTruncated || next.truncated; + runtimeActivityService.appendActivityOutput(activityId, { stdout: line }); + }, + onStderrLine: (line) => { + const next = appendTailText(stderr, line); + stderr = next.value; + stderrTruncated = stderrTruncated || next.truncated; + runtimeActivityService.appendActivityOutput(activityId, { stderr: line }); + } + }); + let exitCode = 0; + let runError = null; + try { + const result = await processHandle.promise; + exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0; + } catch (error) { + runError = error; + exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null; + if (exitCode === null) { + throw error; + } + } + + logger.info('queue:script:done', { scriptId: script.id, exitCode }); + const output = [stdout, stderr].filter(Boolean).join('\n').trim(); + const success = Number(exitCode) === 0; + runtimeActivityService.completeActivity(activityId, { + status: success ? 'success' : 'error', + success, + outcome: success ? 'success' : 'error', + exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null, + message: success ? 'Queue-Skript abgeschlossen' : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})`, + output: output || null, + stdout: stdout || null, + stderr: stderr || null, + stdoutTruncated, + stderrTruncated, + errorMessage: success ? null : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})` + }); + if (runError && !success) { + logger.warn('queue:script:exit-nonzero', { scriptId: script.id, exitCode }); + } + } catch (err) { + runtimeActivityService.completeActivity(activityId, { + status: 'error', + success: false, + outcome: 'error', + message: err?.message || 'Queue-Skript Fehler', + errorMessage: err?.message || 'Queue-Skript Fehler' + }); + logger.error('queue:script:error', { scriptId: entry.scriptId, error: errorToMeta(err) }); + } finally { + if (prepared?.cleanup) await prepared.cleanup(); + } + return; + } + + if (type === 'chain') { + const scriptChainService = require('./scriptChainService'); + try { + await scriptChainService.executeChain(entry.chainId, { source: 'queue' }); + } catch (err) { + logger.error('queue:chain:error', { chainId: entry.chainId, error: errorToMeta(err) }); + } + } + } + + async dispatchQueuedEntry(entry) { + const action = entry?.action; + const jobId = Number(entry?.jobId); + if (!Number.isFinite(jobId) || jobId <= 0) { + return; + } + switch (action) { + case QUEUE_ACTIONS.START_PREPARED: + await this.startPreparedJob(jobId, { immediate: true }); + break; + case QUEUE_ACTIONS.START_SERIES_EPISODE: + await this.startSeriesBatchEpisode(jobId, { + immediate: true, + titleId: entry?.seriesTitleId ?? null, + episodeIndex: entry?.seriesEpisodeIndex ?? null + }); + break; + case QUEUE_ACTIONS.RETRY: + await this.retry(jobId, { immediate: true }); + break; + case QUEUE_ACTIONS.REENCODE: + await this.reencodeFromRaw(jobId, { immediate: true }); + break; + case QUEUE_ACTIONS.RESTART_ENCODE: + await this.restartEncodeWithLastSettings(jobId, { immediate: true }); + break; + case QUEUE_ACTIONS.RESTART_REVIEW: + await this.restartReviewFromRaw(jobId, { immediate: true }); + break; + case QUEUE_ACTIONS.START_CD: + await this.startCdRip(jobId, entry.ripConfig || {}); + break; + default: { + const error = new Error(`Unbekannte Queue-Aktion: ${String(action || '-')}`); + error.statusCode = 400; + throw error; + } + } + } + + async isNonJobQueueEntryReady(entry, options = {}) { + const entryType = String(entry?.type || '').trim().toLowerCase(); + if (entryType !== 'script' && entryType !== 'chain') { + return true; + } + + const sourceJobId = this.normalizeQueueJobId(entry?.sourceJobId); + if (!sourceJobId) { + return true; + } + + const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase(); + if (sourcePhase !== 'post_encode') { + return true; + } + + const runningJobIds = options?.runningJobIds instanceof Set + ? options.runningJobIds + : null; + if (runningJobIds && runningJobIds.has(sourceJobId)) { + return false; + } + + const sourceJobTerminalCache = options?.sourceJobTerminalCache instanceof Map + ? options.sourceJobTerminalCache + : null; + if (sourceJobTerminalCache && sourceJobTerminalCache.has(sourceJobId)) { + return Boolean(sourceJobTerminalCache.get(sourceJobId)); + } + + const sourceJob = await historyService.getJobById(sourceJobId).catch(() => null); + if (!sourceJob) { + if (sourceJobTerminalCache) { + sourceJobTerminalCache.set(sourceJobId, true); + } + return true; + } + + const stateCandidates = [ + String(sourceJob?.status || '').trim().toUpperCase(), + String(sourceJob?.last_state || '').trim().toUpperCase() + ].filter(Boolean); + const isTerminal = stateCandidates.some((state) => ( + state === 'FINISHED' || state === 'ERROR' || state === 'CANCELLED' + )); + if (sourceJobTerminalCache) { + sourceJobTerminalCache.set(sourceJobId, isTerminal); + } + return isTerminal; + } + + async pumpQueue() { + if (this.queuePumpRunning) { + return; + } + this.queuePumpRunning = true; + try { + while (this.queueEntries.length > 0) { + // Get current running counts and limits + const [allRunningJobs, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([ + historyService.getRunningJobs(), + this.getMaxParallelJobs(), + this.getMaxParallelCdEncodes(), + this.getMaxTotalEncodes(), + this.getCdBypassesQueue() + ]); + const runningUsage = this.buildRunningPoolUsage(allRunningJobs); + const filmRunning = runningUsage.filmRunning; + const cdRunning = runningUsage.audioRunning; + const totalRunning = runningUsage.totalRunning; + const runningJobIds = new Set( + (Array.isArray(allRunningJobs) ? allRunningJobs : []) + .map((row) => this.normalizeQueueJobId(row?.id)) + .filter(Boolean) + ); + const sourceJobTerminalCache = new Map(); + + // Find next startable entry + let entryIndex = -1; + for (let i = 0; i < this.queueEntries.length; i++) { + const candidate = this.queueEntries[i]; + const candidateType = String(candidate?.type || 'job').trim().toLowerCase(); + if (candidateType === 'script' || candidateType === 'chain') { + // Detached post-encode automations must only run after the source job finished. + const nonJobReady = await this.isNonJobQueueEntryReady(candidate, { + runningJobIds, + sourceJobTerminalCache + }); + // Non-job queue entries run only when there is no active processing. + // This keeps them properly queued instead of running in parallel. + if (nonJobReady && this.activeProcesses.size === 0 && totalRunning === 0) { + entryIndex = i; + break; + } + continue; + } + if (candidateType === 'wait') { + // Wait keeps queue order and blocks until no tracked job process is active. + if (this.activeProcesses.size === 0) { + entryIndex = i; + } + break; + } + + // Job entry: check hierarchical limits (caps apply only to encode jobs). + if (totalRunning >= maxTotal) { + // Total encode cap reached – nothing can start. + break; + } + + const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate); + if (candidatePoolType === 'audio') { + if (cdRunning < maxCd) { + entryIndex = i; + break; + } + // CD/audio encode cap reached + if (!cdBypass) break; // Strict FIFO: stop scanning + continue; // Bypass mode: skip this blocked CD entry + } + + // Film/video encode job entry + if (filmRunning < maxFilm) { + entryIndex = i; + break; + } + // Film encode cap reached + if (!cdBypass) break; // Strict FIFO: stop scanning + // Bypass mode: skip this blocked film entry + } + + if (entryIndex < 0) { + break; // Nothing can start right now + } + + const entry = this.queueEntries.splice(entryIndex, 1)[0]; + if (!entry) { + break; + } + + const isNonJob = entry.type && entry.type !== 'job'; + await this.emitQueueChanged(); + try { + if (isNonJob) { + await this.dispatchNonJobEntry(entry); + continue; + } + await historyService.appendLog( + entry.jobId, + 'SYSTEM', + `Queue-Start: ${QUEUE_ACTION_LABELS[entry.action] || entry.action}` + ); + await this.dispatchQueuedEntry(entry); + } catch (error) { + if (Number(error?.statusCode || 0) === 409) { + this.queueEntries.splice(entryIndex, 0, entry); + await this.emitQueueChanged(); + break; + } + logger.error('queue:entry:failed', { + type: entry.type || 'job', + action: entry.action, + jobId: entry.jobId, + error: errorToMeta(error) + }); + if (entry.jobId) { + await historyService.appendLog( + entry.jobId, + 'SYSTEM', + `Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}` + ); + const removedDetachedEntries = this.removeDetachedQueueAutomationEntriesForJob(entry.jobId); + if (removedDetachedEntries > 0) { + await historyService.appendLog( + entry.jobId, + 'SYSTEM', + `Queue-Cleanup: ${removedDetachedEntries} verknüpfte Skript/Ketten-Eintrag${removedDetachedEntries === 1 ? '' : 'e'} entfernt.` + ); + await this.emitQueueChanged(); + } + } + } + } + } finally { + this.queuePumpRunning = false; + await this.emitQueueChanged(); + } + } + + async resetFrontendState(reason = 'manual', options = {}) { + const force = Boolean(options?.force); + const keepDetectedDevice = options?.keepDetectedDevice !== false; + + if (!force && (this.activeProcesses.size > 0 || RUNNING_STATES.has(this.snapshot.state))) { + logger.warn('ui:reset:skipped-busy', { + reason, + state: this.snapshot.state, + activeJobId: this.snapshot.activeJobId + }); + return { + reset: false, + skipped: 'busy' + }; + } + + const device = keepDetectedDevice ? (this.detectedDisc || null) : null; + const nextState = device ? 'DISC_DETECTED' : 'IDLE'; + const statusText = device ? 'Neue Disk erkannt' : 'Bereit'; + + logger.warn('ui:reset', { + reason, + previousState: this.snapshot.state, + previousActiveJobId: this.snapshot.activeJobId, + nextState, + keepDetectedDevice + }); + + await this.setState(nextState, { + activeJobId: null, + progress: 0, + eta: null, + statusText, + context: device ? { device } : {} + }); + + return { + reset: true, + state: nextState + }; + } + + async notifyPushover(eventKey, payload = {}) { + try { + const result = await notificationService.notify(eventKey, payload); + logger.debug('notify:event', { + eventKey, + sent: Boolean(result?.sent), + reason: result?.reason || null + }); + } catch (error) { + logger.warn('notify:event:failed', { + eventKey, + error: errorToMeta(error) + }); + } + } + + async ejectDriveIfEnabled(settingsMap, devicePath = null) { + try { + const enabled = String(settingsMap?.auto_eject_after_rip || '').trim().toLowerCase(); + if (enabled !== 'true' && enabled !== '1') { + return; + } + // Collect the list of drives to eject + const devicesToEject = []; + if (devicePath) { + devicesToEject.push(devicePath); + } else if (settingsMap?.drive_mode === 'explicit') { + try { + const parsed = JSON.parse(settingsMap?.drive_devices || '[]'); + if (Array.isArray(parsed)) { + parsed + .map((e) => (typeof e === 'string' ? e.trim() : String(e?.path || '').trim())) + .filter(Boolean) + .forEach((p) => devicesToEject.push(p)); + } + } catch (_error) { + // ignore + } + if (devicesToEject.length === 0) { + const legacy = String(settingsMap?.drive_device || '').trim(); + if (legacy) devicesToEject.push(legacy); + } + } else { + devicesToEject.push(String(settingsMap?.drive_device || '').trim() || '/dev/sr0'); + } + for (const device of devicesToEject) { + logger.info('eject:drive', { device }); + await new Promise((resolve) => { + execFile('eject', [device], { timeout: 10000 }, (error) => { + if (error) { + logger.warn('eject:drive:failed', { device, error: errorToMeta(error) }); + } else { + logger.info('eject:drive:ok', { device }); + } + resolve(); + }); + }); + } + } catch (error) { + logger.warn('eject:drive:error', { error: errorToMeta(error) }); + } + } + + normalizeDiscValue(value) { + return String(value || '').trim().toLowerCase(); + } + + isSameDisc(a, b) { + const aDiscLabel = this.normalizeDiscValue(a?.discLabel); + const bDiscLabel = this.normalizeDiscValue(b?.discLabel); + if (aDiscLabel && bDiscLabel) { + return aDiscLabel === bDiscLabel; + } + + const aPath = this.normalizeDiscValue(a?.path); + const bPath = this.normalizeDiscValue(b?.path); + if (aPath && bPath) { + return aPath === bPath; + } + + const aLabel = this.normalizeDiscValue(a?.label); + const bLabel = this.normalizeDiscValue(b?.label); + if (aLabel && bLabel) { + return aLabel === bLabel; + } + + return false; + } + + shouldSuspendDrivePollingForState(state, context = null) { + const normalizedState = String(state || '').trim().toUpperCase(); + const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']); + if (DRIVE_ACTIVE_STATES.has(normalizedState)) { + return true; + } + + // RAW-based review/restart states should not touch optical drives. + if (!['READY_TO_ENCODE', 'WAITING_FOR_USER_DECISION'].includes(normalizedState)) { + return false; + } + + const resolvedContext = context && typeof context === 'object' ? context : {}; + const review = resolvedContext.mediaInfoReview && typeof resolvedContext.mediaInfoReview === 'object' + ? resolvedContext.mediaInfoReview + : null; + const mode = String(resolvedContext.mode || review?.mode || '').trim().toLowerCase(); + const isPreRip = mode === 'pre_rip' || Boolean(review?.preRip); + if (isPreRip) { + return false; + } + + const rawPath = String(resolvedContext.rawPath || '').trim(); + const inputPath = String(resolvedContext.inputPath || review?.encodeInputPath || '').trim(); + const hasFilesystemInput = [rawPath, inputPath].some((candidate) => + candidate && !candidate.startsWith('disc-track-scan://') + ); + return hasFilesystemInput; + } + + async setState(state, patch = {}) { + const previous = this.snapshot.state; + const previousContext = this.snapshot.context; + const previousActiveJobId = this.snapshot.activeJobId; + const contextPatch = patch.context && typeof patch.context === 'object' && !Array.isArray(patch.context) + ? patch.context + : null; + this.snapshot = { + ...this.snapshot, + state, + activeJobId: patch.activeJobId !== undefined ? patch.activeJobId : this.snapshot.activeJobId, + progress: patch.progress !== undefined ? patch.progress : this.snapshot.progress, + eta: patch.eta !== undefined ? patch.eta : this.snapshot.eta, + statusText: patch.statusText !== undefined ? patch.statusText : this.snapshot.statusText, + context: patch.context !== undefined ? patch.context : this.snapshot.context + }; + + // Keep per-job progress map in sync when a job starts or finishes. + if (patch.activeJobId != null) { + const activeJobId = Number(patch.activeJobId); + const previousJobProgress = this.jobProgress.get(activeJobId) || {}; + const mergedContext = contextPatch + ? { + ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : {}), + ...contextPatch + } + : (previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : null); + const nextProgress = { + ...previousJobProgress, + state, + progress: patch.progress ?? 0, + eta: patch.eta ?? null, + statusText: patch.statusText ?? null + }; + if (mergedContext && Object.keys(mergedContext).length > 0) { + nextProgress.context = mergedContext; + } + this.jobProgress.set(activeJobId, nextProgress); + } else if (patch.activeJobId === null) { + // Job slot cleared – remove the finished job's live entry so it falls + // back to DB data in the frontend. + // Use patch.finishingJobId when provided (parallel-safe); fall back to + // previousActiveJobId only when no parallel job has overwritten the slot. + const finishingJobId = patch.finishingJobId != null + ? Number(patch.finishingJobId) + : (previousActiveJobId != null ? Number(previousActiveJobId) : null); + if (finishingJobId != null) { + this.jobProgress.delete(finishingJobId); + } + } + logger.info('state:changed', { + from: previous, + to: state, + activeJobId: this.snapshot.activeJobId, + statusText: this.snapshot.statusText + }); + + const shouldSuspendCurrentPolling = this.shouldSuspendDrivePollingForState(state, this.snapshot.context); + const shouldSuspendPreviousPolling = this.shouldSuspendDrivePollingForState(previous, previousContext); + if (shouldSuspendCurrentPolling) { + diskDetectionService.suspendPolling(); + } else if (shouldSuspendPreviousPolling) { + diskDetectionService.resumePolling(); + } + + await this.persistSnapshot(); + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + void this.emitQueueChanged(); + void this.pumpQueue(); + } + + async persistSnapshot(force = true) { + if (!force) { + const now = Date.now(); + if (now - this.lastPersistAt < 300) { + return; + } + this.lastPersistAt = now; + } + + const db = await getDb(); + await db.run( + ` + UPDATE pipeline_state + SET + state = ?, + active_job_id = ?, + progress = ?, + eta = ?, + status_text = ?, + context_json = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `, + [ + this.snapshot.state, + this.snapshot.activeJobId, + this.snapshot.progress, + this.snapshot.eta, + this.snapshot.statusText, + JSON.stringify(this.snapshot.context || {}) + ] + ); + } + + async updateProgress(stage, percent, eta, statusText, jobIdOverride = null, options = {}) { + const effectiveJobId = jobIdOverride != null ? Number(jobIdOverride) : this.snapshot.activeJobId; + const previousJobProgress = effectiveJobId != null + ? (this.jobProgress.get(effectiveJobId) || {}) + : null; + const effectiveProgress = percent ?? this.snapshot.progress; + let effectiveEta; + if (eta !== undefined) { + effectiveEta = eta; + } else if (previousJobProgress && previousJobProgress.eta !== undefined) { + effectiveEta = previousJobProgress.eta ?? null; + } else { + effectiveEta = this.snapshot.eta ?? null; + } + const effectiveStatusText = statusText ?? this.snapshot.statusText; + const progressOptions = options && typeof options === 'object' ? options : {}; + const contextPatch = progressOptions.contextPatch && typeof progressOptions.contextPatch === 'object' + && !Array.isArray(progressOptions.contextPatch) + ? progressOptions.contextPatch + : null; + + // Update per-job progress so concurrent jobs don't overwrite each other. + if (effectiveJobId != null) { + const mergedContext = contextPatch + ? { + ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : {}), + ...contextPatch + } + : (previousJobProgress.context && typeof previousJobProgress.context === 'object' + ? previousJobProgress.context + : null); + const nextProgress = { + ...previousJobProgress, + state: stage, + progress: effectiveProgress, + eta: effectiveEta, + statusText: effectiveStatusText + }; + if (mergedContext && Object.keys(mergedContext).length > 0) { + nextProgress.context = mergedContext; + } + this.jobProgress.set(effectiveJobId, nextProgress); + + const normalizedEffectiveJobId = Number(effectiveJobId); + const seriesBatchParentJobId = this.seriesBatchParentByChild.get(normalizedEffectiveJobId) || null; + if ( + seriesBatchParentJobId + && Number(seriesBatchParentJobId) !== normalizedEffectiveJobId + ) { + const normalizedStage = String(stage || '').trim().toUpperCase(); + const nowMs = Date.now(); + const lastSyncAt = this.seriesBatchParentProgressSyncAt.get(Number(seriesBatchParentJobId)) || 0; + const forceSync = normalizedStage === 'FINISHED' || normalizedStage === 'ERROR' || normalizedStage === 'CANCELLED'; + if (forceSync || (nowMs - lastSyncAt) >= 1200) { + this.seriesBatchParentProgressSyncAt.set(Number(seriesBatchParentJobId), nowMs); + void this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { + logger.warn('series-batch:parent-progress:sync-from-child-progress-failed', { + parentJobId: seriesBatchParentJobId, + childJobId: normalizedEffectiveJobId, + error: errorToMeta(error) + }); + }); + } + } + } + + // Only update the global snapshot fields when this update belongs to the + // currently active job (avoids the snapshot jumping between parallel jobs). + if (effectiveJobId === this.snapshot.activeJobId || effectiveJobId == null) { + const nextContext = contextPatch + ? { + ...(this.snapshot.context && typeof this.snapshot.context === 'object' + ? this.snapshot.context + : {}), + ...contextPatch + } + : this.snapshot.context; + this.snapshot = { + ...this.snapshot, + state: stage, + progress: effectiveProgress, + eta: effectiveEta, + statusText: effectiveStatusText, + context: nextContext + }; + await this.persistSnapshot(false); + } + + const rounded = Number((effectiveProgress || 0).toFixed(2)); + const key = `${effectiveJobId}:${stage}:${rounded}`; + if (key !== this.lastProgressKey) { + this.lastProgressKey = key; + logger.debug('progress:update', { + stage, + activeJobId: effectiveJobId, + progress: rounded, + eta: effectiveEta, + statusText: effectiveStatusText + }); + } + wsService.broadcast('PIPELINE_PROGRESS', { + state: stage, + activeJobId: effectiveJobId, + progress: effectiveProgress, + eta: effectiveEta, + statusText: effectiveStatusText, + contextPatch + }); + } + + async onDiscInserted(deviceInfo) { + const rawDevice = deviceInfo && typeof deviceInfo === 'object' + ? deviceInfo + : {}; + const explicitProfile = normalizeMediaProfile(rawDevice.mediaProfile); + const inferredProfile = inferMediaProfileFromDeviceInfo(rawDevice); + const resolvedMediaProfile = isSpecificMediaProfile(explicitProfile) + ? explicitProfile + : (isSpecificMediaProfile(inferredProfile) + ? inferredProfile + : (explicitProfile || inferredProfile || 'other')); + const resolvedDevice = { + ...rawDevice, + mediaProfile: resolvedMediaProfile + }; + + logger.info('disc:inserted', { deviceInfo: resolvedDevice, mediaProfile: resolvedMediaProfile }); + wsService.broadcast('DISC_DETECTED', { device: resolvedDevice }); + + // CD discs are tracked per-drive in cdDrives, not in the global state machine + if (resolvedMediaProfile === 'cd') { + const cdDevicePath = String(resolvedDevice.path || '').trim(); + if (cdDevicePath) { + const existingDrive = this.cdDrives.get(cdDevicePath); + const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']); + if (!ACTIVE_CD_STATES.has(existingDrive?.state)) { + this._setCdDriveState(cdDevicePath, { state: 'DISC_DETECTED', device: resolvedDevice }); + } + } + return; + } + + const previousDevice = this.snapshot.context?.device || this.detectedDisc; + const previousState = this.snapshot.state; + const previousJobId = this.snapshot.context?.jobId || this.snapshot.activeJobId || null; + const discChanged = previousDevice ? !this.isSameDisc(previousDevice, resolvedDevice) : false; + + this.detectedDisc = resolvedDevice; + + if (discChanged && !RUNNING_STATES.has(previousState) && previousState !== 'DISC_DETECTED' && previousState !== 'READY_TO_ENCODE') { + const message = `Disk gewechselt (${resolvedDevice.discLabel || resolvedDevice.path || 'unbekannt'}). Bitte neu analysieren.`; + logger.info('disc:changed:reset', { + fromState: previousState, + previousDevice, + newDevice: resolvedDevice, + previousJobId + }); + + if (previousJobId && (previousState === 'METADATA_LOOKUP' || previousState === 'METADATA_SELECTION' || previousState === 'READY_TO_START' || previousState === 'WAITING_FOR_USER_DECISION')) { + await historyService.updateJob(previousJobId, { + status: 'ERROR', + last_state: 'ERROR', + end_time: nowIso(), + error_message: message + }); + await historyService.appendLog(previousJobId, 'SYSTEM', message); + } + + await this.setState('DISC_DETECTED', { + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Neue Disk erkannt', + context: { + device: resolvedDevice + } + }); + return; + } + + if (this.snapshot.state === 'IDLE' || this.snapshot.state === 'FINISHED' || this.snapshot.state === 'ERROR' || this.snapshot.state === 'DISC_DETECTED') { + await this.setState('DISC_DETECTED', { + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Neue Disk erkannt', + context: { + device: resolvedDevice + } + }); + } + } + + async onDiscRemoved(deviceInfo) { + logger.info('disc:removed', { deviceInfo }); + wsService.broadcast('DISC_REMOVED', { device: deviceInfo }); + + const removedPath = String(deviceInfo?.path || '').trim(); + + // If it's a tracked CD drive, remove or leave it depending on active state + if (removedPath && this.cdDrives.has(removedPath)) { + const driveState = this.cdDrives.get(removedPath); + const ACTIVE_CD_STATES = new Set(['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']); + if (!ACTIVE_CD_STATES.has(driveState?.state)) { + this._removeCdDrive(removedPath); + } + // If actively ripping, leave the entry – rip completion will clean it up + return; + } + + this.detectedDisc = null; + if (this.snapshot.state === 'DISC_DETECTED') { + await this.setState('IDLE', { + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Keine Disk erkannt', + context: {} + }); + } + } + + ensureNotBusy(action, jobId = null) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + return; + } + if (this.activeProcesses.has(normalizedJobId)) { + const error = new Error(`Job #${normalizedJobId} ist bereits aktiv. Aktion '${action}' aktuell nicht möglich.`); + error.statusCode = 409; + logger.warn('busy:blocked-action', { + action, + jobId: normalizedJobId, + activeState: this.snapshot.state, + activeJobId: this.snapshot.activeJobId + }); + throw error; + } + } + + isPrimaryJob(jobId) { + const activeState = String(this.snapshot.state || '').toUpperCase(); + if (!['ENCODING', 'RIPPING'].includes(activeState)) { + return true; + } + return Number(this.snapshot.activeJobId) === Number(jobId); + } + + withAnalyzeContextMediaProfile(makemkvInfo, mediaProfile) { + const normalizedProfile = normalizeMediaProfile(mediaProfile); + const base = makemkvInfo && typeof makemkvInfo === 'object' + ? makemkvInfo + : {}; + return { + ...base, + analyzeContext: { + ...(base.analyzeContext || {}), + mediaProfile: normalizedProfile || null + } + }; + } + + resolveMediaProfileForJob(job, options = {}) { + const pickSpecificProfile = (value) => { + const normalized = normalizeMediaProfile(value); + if (!normalized) { + return null; + } + if (isSpecificMediaProfile(normalized)) { + return normalized; + } + return null; + }; + + const explicitProfile = pickSpecificProfile(options?.mediaProfile); + if (explicitProfile) { + return explicitProfile; + } + + const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' + ? options.encodePlan + : null; + const profileFromJobKind = inferMediaProfileFromJobKind( + options?.jobKind + || job?.job_kind + || encodePlan?.jobKind + ); + if (profileFromJobKind) { + return profileFromJobKind; + } + const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile); + if (profileFromPlan) { + return profileFromPlan; + } + const converterMediaTypeHint = String(encodePlan?.converterMediaType || '').trim().toLowerCase(); + if (converterMediaTypeHint === 'video' || converterMediaTypeHint === 'audio' || converterMediaTypeHint === 'iso') { + return 'converter'; + } + if (String(encodePlan?.mode || '').trim().toLowerCase() === 'converter') { + return 'converter'; + } + if ( + hasConverterPathSegment(encodePlan?.inputPath) + || hasConverterPathSegment(encodePlan?.encodeInputPath) + || hasConverterPathSegment(job?.raw_path) + || hasConverterPathSegment(options?.rawPath) + || hasConverterPathSegment(job?.encode_input_path) + ) { + return 'converter'; + } + + const mkInfo = options?.makemkvInfo && typeof options.makemkvInfo === 'object' + ? options.makemkvInfo + : this.safeParseJson(job?.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext || {}; + const profileFromAnalyze = pickSpecificProfile( + analyzeContext.mediaProfile || mkInfo?.mediaProfile + ); + if (profileFromAnalyze) { + return profileFromAnalyze; + } + + const currentContextProfile = ( + Number(this.snapshot.context?.jobId) === Number(job?.id) + ? pickSpecificProfile(this.snapshot.context?.mediaProfile) + : null + ); + if (currentContextProfile) { + return currentContextProfile; + } + + const deviceProfile = inferMediaProfileFromDeviceInfo( + options?.deviceInfo + || this.detectedDisc + || this.snapshot.context?.device + || null + ); + if (isSpecificMediaProfile(deviceProfile)) { + return deviceProfile; + } + + const rawPathProfile = inferMediaProfileFromRawPath(options?.rawPath || job?.raw_path || null); + if (rawPathProfile) { + return rawPathProfile; + } + + return 'other'; + } + + resolveJobKindForJob(job, options = {}) { + const explicitKind = normalizeJobKind(options?.jobKind); + if (explicitKind) { + return explicitKind; + } + const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' + ? options.encodePlan + : this.safeParseJson(job?.encode_plan_json); + const kindFromPlan = normalizeJobKind(encodePlan?.jobKind); + if (kindFromPlan) { + return kindFromPlan; + } + const kindFromJob = normalizeJobKind(job?.job_kind); + if (kindFromJob) { + return kindFromJob; + } + const mediaProfile = this.resolveMediaProfileForJob(job, { ...options, encodePlan }); + return resolveJobKindForMediaProfile(mediaProfile, { + converterMediaType: options?.converterMediaType || encodePlan?.converterMediaType || null + }); + } + + async getEffectiveSettingsForJob(job, options = {}) { + const mediaProfile = this.resolveMediaProfileForJob(job, options); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + return { + settings, + mediaProfile + }; + } + + async runCapturedCommand(cmd, args = []) { + const command = String(cmd || '').trim(); + const argv = Array.isArray(args) ? args.map((item) => String(item)) : []; + if (!command) { + throw new Error('Kommando fehlt.'); + } + + return new Promise((resolve, reject) => { + execFile(command, argv, { maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + return; + } + resolve({ + stdout: String(stdout || ''), + stderr: String(stderr || '') + }); + }); + }); + } + + async ensureExternalToolAvailable(cmd, options = {}) { + const command = String(cmd || '').trim(); + const label = String(options?.label || command || 'Tool').trim() || 'Tool'; + const versionArgs = Array.isArray(options?.versionArgs) && options.versionArgs.length > 0 + ? options.versionArgs.map((item) => String(item)) + : ['--version']; + if (!command) { + const error = new Error(`${label} ist nicht konfiguriert.`); + error.statusCode = 500; + throw error; + } + + try { + await this.runCapturedCommand(command, versionArgs); + } catch (error) { + if (String(error?.code || '').trim().toUpperCase() === 'ENOENT') { + const resolvedError = new Error( + `${label} konnte nicht gestartet werden. Bitte das Kommando in den Settings prüfen oder ${label} auf dem Server installieren.` + ); + resolvedError.statusCode = 500; + resolvedError.code = 'ENOENT'; + throw resolvedError; + } + throw error; + } + } + + async ensureMakeMKVRegistration(jobId, stage) { + const syncResult = await settingsService.syncMakeMKVRegistrationKeyFromSettings(); + if (!syncResult?.applied) { + return { applied: false, reason: 'not_configured', path: syncResult?.path || null }; + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Synchronisiere MakeMKV-Key aus den Settings nach ~/.MakeMKV/settings.conf.' + ); + + return { + applied: true, + path: syncResult?.path || null, + changed: Boolean(syncResult?.changed), + stage + }; + } + + isReviewRefreshSettingKey(key) { + const normalized = String(key || '').trim().toLowerCase(); + if (!normalized) { + return false; + } + + if (REVIEW_REFRESH_SETTING_KEYS.has(normalized)) { + return true; + } + + return REVIEW_REFRESH_SETTING_PREFIXES.some((prefix) => normalized.startsWith(prefix)); + } + + async refreshEncodeReviewAfterSettingsSave(changedKeys = []) { + const keys = Array.isArray(changedKeys) + ? changedKeys.map((item) => String(item || '').trim()).filter(Boolean) + : []; + const queueLimitKeys = ['pipeline_max_parallel_jobs', 'pipeline_max_parallel_cd_encodes', 'pipeline_max_total_encodes', 'pipeline_cd_bypasses_queue']; + if (keys.some((k) => queueLimitKeys.includes(k))) { + await this.emitQueueChanged(); + void this.pumpQueue(); + } + const relevantKeys = keys.filter((key) => this.isReviewRefreshSettingKey(key)); + if (relevantKeys.length === 0) { + return { + triggered: false, + reason: 'no_relevant_setting_changes', + relevantKeys: [] + }; + } + + if (this.activeProcesses.size > 0 || RUNNING_STATES.has(this.snapshot.state)) { + return { + triggered: false, + reason: 'pipeline_busy', + relevantKeys + }; + } + + const rawJobId = Number(this.snapshot.activeJobId || this.snapshot.context?.jobId || null); + const activeJobId = Number.isFinite(rawJobId) && rawJobId > 0 ? Math.trunc(rawJobId) : null; + if (!activeJobId) { + return { + triggered: false, + reason: 'no_active_job', + relevantKeys + }; + } + + const job = await historyService.getJobById(activeJobId); + if (!job) { + return { + triggered: false, + reason: 'active_job_not_found', + relevantKeys, + jobId: activeJobId + }; + } + + if (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE') { + return { + triggered: false, + reason: 'active_job_not_ready_to_encode', + relevantKeys, + jobId: activeJobId, + status: job.status, + lastState: job.last_state + }; + } + + const existingPlan = this.safeParseJson(job.encode_plan_json); + const refreshSettings = await settingsService.getSettingsMap(); + const refreshMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: existingPlan, + rawPath: job.raw_path + }); + const resolvedRefreshRawPath = this.resolveCurrentRawPathForSettings( + refreshSettings, + refreshMediaProfile, + job.raw_path + ); + + if (!resolvedRefreshRawPath) { + return { + triggered: false, + reason: 'raw_path_missing', + relevantKeys, + jobId: activeJobId, + rawPath: job.raw_path || null + }; + } + + if (resolvedRefreshRawPath !== job.raw_path) { + await historyService.updateJob(activeJobId, { raw_path: resolvedRefreshRawPath }); + } + + const mode = existingPlan?.mode || this.snapshot.context?.mode || 'rip'; + const sourceJobId = existingPlan?.sourceJobId || this.snapshot.context?.sourceJobId || null; + + await historyService.appendLog( + activeJobId, + 'SYSTEM', + `Settings gespeichert (${relevantKeys.join(', ')}). Titel-/Spurprüfung wird mit aktueller Konfiguration neu gestartet.` + ); + + this.runReviewForRawJob(activeJobId, resolvedRefreshRawPath, { mode, sourceJobId }).catch((error) => { + logger.error('settings:refresh-review:failed', { + jobId: activeJobId, + relevantKeys, + error: errorToMeta(error) + }); + }); + + return { + triggered: true, + reason: 'refresh_started', + relevantKeys, + jobId: activeJobId, + mode + }; + } + + resolvePlaylistDecisionForJob(jobId, job, selectionOverride = null) { + const activeContext = this.snapshot.context?.jobId === jobId + ? (this.snapshot.context || {}) + : {}; + + const mkInfo = this.safeParseJson(job?.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext || {}; + const playlistAnalysis = activeContext.playlistAnalysis || analyzeContext.playlistAnalysis || mkInfo?.playlistAnalysis || null; + + const playlistDecisionRequired = Boolean( + activeContext.playlistDecisionRequired !== undefined + ? activeContext.playlistDecisionRequired + : (analyzeContext.playlistDecisionRequired !== undefined + ? analyzeContext.playlistDecisionRequired + : playlistAnalysis?.manualDecisionRequired) + ); + + const rawSelection = selectionOverride + || activeContext.selectedPlaylist + || analyzeContext.selectedPlaylist + || null; + const selectedPlaylist = normalizePlaylistId(rawSelection); + + const rawSelectedTitleId = activeContext.selectedTitleId ?? analyzeContext.selectedTitleId ?? null; + let selectedTitleId = null; + if (selectedPlaylist) { + selectedTitleId = pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylist); + } + if (selectedTitleId === null) { + const parsedSelectedTitleId = normalizeNonNegativeInteger(rawSelectedTitleId); + if (parsedSelectedTitleId !== null) { + selectedTitleId = parsedSelectedTitleId; + } + } + if (!selectedPlaylist && selectedTitleId !== null && !isCandidateTitleId(playlistAnalysis, selectedTitleId)) { + selectedTitleId = null; + } + + const candidatePlaylists = buildPlaylistCandidates(playlistAnalysis); + const recommendation = playlistAnalysis?.recommendation || null; + + return { + playlistAnalysis, + playlistDecisionRequired, + candidatePlaylists, + selectedPlaylist, + selectedTitleId, + recommendation + }; + } + + async analyzeRawImportJob(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID für RAW-Analyse.'); + error.statusCode = 400; + throw error; + } + + this.ensureNotBusy('analyzeRawImportJob', normalizedJobId); + logger.info('analyze:raw-import:start', { + jobId: normalizedJobId, + rawPath: options?.rawPath || null + }); + const activeLockPaths = Array.isArray(diskDetectionService.getActiveLocks?.()) + ? diskDetectionService.getActiveLocks() + .map((entry) => this.normalizeDrivePath(entry?.path)) + .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) + : []; + if (activeLockPaths.length > 0) { + try { + await this._forceUnlockStaleDriveLocks(activeLockPaths); + } catch (error) { + logger.warn('analyze:raw-import:stale-lock-cleanup-failed', { + jobId: normalizedJobId, + error: errorToMeta(error) + }); + } + } + + const sourceJob = await historyService.getJobById(normalizedJobId); + if (!sourceJob) { + const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); + + const requestedRawPath = String(options?.rawPath || sourceJob.raw_path || '').trim(); + if (!requestedRawPath) { + const error = new Error('RAW-Analyse nicht möglich: raw_path fehlt.'); + error.statusCode = 400; + throw error; + } + + const globalSettings = await settingsService.getSettingsMap(); + const resolvedRawPath = this.resolveCurrentRawPathForSettings( + globalSettings, + null, + requestedRawPath + ) || requestedRawPath; + if (!resolvedRawPath || !fs.existsSync(resolvedRawPath)) { + const error = new Error(`RAW-Analyse nicht möglich: RAW-Pfad nicht gefunden (${requestedRawPath}).`); + error.statusCode = 400; + throw error; + } + + const inferredRawProfile = normalizeMediaProfile( + options?.mediaProfile || inferMediaProfileFromRawPath(resolvedRawPath) + ); + const fallbackProfile = normalizeMediaProfile( + sourceJob?.media_type + || inferMediaProfileFromJobKind(sourceJob?.job_kind) + || null + ); + const mediaProfile = isSpecificMediaProfile(inferredRawProfile) + ? inferredRawProfile + : (isSpecificMediaProfile(fallbackProfile) ? fallbackProfile : null); + + if (!mediaProfile) { + const error = new Error(`RAW-Analyse nicht möglich: Medientyp konnte nicht bestimmt werden (${resolvedRawPath}).`); + error.statusCode = 400; + throw error; + } + + if (mediaProfile === 'cd') { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + 'RAW-Import erkannt als CD. Starte CD-Vorprüfung wie nach "Disk analysieren".' + ); + const cdResult = await this.restartCdReviewFromRaw(normalizedJobId, {}); + return { + started: true, + stage: 'CD_METADATA_SELECTION', + mediaProfile: 'cd', + ...(cdResult && typeof cdResult === 'object' ? cdResult : {}) + }; + } + + if (mediaProfile === 'audiobook' || mediaProfile === 'converter') { + const error = new Error(`RAW-Analyse für Medientyp "${mediaProfile}" wird über diesen Einstieg nicht unterstützt.`); + error.statusCode = 400; + throw error; + } + + const detectedTitle = String( + options?.detectedTitle + || sourceJob.detected_title + || sourceJob.title + || path.basename(resolvedRawPath) + || 'Unknown Disc' + ).trim() || 'Unknown Disc'; + const deviceWithProfile = { + path: resolvedRawPath, + discLabel: detectedTitle, + label: detectedTitle, + mediaProfile, + source: 'raw_import' + }; + const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile, 'analyze'); + + await resetProcessLogIfLifecycleAllows(normalizedJobId, sourceJob); + await historyService.updateJob(normalizedJobId, { + status: 'ANALYZING', + last_state: 'ANALYZING', + error_message: null, + detected_title: detectedTitle, + title: null, + year: null, + imdb_id: null, + poster_url: null, + omdb_json: null, + selected_from_omdb: 0, + rip_successful: 0, + end_time: null, + media_type: mediaProfile, + job_kind: resolveJobKindForMediaProfile(mediaProfile), + parent_job_id: null, + disc_device: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + ...(resolvedRawPath !== sourceJob.raw_path ? { raw_path: resolvedRawPath } : {}) + }); + // Safety net: orphan RAW analysis must never hold/restore a physical drive lock. + this._releaseDriveLockForJob(normalizedJobId, { reason: 'orphan_raw_analyze' }); + + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `RAW-Import wird wie "Disk analysieren" verarbeitet: ${path.basename(resolvedRawPath)} (Profil: ${mediaProfile}).` + ); + + await this.setState('ANALYZING', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: 'Disk wird analysiert ...', + context: { + jobId: normalizedJobId, + detectedTitle, + mediaProfile, + rawPath: resolvedRawPath, + rawImport: true + } + }); + + try { + let effectiveDetectedTitle = detectedTitle; + let metadataCandidates = []; + const metadataProvider = 'tmdb'; + let workflowKind = null; + let pluginExecution = null; + + if (analyzePlugin) { + const pluginContext = await this.buildPluginContext(analyzePlugin.id, { + discInfo: deviceWithProfile, + jobId: normalizedJobId + }); + try { + const pluginResult = await analyzePlugin.analyze(resolvedRawPath, sourceJob, pluginContext); + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); + if (pluginDetectedTitle) { + effectiveDetectedTitle = pluginDetectedTitle; + } + logger.info('plugin:analyze:used', { + jobId: normalizedJobId, + pluginId: analyzePlugin.id, + mediaProfile, + source: 'raw_import' + }); + } catch (error) { + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + logger.warn('plugin:analyze:raw-import:fallback-legacy', { + jobId: normalizedJobId, + pluginId: analyzePlugin.id, + mediaProfile, + error: errorToMeta(error) + }); + } + } + + if (isSeriesDiscMediaProfile(mediaProfile)) { + await historyService.updateJob(normalizedJobId, { + status: 'METADATA_LOOKUP', + last_state: 'METADATA_LOOKUP' + }); + await this.setState('METADATA_LOOKUP', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: 'TMDb-Metadaten werden gesucht', + context: { + jobId: normalizedJobId, + detectedTitle: effectiveDetectedTitle, + mediaProfile, + rawPath: resolvedRawPath, + rawImport: true, + metadataProvider + } + }); + metadataCandidates = await this.searchTmdbMixedMetadata(effectiveDetectedTitle, {}); + workflowKind = deriveWorkflowKindFromMetadataCandidates(metadataCandidates); + } + + const prepareInfo = this.withPluginExecutionMeta( + this.withAnalyzeContextMediaProfile({ + source: 'orphan_raw_import', + importContext: sourceMakemkvInfo?.importContext && typeof sourceMakemkvInfo.importContext === 'object' + ? sourceMakemkvInfo.importContext + : null, + rawPath: resolvedRawPath, + phase: 'PREPARE', + preparedAt: nowIso(), + analyzeContext: { + playlistAnalysis: null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + workflowKind: workflowKind || null, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null + } + }, mediaProfile), + pluginExecution + ); + + await historyService.updateJob(normalizedJobId, { + status: 'METADATA_SELECTION', + last_state: 'METADATA_SELECTION', + detected_title: effectiveDetectedTitle, + media_type: mediaProfile, + job_kind: resolveJobKindForMediaProfile(mediaProfile), + parent_job_id: null, + makemkv_info_json: JSON.stringify(prepareInfo) + }); + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Film- und Serien-Suche abgeschlossen (Suchvorschlag: "${effectiveDetectedTitle}").` + ); + + await this.setState('METADATA_SELECTION', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: 'Metadaten auswählen', + context: { + jobId: normalizedJobId, + detectedTitle: effectiveDetectedTitle, + detectedTitleSource: effectiveDetectedTitle !== detectedTitle ? 'plugin' : 'raw_import', + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + mediaProfile, + workflowKind: workflowKind || null, + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null, + playlistAnalysis: null, + playlistDecisionRequired: false, + playlistCandidates: [], + selectedPlaylist: null, + selectedTitleId: null, + rawPath: resolvedRawPath, + rawImport: true, + ...(pluginExecution ? { pluginExecution } : {}) + } + }); + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Metadaten bereit', + message: `Job #${normalizedJobId}: Metadaten müssen manuell zugeordnet werden` + }); + + return { + started: true, + stage: 'METADATA_SELECTION', + jobId: normalizedJobId, + rawPath: resolvedRawPath, + mediaProfile, + detectedTitle: effectiveDetectedTitle, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null, + workflowKind: workflowKind || null + }; + } catch (error) { + logger.error('metadata:prepare:raw-import:failed', { jobId: normalizedJobId, error: errorToMeta(error) }); + await this.failJob(normalizedJobId, 'METADATA_SELECTION', error); + throw error; + } + } + + async analyzeDisc(devicePath = null) { + this.ensureNotBusy('analyze'); + logger.info('analyze:start', { devicePath }); + const requestedDevicePath = this.normalizeDrivePath(devicePath); + if (requestedDevicePath) { + await this._ensureDriveUnlockedForAnalyze(requestedDevicePath); + } + + let device; + if (requestedDevicePath) { + const driveEntry = this.cdDrives.get(requestedDevicePath); + device = driveEntry?.device || null; + if (!device && this.detectedDisc?.path === requestedDevicePath) { + device = this.detectedDisc; + } + if (!device) { + // Try the disk detection service's per-drive map — covers non-CD drives + // not yet tracked by the global state machine (e.g. second DVD drive). + const diskDetected = diskDetectionService.detectedDiscs.get(requestedDevicePath); + device = diskDetected || { path: requestedDevicePath }; + } + } else { + device = this.detectedDisc || this.snapshot.context?.device; + } + + if (!device) { + const error = new Error('Keine Disk erkannt.'); + error.statusCode = 400; + logger.warn('analyze:no-disc'); + throw error; + } + + // Use only disc-specific labels — never the drive hardware model (device.model + // reflects the drive hardware, e.g. "BD-RE BH16NS55", not the disc content). + const detectedTitle = String( + device.discLabel + || device.label + || 'Unknown Disc' + ).trim(); + const explicitProfile = normalizeMediaProfile(device?.mediaProfile); + const inferredProfile = inferMediaProfileFromDeviceInfo(device); + let mediaProfile = isSpecificMediaProfile(explicitProfile) + ? explicitProfile + : (isSpecificMediaProfile(inferredProfile) + ? inferredProfile + : (explicitProfile || inferredProfile || 'other')); + let deviceWithProfile = { + ...device, + mediaProfile + }; + const effectiveAnalyzeDevicePath = this.normalizeDrivePath(deviceWithProfile?.path || requestedDevicePath); + if (effectiveAnalyzeDevicePath) { + await this._cleanupReplaceableDriveJobsForAnalyze(effectiveAnalyzeDevicePath); + } + + // Fallback for Audio-CDs with ambiguous filesystem markers: + // if profile inference ended up as non-CD and the drive reports either no FS type + // or iso9660/cdfs, probe the TOC directly and force CD routing when tracks exist. + const analyzeFsType = String(deviceWithProfile?.fstype || device?.fstype || '').trim().toLowerCase(); + const isAmbiguousCdFsType = !analyzeFsType || analyzeFsType.includes('iso9660') || analyzeFsType.includes('cdfs'); + if ( + mediaProfile !== 'cd' + && String(device?.path || '').trim() + && isAmbiguousCdFsType + ) { + try { + const settingsMap = await settingsService.getSettingsMap(); + const cdparanoiaCmd = String(settingsMap?.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; + const tocTracks = await cdRipService.readToc(device.path, cdparanoiaCmd); + if (tocTracks.length > 0) { + logger.info('analyze:media-profile-override-to-cd', { + devicePath: device.path, + previousMediaProfile: mediaProfile, + trackCount: tocTracks.length + }); + mediaProfile = 'cd'; + deviceWithProfile = { + ...deviceWithProfile, + mediaProfile: 'cd', + fstype: deviceWithProfile.fstype || 'audio_cd' + }; + } + } catch (error) { + logger.debug('analyze:media-profile-cd-probe-failed', { + devicePath: device.path, + error: errorToMeta(error) + }); + } + } + + const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile, 'analyze'); + + // Route audio CDs to the dedicated CD pipeline + if (mediaProfile === 'cd') { + return this.analyzeCd(deviceWithProfile, { + plugin: analyzePlugin?.id === 'cd' ? analyzePlugin : null + }); + } + + const job = await historyService.createJob({ + discDevice: device.path, + status: 'ANALYZING', + detectedTitle, + jobKind: resolveJobKindForMediaProfile(mediaProfile) + }); + let analyzeDriveLockHeld = false; + if (effectiveAnalyzeDevicePath && !String(effectiveAnalyzeDevicePath).startsWith('__virtual__')) { + analyzeDriveLockHeld = this._acquireDriveLockForJob(effectiveAnalyzeDevicePath, job.id, { + stage: 'ANALYZING', + source: 'DRIVE_ANALYZE', + mediaProfile, + reason: 'drive_analyze_running' + }); + } + + // Surface the newly created analyze job immediately in the Ripper UI, + // even if metadata provider lookups (TMDb) take longer. + await this.setState('ANALYZING', { + activeJobId: job.id, + progress: 0, + eta: null, + statusText: 'Disk wird analysiert ...', + context: { + jobId: job.id, + device: deviceWithProfile, + detectedTitle, + mediaProfile + } + }); + + try { + let effectiveDetectedTitle = detectedTitle; + let metadataCandidates = []; + const metadataProvider = 'tmdb'; + let workflowKind = null; + let pluginExecution = null; + if (analyzePlugin) { + const pluginContext = await this.buildPluginContext(analyzePlugin.id, { + discInfo: deviceWithProfile, + jobId: job.id + }); + try { + const pluginResult = await analyzePlugin.analyze(device.path, job, pluginContext); + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); + if (pluginDetectedTitle) { + effectiveDetectedTitle = pluginDetectedTitle; + } + logger.info('plugin:analyze:used', { + jobId: job.id, + pluginId: analyzePlugin.id, + mediaProfile + }); + } catch (error) { + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + logger.warn('plugin:analyze:fallback-legacy', { + jobId: job.id, + pluginId: analyzePlugin.id, + mediaProfile, + error: errorToMeta(error) + }); + } + } + if (isSeriesDiscMediaProfile(mediaProfile)) { + await historyService.updateJob(job.id, { + status: 'METADATA_LOOKUP', + last_state: 'METADATA_LOOKUP' + }); + await this.setState('METADATA_LOOKUP', { + activeJobId: job.id, + progress: 0, + eta: null, + statusText: 'TMDb-Metadaten werden gesucht', + context: { + jobId: job.id, + device: deviceWithProfile, + detectedTitle: effectiveDetectedTitle, + mediaProfile, + metadataProvider + } + }); + metadataCandidates = await this.searchTmdbMixedMetadata(effectiveDetectedTitle, {}); + workflowKind = deriveWorkflowKindFromMetadataCandidates(metadataCandidates); + } + logger.info('metadata:prepare:result', { + jobId: job.id, + detectedTitle: effectiveDetectedTitle, + metadataProvider, + metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0 + }); + + const prepareInfo = this.withPluginExecutionMeta( + this.withAnalyzeContextMediaProfile({ + phase: 'PREPARE', + preparedAt: nowIso(), + analyzeContext: { + playlistAnalysis: null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + workflowKind: workflowKind || null, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null + } + }, mediaProfile), + pluginExecution + ); + + await historyService.updateJob(job.id, { + status: 'METADATA_SELECTION', + last_state: 'METADATA_SELECTION', + detected_title: effectiveDetectedTitle, + makemkv_info_json: JSON.stringify(prepareInfo) + }); + await historyService.appendLog( + job.id, + 'SYSTEM', + `${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Film- und Serien-Suche abgeschlossen (Suchvorschlag: "${effectiveDetectedTitle}").` + ); + + const keepCurrentPipelineSession = this._hasForeignInteractiveSession(job.id); + if (!keepCurrentPipelineSession) { + await this.setState('METADATA_SELECTION', { + activeJobId: job.id, + progress: 0, + eta: null, + statusText: 'Metadaten auswählen', + context: { + jobId: job.id, + device: deviceWithProfile, + detectedTitle: effectiveDetectedTitle, + detectedTitleSource: effectiveDetectedTitle !== detectedTitle + ? 'plugin' + : (device.discLabel ? 'discLabel' : 'fallback'), + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + mediaProfile, + workflowKind: workflowKind || null, + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null, + playlistAnalysis: null, + playlistDecisionRequired: false, + playlistCandidates: [], + selectedPlaylist: null, + selectedTitleId: null, + ...(pluginExecution ? { pluginExecution } : {}) + } + }); + } else { + const foreignActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); + await historyService.appendLog( + job.id, + 'SYSTEM', + `Metadaten-Auswahl im Hintergrund vorbereitet. Aktive Session bleibt bei interaktivem Job #${foreignActiveJobId || 'unbekannt'}.` + ); + } + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Metadaten bereit', + message: `Job #${job.id}: Metadaten müssen manuell zugeordnet werden` + }); + + return { + jobId: job.id, + detectedTitle: effectiveDetectedTitle, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null, + workflowKind: workflowKind || null + }; + } catch (error) { + logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) }); + await this.failJob(job.id, 'METADATA_SELECTION', error); + throw error; + } finally { + if (analyzeDriveLockHeld) { + this._releaseDriveLockForJob(job.id, { reason: 'drive_analyze_finished' }); + } + } + } + + async searchTmdbMovies(query) { + const rawQuery = String(query || '').trim(); + const queryVariants = buildMetadataSearchQueryVariants(rawQuery); + if (queryVariants.length === 0) { + return []; + } + + logger.info('tmdb:movie-search', { + query: rawQuery, + variants: queryVariants + }); + const tmdbConfigured = await tmdbService.isConfigured(); + if (!tmdbConfigured) { + const error = new Error('TMDb Read Access Token fehlt oder ist ungültig.'); + error.statusCode = 412; + throw error; + } + + let tmdbLanguage = null; + try { + const effectiveSettings = await settingsService.getEffectiveSettingsMap('dvd'); + tmdbLanguage = String(effectiveSettings?.dvd_series_language || '').trim() || null; + } catch (_settingsError) { + tmdbLanguage = null; + } + + for (const variant of queryVariants) { + const results = await tmdbService.searchMoviesWithDetails(variant, { + language: tmdbLanguage + }); + if (Array.isArray(results) && results.length > 0) { + logger.info('tmdb:movie-search:done', { + query: rawQuery, + matchedVariant: variant, + count: results.length + }); + return results; + } + } + logger.info('tmdb:movie-search:done', { + query: rawQuery, + matchedVariant: null, + count: 0 + }); + return []; + } + + async searchTmdbSeries(query, seasonNumber = null) { + const rawQuery = String(query || '').trim(); + const queryVariants = buildMetadataSearchQueryVariants(rawQuery); + if (queryVariants.length === 0) { + const error = new Error('TMDb-Suche benötigt einen Titel.'); + error.statusCode = 400; + throw error; + } + + const tmdbConfigured = await tmdbService.isConfigured(); + if (!tmdbConfigured) { + const error = new Error('TMDb Read Access Token fehlt oder ist ungültig.'); + error.statusCode = 412; + throw error; + } + + const seasonFilter = String(seasonNumber || '').trim(); + const applySeasonFilter = (rows) => { + const sourceRows = Array.isArray(rows) ? rows : []; + const failureCode = tmdbService.readFailureCode(sourceRows); + if (!seasonFilter) { + return sourceRows; + } + const seasonFilterLower = seasonFilter.toLowerCase(); + const filteredRows = sourceRows.filter((row) => { + const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase(); + const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase(); + return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower); + }); + return tmdbService.attachFailureCode(filteredRows, failureCode); + }; + logger.info('tmdb:series-search', { query: rawQuery, seasonNumber: seasonFilter || null, variants: queryVariants }); + + let tmdbLanguage = null; + try { + const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null; + } catch (_settingsError) { + tmdbLanguage = null; + } + + const runTmdbSearchForVariant = async (variantQuery) => { + const normalizedQuery = String(variantQuery || '').trim(); + const cacheKey = [ + normalizedQuery.toLowerCase(), + seasonFilter.toLowerCase(), + String(tmdbLanguage || '').trim().toLowerCase() + ].join('::'); + const cacheEntry = tmdbSeriesSearchCache.get(cacheKey); + const nowTs = Date.now(); + if (cacheEntry && cacheEntry.expiresAt > nowTs && Array.isArray(cacheEntry.results)) { + logger.info('tmdb:series-search:cache-hit', { + query: rawQuery, + attemptedVariant: normalizedQuery, + seasonNumber: seasonFilter || null, + count: cacheEntry.results.length + }); + return cloneTmdbSeriesSearchResults(cacheEntry.results); + } + if (cacheEntry && cacheEntry.expiresAt <= nowTs) { + tmdbSeriesSearchCache.delete(cacheKey); + } + + let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, { + language: tmdbLanguage + }); + normalizedResults = applySeasonFilter(normalizedResults); + const firstPassFailureCode = tmdbService.readFailureCode(normalizedResults); + + if (normalizedResults.length === 0) { + if (firstPassFailureCode) { + logger.warn('tmdb:series-search:empty:first-pass:skip-retry', { + query: rawQuery, + attemptedVariant: normalizedQuery, + seasonNumber: seasonFilter || null, + failureCode: firstPassFailureCode + }); + } else { + logger.warn('tmdb:series-search:empty:first-pass', { + query: rawQuery, + attemptedVariant: normalizedQuery, + seasonNumber: seasonFilter || null + }); + let retryResults = []; + try { + retryResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, { + language: tmdbLanguage + }); + } catch (retryError) { + logger.warn('tmdb:series-search:retry:failed', { + query: rawQuery, + attemptedVariant: normalizedQuery, + seasonNumber: seasonFilter || null, + error: errorToMeta(retryError) + }); + } + normalizedResults = applySeasonFilter(retryResults); + } + } + + if (normalizedResults.length === 0) { + return []; + } + + const cacheNowTs = Date.now(); + if (tmdbSeriesSearchCache.size >= 200) { + for (const [key, entry] of tmdbSeriesSearchCache.entries()) { + if (!entry || entry.expiresAt <= cacheNowTs) { + tmdbSeriesSearchCache.delete(key); + } + } + if (tmdbSeriesSearchCache.size >= 200) { + const oldestKey = tmdbSeriesSearchCache.keys().next().value; + if (oldestKey) { + tmdbSeriesSearchCache.delete(oldestKey); + } + } + } + + tmdbSeriesSearchCache.set(cacheKey, { + results: cloneTmdbSeriesSearchResults(normalizedResults), + expiresAt: cacheNowTs + TMDB_SERIES_SEARCH_CACHE_TTL_MS + }); + + return normalizedResults; + }; + + for (const variant of queryVariants) { + const results = await runTmdbSearchForVariant(variant); + if (Array.isArray(results) && results.length > 0) { + logger.info('tmdb:series-search:done', { + query: rawQuery, + matchedVariant: variant, + count: results.length + }); + return results; + } + } + logger.info('tmdb:series-search:done', { + query: rawQuery, + matchedVariant: null, + count: 0, + empty: true + }); + return []; + } + + async searchTmdbMixedMetadata(query, options = {}) { + const rawQuery = String(query || '').trim(); + if (!rawQuery) { + return []; + } + const normalizedSeasonNumber = normalizePositiveInteger(options?.seasonNumber); + const [movieResult, seriesResult] = await Promise.allSettled([ + this.searchTmdbMovies(rawQuery), + this.searchTmdbSeries(rawQuery, normalizedSeasonNumber || null) + ]); + + if (movieResult.status === 'rejected' && seriesResult.status === 'rejected') { + throw movieResult.reason || seriesResult.reason; + } + + const movieRows = movieResult.status === 'fulfilled' && Array.isArray(movieResult.value) + ? movieResult.value + : []; + const seriesRows = seriesResult.status === 'fulfilled' && Array.isArray(seriesResult.value) + ? seriesResult.value + : []; + + const mixed = [ + ...movieRows.map((row) => normalizeMetadataCandidateRow(row, 'movie')), + ...seriesRows.map((row) => normalizeMetadataCandidateRow(row, 'series')) + ]; + const deduped = []; + const seen = new Set(); + for (const row of mixed) { + const rowType = normalizeMetadataCandidateType(row, null); + const seasonNumberKey = rowType === 'series' ? (normalizePositiveInteger(row?.seasonNumber) || 'none') : 'none'; + const key = String( + row?.providerId + || `${rowType}:${String(row?.tmdbId || '').trim()}:${seasonNumberKey}:${String(row?.title || '').trim().toLowerCase()}` + ).trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(row); + } + + logger.info('tmdb:mixed-search:done', { + query: rawQuery, + movieCount: movieRows.length, + seriesCount: seriesRows.length, + totalCount: deduped.length + }); + return deduped; + } + + async runDiscTrackReviewForJob(jobId, deviceInfo = null, options = {}) { + this.ensureNotBusy('runDiscTrackReviewForJob', jobId); + logger.info('disc-track-review:start', { jobId, deviceInfo, options }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + mediaProfile: options?.mediaProfile, + deviceInfo, + makemkvInfo: mkInfo + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); + let reviewPluginExecution = null; + const analyzeContext = mkInfo?.analyzeContext || {}; + let effectiveAnalyzeContext = analyzeContext; + const playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; + const selectedPlaylistId = normalizePlaylistId( + options?.selectedPlaylist + || analyzeContext.selectedPlaylist + || this.snapshot.context?.selectedPlaylist + || null + ); + const selectedMakemkvTitleIdRaw = + options?.selectedTitleId + ?? analyzeContext.selectedTitleId + ?? this.snapshot.context?.selectedTitleId + ?? null; + const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedMakemkvTitleIdRaw); + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); + let effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + + await this.setState('MEDIAINFO_CHECK', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'Vorab-Spurprüfung (Disc) läuft', + context: { + ...(this.snapshot.context || {}), + jobId, + reviewConfirmed: false, + mode: 'pre_rip', + mediaProfile, + selectedMetadata + } + }); + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)), + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + + const lines = []; + let runInfo = null; + let sourceArg = null; + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + deviceInfo, + reviewMode: 'pre_rip', + source: 'HANDBRAKE_SCAN', + silent: !this.isPrimaryJob(jobId) + }); + appendLinesInPlace(lines, pluginScan.scanLines); + runInfo = pluginScan.runInfo || null; + sourceArg = pluginScan.sourceArg || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + + const parsed = parseMediainfoJsonOutput(lines.join('\n')); + if (!parsed) { + const error = new Error('HandBrake Scan-Ausgabe konnte nicht als JSON gelesen werden.'); + error.runInfo = runInfo; + throw error; + } + if (isSeriesDiscMediaProfile(mediaProfile)) { + const seriesScanLines = Array.isArray(pluginScan?.scanAnalysisLines) && pluginScan.scanAnalysisLines.length > 0 + ? pluginScan.scanAnalysisLines + : lines; + const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, seriesScanLines); + effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; + effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, effectiveAnalyzeContext); + } + + if (effectiveIsSeriesDvdReview) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Mindestlängen-Filter für Vorab-Spurprüfung ist deaktiviert.` + ); + } + + const review = buildDiscScanReview({ + scanJson: parsed, + settings, + playlistAnalysis, + selectedPlaylistId, + selectedMakemkvTitleId, + mediaProfile, + sourceArg, + disableMinLengthFilter: effectiveIsSeriesDvdReview + }); + + if (!Array.isArray(review.titles) || review.titles.length === 0) { + const error = new Error('Vorab-Spurprüfung lieferte keine Titel.'); + error.statusCode = 400; + throw error; + } + + const persistedMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + source: 'disc_scan', + runInfo + }, reviewPluginExecution); + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + analyzeContext: effectiveAnalyzeContext + }, mediaProfile)), + mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), + encode_plan_json: JSON.stringify(review), + encode_input_path: review.encodeInputPath || null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorab-Spurprüfung abgeschlossen: ${review.titles.length} Titel, Auswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.` + ); + + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: review.titleSelectionRequired + ? 'Vorab-Spurprüfung fertig - Titel per Checkbox wählen' + : 'Vorab-Spurprüfung fertig - Auswahl bestätigen, dann Backup/Encode starten', + context: { + ...(this.snapshot.context || {}), + jobId, + inputPath: review.encodeInputPath || null, + hasEncodableTitle: Boolean(review.encodeInputTitleId), + reviewConfirmed: false, + mode: 'pre_rip', + mediaProfile, + mediaInfoReview: review, + selectedMetadata, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + + return review; + } + + async handleDiscTrackReviewFailure(jobId, error, context = {}) { + const message = error?.message || String(error); + const runInfo = error?.runInfo && typeof error.runInfo === 'object' + ? error.runInfo + : null; + const isDiscScanFailure = String(runInfo?.source || '').toUpperCase() === 'HANDBRAKE_SCAN' + || /no title found/i.test(message); + + if (!isDiscScanFailure) { + await this.failJob(jobId, 'MEDIAINFO_CHECK', error); + return; + } + + logger.warn('disc-track-review:fallback-to-manual-rip', { + jobId, + message, + runInfo: runInfo || null + }); + + await historyService.updateJob(jobId, { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + error_message: null, + mediainfo_info_json: JSON.stringify({ + source: 'disc_scan', + failedAt: nowIso(), + error: message, + runInfo + }), + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorab-Spurprüfung fehlgeschlagen (${message}). Fallback: Backup/Rip kann manuell gestartet werden; Spurauswahl erfolgt danach.` + ); + + await this.setState('READY_TO_START', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'Vorab-Spurprüfung fehlgeschlagen - Backup manuell starten', + context: { + ...(this.snapshot.context || {}), + jobId, + selectedMetadata: context.selectedMetadata || this.snapshot.context?.selectedMetadata || null, + playlistAnalysis: context.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null, + playlistDecisionRequired: Boolean(context.playlistDecisionRequired ?? this.snapshot.context?.playlistDecisionRequired), + playlistCandidates: context.playlistCandidates || this.snapshot.context?.playlistCandidates || [], + selectedPlaylist: context.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null, + selectedTitleId: context.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null, + preRipScanFailed: true, + preRipScanError: message + } + }); + } + + async runBackupTrackReviewForJob(jobId, rawPath, options = {}) { + this.ensureNotBusy('runBackupTrackReviewForJob', jobId); + logger.info('backup-track-review:start', { jobId, rawPath, options }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + options = await this.resolveMultipartReviewLockOptions(job, options); + + if (!rawPath || !fs.existsSync(rawPath)) { + const error = new Error(`RAW-Pfad nicht gefunden (${rawPath || '-'})`); + error.statusCode = 400; + throw error; + } + + const mode = String(options?.mode || 'rip').trim().toLowerCase() || 'rip'; + const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection); + const forceFreshAnalyze = Boolean(options?.forceFreshAnalyze); + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + mediaProfile: options?.mediaProfile, + rawPath, + makemkvInfo: mkInfo + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); + let reviewPluginExecution = null; + const analyzeContext = mkInfo?.analyzeContext || {}; + let playlistAnalysis = forceFreshAnalyze + ? null + : (analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null); + let handBrakePlaylistScan = forceFreshAnalyze + ? null + : normalizeHandBrakePlaylistScanCache(analyzeContext.handBrakePlaylistScan || null); + if (playlistAnalysis && handBrakePlaylistScan) { + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + } + let playlistRuntimeFilter = { + applied: false, + reason: 'not_evaluated' + }; + const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze) + ? (options?.selectedPlaylist || null) + : (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null); + const selectedPlaylistExplicit = Boolean(normalizePlaylistId(options?.selectedPlaylist || null)); + let selectedPlaylistId = normalizePlaylistId( + selectedPlaylistSource + ); + const selectedTitleSource = (forcePlaylistReselection || forceFreshAnalyze) + ? (options?.selectedTitleId ?? null) + : (options?.selectedTitleId ?? analyzeContext.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null); + const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedTitleSource); + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); + const disableAnalyzeMinLengthFilter = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const isSeriesBackupReview = disableAnalyzeMinLengthFilter; + if (playlistAnalysis && !isSeriesBackupReview) { + playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, { + selectedMetadata, + settings, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + playlistAnalysis = playlistRuntimeFilter.playlistAnalysis; + } + const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId); + const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList( + options?.selectedHandBrakeTitleIds + || (preSelectedHandBrakeTitleId ? [preSelectedHandBrakeTitleId] : []) + ); + let handBrakePlaylistMapScanJson = null; + let handBrakePlaylistMapScanLines = []; + let handBrakePlaylistMapRunInfo = null; + + if (this.isPrimaryJob(jobId)) { + await this.setState('MEDIAINFO_CHECK', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'Titel-/Spurprüfung aus RAW-Backup läuft', + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath, + inputPath: null, + hasEncodableTitle: false, + reviewConfirmed: false, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + selectedMetadata + } + }); + } + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)), + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + + if (forcePlaylistReselection && !selectedPlaylistId) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Re-Encode: gespeicherte Playlist-Auswahl wird ignoriert. Bitte Playlist manuell neu auswählen.' + ); + } + if (forceFreshAnalyze) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Review-Neustart erzwingt frische MakeMKV Full-Analyse (kein Reuse von Playlist-/HandBrake-Cache).' + ); + } + + // Build playlist->TITLE_ID mapping once from MakeMKV full robot scan on RAW backup. + let makeMkvAnalyzeRunInfo = null; + let analyzedFromFreshRun = false; + const existingPostBackupAnalyze = mkInfo?.postBackupAnalyze && typeof mkInfo.postBackupAnalyze === 'object' + ? mkInfo.postBackupAnalyze + : null; + + await this.ensureMakeMKVRegistration(jobId, 'MEDIAINFO_CHECK'); + if (selectedPlaylistId) { + if (!playlistAnalysis || !Array.isArray(playlistAnalysis?.titles) || playlistAnalysis.titles.length === 0) { + const error = new Error( + 'Playlist-Auswahl kann nicht fortgesetzt werden: MakeMKV-Mapping fehlt. Bitte zuerst die RAW-Analyse erneut starten.' + ); + error.statusCode = 409; + throw error; + } + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Verwende vorhandenes MakeMKV-Playlist-Mapping aus dem letzten Full-Scan (kein erneuter Full-Scan).' + ); + } else { + const analyzeLines = []; + const analyzeConfig = await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { + mediaProfile, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + logger.info('backup-track-review:makemkv-analyze-command', { + jobId, + cmd: analyzeConfig.cmd, + args: analyzeConfig.args, + sourceArg: analyzeConfig.sourceArg + }); + + makeMkvAnalyzeRunInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'MAKEMKV_ANALYZE_BACKUP', + cmd: analyzeConfig.cmd, + args: analyzeConfig.args, + parser: parseMakeMkvProgress, + collectLines: analyzeLines, + silent: !this.isPrimaryJob(jobId) + }); + + const minLengthMinutesForAnalyze = disableAnalyzeMinLengthFilter + ? 0 + : Number(settings.makemkv_min_length_minutes || 60); + const analyzed = analyzePlaylistObfuscation( + analyzeLines, + minLengthMinutesForAnalyze, + {} + ); + playlistAnalysis = analyzed || null; + if (playlistAnalysis && !isSeriesBackupReview) { + playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, { + selectedMetadata, + settings, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + playlistAnalysis = playlistRuntimeFilter.playlistAnalysis; + } + analyzedFromFreshRun = true; + } + + const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired); + let playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + if (selectedPlaylistId && playlistCandidates.length > 0) { + const isKnownPlaylistAfterRuntimeFilter = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId); + if (!isKnownPlaylistAfterRuntimeFilter && !selectedPlaylistExplicit) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorherige Playlist-Auswahl ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId} passt nicht mehr zur aktuellen TMDb-Runtime-Filterung und wurde verworfen.` + ); + selectedPlaylistId = null; + } + } + let selectedTitleFromPlaylist = selectedPlaylistId + ? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId) + : null; + const selectedTitleFromContextFallback = (!selectedPlaylistId && !isCandidateTitleId(playlistAnalysis, selectedMakemkvTitleId)) + ? null + : selectedMakemkvTitleId; + let selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContextFallback ?? null; + if (selectedPlaylistId && playlistCandidates.length > 0) { + const isKnownPlaylist = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId); + if (!isKnownPlaylist) { + const error = new Error(`Playlist ${selectedPlaylistId}.mpls ist nicht in den erkannten Kandidaten enthalten.`); + error.statusCode = 400; + throw error; + } + } + + const shouldPrepareHandBrakeDecisionData = Boolean( + playlistDecisionRequired + && !selectedPlaylistId + && playlistCandidates.length > 0 + ); + const shouldPrepareAlternativeFeatureReviewData = Boolean( + selectedPlaylistId + && !isSeriesBackupReview + && playlistCandidates.length > 1 + ); + const shouldPrepareExpandedHandBrakePlaylistData = ( + shouldPrepareHandBrakeDecisionData + || shouldPrepareAlternativeFeatureReviewData + ); + if (shouldPrepareExpandedHandBrakePlaylistData) { + const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates); + if (!hasCompleteCache) { + await this.updateProgress( + 'MEDIAINFO_CHECK', + 25, + null, + shouldPrepareAlternativeFeatureReviewData + ? 'HandBrake Trackdaten für alternative Filmfassungen werden vorbereitet' + : 'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet', + jobId + ); + try { + const resolveScanLines = []; + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + silent: !this.isPrimaryJob(jobId) + }); + appendLinesInPlace(resolveScanLines, pluginScan.scanLines); + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + + const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); + handBrakePlaylistMapScanJson = resolveScanJson; + handBrakePlaylistMapScanLines = Array.isArray(resolveScanLines) ? [...resolveScanLines] : []; + handBrakePlaylistMapRunInfo = pluginScan.runInfo || null; + if (resolveScanJson) { + const preparedCache = buildHandBrakePlaylistScanCache(resolveScanJson, playlistCandidates, rawPath); + if (preparedCache) { + handBrakePlaylistScan = preparedCache; + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + if (playlistAnalysis && !isSeriesBackupReview) { + playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, { + selectedMetadata, + settings, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + playlistAnalysis = playlistRuntimeFilter.playlistAnalysis; + } + playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + await historyService.appendLog( + jobId, + 'SYSTEM', + shouldPrepareAlternativeFeatureReviewData + ? `HandBrake Trackdaten für alternative Filmfassungen vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.` + : `HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.` + ); + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + shouldPrepareAlternativeFeatureReviewData + ? 'HandBrake Trackdaten für alternative Filmfassungen konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.' + : 'HandBrake Playlist-Trackdaten konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.' + ); + } + } else { + const error = new Error('HandBrake Playlist-Trackdaten konnten nicht geparst werden.'); + error.runInfo = null; + throw error; + } + } catch (error) { + logger.warn('backup-track-review:handbrake-predecision-failed', { + jobId, + error: errorToMeta(error) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + shouldPrepareAlternativeFeatureReviewData + ? `HandBrake Vorbereitung für alternative Filmfassungen fehlgeschlagen: ${error.message}` + : `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}` + ); + throw error; + } + } else { + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + if (playlistAnalysis && !isSeriesBackupReview) { + playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, { + selectedMetadata, + settings, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + playlistAnalysis = playlistRuntimeFilter.playlistAnalysis; + } + playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + await historyService.appendLog( + jobId, + 'SYSTEM', + shouldPrepareAlternativeFeatureReviewData + ? 'HandBrake Trackdaten für alternative Filmfassungen aus Cache übernommen (kein erneuter --scan -t 0).' + : 'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).' + ); + } + } + + if (playlistRuntimeFilter.applied) { + const runtimeLabel = `${playlistRuntimeFilter.runtimeMinutes} min`; + const lowerBoundLabel = formatDurationClock(playlistRuntimeFilter.lowerBoundSeconds) + || `${playlistRuntimeFilter.lowerBoundSeconds}s`; + const deductionLabel = playlistRuntimeFilter.configuredPercent > 0 + ? `${Number(playlistRuntimeFilter.configuredPercent).toFixed(2).replace(/\.00$/, '')}%` + : '2 min Standardtoleranz'; + await historyService.appendLog( + jobId, + 'SYSTEM', + `TMDb-Runtime-Playlistfilter: ${runtimeLabel}, Untergrenze ${lowerBoundLabel} ` + + `(Abzug ${deductionLabel}, min. ${formatDurationClock(PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS)}). ` + + `${playlistRuntimeFilter.sourceCount} -> ${playlistRuntimeFilter.resultCount} Playlist-Kandidaten.` + ); + } + + let selectedPlaylistByRuntimeAutoMatch = false; + const shouldTryRuntimeAutoMatch = Boolean( + playlistDecisionRequired + && !selectedPlaylistId + && !isSeriesBackupReview + && isSeriesDiscMediaProfile(mediaProfile) + && playlistCandidates.length > 1 + ); + if (shouldTryRuntimeAutoMatch) { + const runtimeAutoMatch = resolvePlaylistByRuntimeAutoMatch({ + playlistCandidates, + selectedMetadata + }); + if (runtimeAutoMatch?.playlistId) { + selectedPlaylistId = runtimeAutoMatch.playlistId; + selectedTitleFromPlaylist = pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId); + selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContextFallback ?? null; + selectedPlaylistByRuntimeAutoMatch = true; + await historyService.appendLog( + jobId, + 'SYSTEM', + `TMDb Runtime-Abgleich: ${runtimeAutoMatch.runtimeMinutes} min, Toleranz ±02:00.` + ); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Automatische Playlist-Auswahl via Runtime-Match: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}` + + ` (Dauer ${formatDurationClock(runtimeAutoMatch.durationSeconds) || `${runtimeAutoMatch.durationSeconds}s`},` + + ` Delta ${runtimeAutoMatch.deltaSeconds}s).` + ); + } + } + + const updatedMakemkvInfoBase = { + ...mkInfo, + analyzeContext: { + ...(mkInfo?.analyzeContext || {}), + mediaProfile, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired, + selectedPlaylist: selectedPlaylistId || null, + selectedTitleId: selectedTitleForContext, + handBrakePlaylistScan: handBrakePlaylistScan || null + }, + postBackupAnalyze: analyzedFromFreshRun + ? { + analyzedAt: nowIso(), + source: 'MAKEMKV_ANALYZE_BACKUP', + runInfo: makeMkvAnalyzeRunInfo, + error: null + } + : { + analyzedAt: existingPostBackupAnalyze?.analyzedAt || nowIso(), + source: 'MAKEMKV_ANALYZE_BACKUP', + runInfo: existingPostBackupAnalyze?.runInfo || null, + reused: true, + error: null + } + }; + const buildUpdatedMakemkvInfo = () => this.withPluginExecutionMeta(updatedMakemkvInfoBase, reviewPluginExecution); + const getMergedReviewPluginExecution = () => this.sanitizePluginExecutionState( + buildUpdatedMakemkvInfo()?.pluginExecution + ); + + if (isSeriesBackupReview) { + const normalizedSeriesPreselection = normalizeReviewTitleIdList(preSelectedHandBrakeTitleIds); + let seriesScanJson = handBrakePlaylistMapScanJson; + let seriesScanLines = Array.isArray(handBrakePlaylistMapScanLines) ? [...handBrakePlaylistMapScanLines] : []; + let seriesScanRunInfo = handBrakePlaylistMapRunInfo; + + if (!seriesScanJson) { + await this.updateProgress( + 'MEDIAINFO_CHECK', + 30, + null, + 'HandBrake Serien-Titelanalyse läuft', + jobId + ); + const scanLines = []; + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + silent: !this.isPrimaryJob(jobId) + }); + appendLinesInPlace(scanLines, pluginScan.scanLines); + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + seriesScanRunInfo = pluginScan.runInfo || null; + seriesScanJson = parseMediainfoJsonOutput(scanLines.join('\n')); + seriesScanLines = scanLines; + handBrakePlaylistMapRunInfo = seriesScanRunInfo; + handBrakePlaylistMapScanLines = scanLines; + handBrakePlaylistMapScanJson = seriesScanJson; + if (!seriesScanJson) { + const error = new Error('HandBrake Serien-Scan lieferte kein parsebares JSON.'); + error.runInfo = seriesScanRunInfo; + throw error; + } + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake Serien-Titelanalyse aus vorhandenem Scan wiederverwendet.' + ); + } + + const seriesScanContextLines = seriesScanLines.length > 0 ? seriesScanLines : null; + const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan( + updatedMakemkvInfoBase.analyzeContext || {}, + seriesScanContextLines + ); + if (seriesScanContextResult?.analyzeContext && typeof seriesScanContextResult.analyzeContext === 'object') { + updatedMakemkvInfoBase.analyzeContext = seriesScanContextResult.analyzeContext; + } + if (seriesScanContextResult?.derived) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Titelklassifizierung aus HandBrake-Scan aktualisiert (PlayAll/Kurz/Duplikate markiert).' + ); + } + + const cacheSeedRows = Array.isArray(playlistAnalysis?.candidates) && playlistAnalysis.candidates.length > 0 + ? playlistAnalysis.candidates + : (Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []); + if (seriesScanJson) { + const preparedCache = buildHandBrakePlaylistScanCache(seriesScanJson, cacheSeedRows, rawPath); + if (preparedCache) { + handBrakePlaylistScan = preparedCache; + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + } + } + + const allSeriesTitles = parseHandBrakeTitleList(seriesScanJson); + const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates( + allSeriesTitles, + updatedMakemkvInfoBase.analyzeContext + ); + let seriesCandidates = filteredSeriesResult.candidates; + if (filteredSeriesResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Filter auf Blu-ray Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` + ); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Blu-ray Titel-Scan: ${allSeriesTitles.length} gesamt, ${seriesCandidates.length} Serienkandidaten.` + ); + + if (normalizedSeriesPreselection.length > 0) { + const selectedSet = new Set(normalizedSeriesPreselection.map((id) => Number(id))); + seriesCandidates = allSeriesTitles.filter((item) => selectedSet.has(Number(item?.handBrakeTitleId))); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Manuelle Serien-Titelauswahl übernommen: ${normalizedSeriesPreselection.map((id) => `-t ${id}`).join(', ')}.` + ); + } + + if ((!Array.isArray(seriesCandidates) || seriesCandidates.length === 0) && allSeriesTitles.length > 0) { + const rowsWithDuration = allSeriesTitles.filter((item) => Number(item?.durationSeconds || 0) > 0); + seriesCandidates = rowsWithDuration.length > 0 ? rowsWithDuration : allSeriesTitles; + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Heuristik lieferte keine Kandidaten; Fallback auf gesamte Titelliste.' + ); + } + + let seriesSinglePlayAllFallback = null; + if (normalizedSeriesPreselection.length === 0) { + seriesSinglePlayAllFallback = resolveSeriesSinglePlayAllFallback( + seriesCandidates, + allSeriesTitles, + selectedMetadata + ); + if (seriesSinglePlayAllFallback?.applied && Array.isArray(seriesSinglePlayAllFallback.candidates)) { + seriesCandidates = seriesSinglePlayAllFallback.candidates; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Sonderfall erkannt (ein PlayAll-Titel + Kurzclips): -t ${seriesSinglePlayAllFallback.keptTitleId} ` + + `behalten, ${seriesSinglePlayAllFallback.removedCount || 0} Kurzclips ausgeblendet.` + ); + } + } + + const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( + seriesCandidates, + allSeriesTitles + ); + if (seriesPlayAllDecision.required) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Grenzfall erkannt (PlayAll vs. Doppelfolge). Heuristik bleibt bei Episodenkandidaten ` + + `(${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'} vs ` + + `${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}).` + ); + } + + const seriesCandidateIds = normalizeReviewTitleIdList( + seriesCandidates.map((item) => item?.handBrakeTitleId) + ); + if (seriesCandidateIds.length === 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Heuristik konnte keine verwertbaren Titel-IDs auflösen. Fallback auf generische RAW-Spurprüfung.' + ); + return this.runMediainfoReviewForJob(jobId, rawPath, { + ...options, + mediaProfile + }); + } + + const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( + seriesScanJson, + seriesCandidateIds, + { encodeInputPath: rawPath } + ); + let resolvedSelectedTitleIds = normalizeReviewTitleIdList( + selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) + ); + if (resolvedSelectedTitleIds.length === 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Titel konnten nicht aus HandBrake-Scan übernommen werden. Fallback auf generische RAW-Spurprüfung.' + ); + return this.runMediainfoReviewForJob(jobId, rawPath, { + ...options, + mediaProfile + }); + } + let resolvedPrimaryTitleId = normalizeReviewTitleId(preSelectedHandBrakeTitleId) + || resolvedSelectedTitleIds[0] + || null; + if (!resolvedPrimaryTitleId || !resolvedSelectedTitleIds.includes(resolvedPrimaryTitleId)) { + resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; + } + if (!resolvedPrimaryTitleId) { + const error = new Error('Serien-Titelprüfung aus RAW fehlgeschlagen: keine primäre Titel-ID auflösbar.'); + error.statusCode = 400; + throw error; + } + + const primaryTitleInfo = parseHandBrakeSelectedTitleInfo(seriesScanJson, { + handBrakeTitleId: resolvedPrimaryTitleId, + strictHandBrakeTitleId: true + }); + if (!primaryTitleInfo) { + const error = new Error(`Kein HandBrake-Titel für Serien-Haupttitel -t ${resolvedPrimaryTitleId} gefunden.`); + error.statusCode = 400; + error.runInfo = seriesScanRunInfo; + throw error; + } + + let presetProfile = null; + try { + presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, { + titleId: resolvedPrimaryTitleId, + mediaProfile + }); + } catch (error) { + logger.warn('backup-track-review:series:preset-profile-failed', { + jobId, + error: errorToMeta(error) + }); + presetProfile = { + source: 'fallback', + message: `Preset-Profil konnte nicht geladen werden: ${error.message}` + }; + } + + const syntheticFilePath = path.join( + rawPath, + `handbrake_t${String(Math.trunc(Number(resolvedPrimaryTitleId))).padStart(2, '0')}.mkv` + ); + const syntheticMediaInfoByPath = { + [syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(primaryTitleInfo) + }; + let review = buildMediainfoReview({ + mediaFiles: [{ + path: syntheticFilePath, + size: Number(primaryTitleInfo?.sizeBytes || 0) + }], + mediaInfoByPath: syntheticMediaInfoByPath, + settings: resolveReviewTrackLanguageSettings(settings, mediaProfile, true), + presetProfile, + playlistAnalysis, + preferredEncodeTitleId: null, + selectedPlaylistId: null, + selectedMakemkvTitleId: null + }); + review = remapReviewTrackIdsToSourceIds(review); + + const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); + let normalizedTitles = selectedReviewTitles.map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = selectedSet.has(Number(titleId)); + return { + ...title, + selectedForEncode, + encodeInput: titleId === resolvedPrimaryTitleId + }; + }); + if (normalizedTitles.length === 0) { + normalizedTitles = review.titles || []; + } + + review = { + ...review, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + reviewConfirmed: false, + partial: false, + processedFiles: normalizedTitles.length, + totalFiles: normalizedTitles.length, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds, + selectedPlaylistId: null, + selectedMakemkvTitleId: null, + titleSelectionRequired: false, + titles: normalizedTitles, + selectedTitleIds: resolvedSelectedTitleIds, + encodeInputTitleId: resolvedPrimaryTitleId, + encodeInputPath: rawPath, + notes: [ + ...(Array.isArray(review.notes) ? review.notes : []), + 'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.', + ...(seriesSinglePlayAllFallback?.applied ? [ + `Serien-Sonderfall: Ein langer PlayAll-Titel wurde automatisch gewählt (Titel #${seriesSinglePlayAllFallback.keptTitleId}); ` + + `${seriesSinglePlayAllFallback.removedCount || 0} kurze Zusatz-Titel wurden ausgeblendet.` + ] : []), + `HandBrake Serien-Heuristik aktiv: ${resolvedSelectedTitleIds.length} Episoden-Titel aus --scan -t 0 übernommen.` + ] + }; + + const shortTitleFilterResult = filterSeriesBackupReviewTitles(review); + if (shortTitleFilterResult.applied) { + review = shortTitleFilterResult.plan; + resolvedSelectedTitleIds = normalizeReviewTitleIdList(review.selectedTitleIds || []); + resolvedPrimaryTitleId = normalizeReviewTitleId(review.encodeInputTitleId) + || resolvedSelectedTitleIds[0] + || resolvedPrimaryTitleId; + review = { + ...review, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Kurztitel-Filter aktiv: ${shortTitleFilterResult.sourceCount} -> ${shortTitleFilterResult.resultCount}` + + ` Titel (Schwelle ${formatDurationClock(shortTitleFilterResult.shortThresholdSeconds)}).` + ); + } + + const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( + review, + options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null + ); + const multipartLockResult = applyMultipartSettingsLockToReviewPlan( + reviewPrefillResult.plan, + options?.multipartSettingsLock || null + ); + review = multipartLockResult.plan; + const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(review, { + mediaProfile, + isSeriesSelection: true + }); + review = reviewDefaultUserPresetResult.plan; + resolvedSelectedTitleIds = normalizeReviewTitleIdList(review.selectedTitleIds || []); + resolvedPrimaryTitleId = normalizeReviewTitleId(review.encodeInputTitleId) + || resolvedSelectedTitleIds[0] + || resolvedPrimaryTitleId; + + updatedMakemkvInfoBase.analyzeContext = { + ...(updatedMakemkvInfoBase.analyzeContext || {}), + mediaProfile, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + selectedHandBrakeTitleId: resolvedPrimaryTitleId || null, + selectedHandBrakeTitleIds: resolvedSelectedTitleIds, + handBrakePlaylistScan: handBrakePlaylistScan || null + }; + + const persistedMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + source: 'raw_backup_handbrake_series_scan', + makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo, + makemkvTitleAnalyzeRunInfo: null, + handbrakePlaylistResolveRunInfo: seriesScanRunInfo, + handbrakeTitleRunInfo: seriesScanRunInfo, + handbrakeTitleId: resolvedPrimaryTitleId || null, + handbrakeTitleIds: resolvedSelectedTitleIds + }, reviewPluginExecution); + + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()), + mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), + encode_plan_json: JSON.stringify(review), + encode_input_path: review.encodeInputPath || null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Titel-/Spurprüfung aus RAW abgeschlossen: ${review.titles.length} Titel, Vorauswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.` + ); + if (reviewPrefillResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, ` + + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, ` + + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, ` + + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` + ); + } + if (multipartLockResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` + ); + } + + const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId); + const readyStatusText = review.titleSelectionRequired + ? 'Titel-/Spurprüfung fertig - Titel per Checkbox wählen' + : (hasEncodableTitle + ? 'Titel-/Spurprüfung fertig - Auswahl bestätigen, dann Encode manuell starten' + : 'Titel-/Spurprüfung fertig - kein Titel erfüllt MIN_LENGTH_MINUTES'); + if (this.isPrimaryJob(jobId)) { + const mergedPluginExecutionForReady = getMergedReviewPluginExecution(); + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: readyStatusText, + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath, + inputPath: review.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: review, + selectedMetadata, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired: false, + playlistCandidates, + selectedPlaylist: null, + selectedTitleId: null, + ...(mergedPluginExecutionForReady ? { pluginExecution: mergedPluginExecutionForReady } : {}) + } + }); + } else { + await this.updateProgress('READY_TO_ENCODE', 0, null, readyStatusText, jobId, { + contextPatch: { + jobId, + rawPath, + inputPath: review.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: review, + selectedMetadata, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired: false, + playlistCandidates, + selectedPlaylist: null, + selectedTitleId: null + } + }); + } + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - RAW geprüft', + message: `Job #${jobId}: bereit zum manuellen Encode-Start` + }); + + return review; + } + + if (playlistDecisionRequired && !selectedPlaylistId) { + const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates) + ? playlistAnalysis.evaluatedCandidates + : []; + const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); + const decisionContext = describePlaylistManualDecision(playlistAnalysis); + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + source: 'makemkv_backup_robot', + runInfo: makeMkvAnalyzeRunInfo + }, reviewPluginExecution); + + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog(jobId, 'SYSTEM', 'Mehrere mögliche Haupttitel erkannt!'); + await historyService.appendLog(jobId, 'SYSTEM', decisionContext.detailText); + for (const candidate of evaluated) { + await historyService.appendLog(jobId, 'SYSTEM', buildPlaylistCandidateDiagnosticLine(candidate)); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Status=awaiting_playlist_selection${recommendationFile ? ` | Empfehlung=${recommendationFile}` : ''}` + ); + + const mergedPluginExecutionForWaiting = getMergedReviewPluginExecution(); + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'awaiting_playlist_selection', + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath, + inputPath: null, + hasEncodableTitle: false, + reviewConfirmed: false, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + selectedMetadata, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired: true, + playlistCandidates, + selectedPlaylist: null, + selectedTitleId: null, + waitingForManualPlaylistSelection: true, + manualDecisionState: 'awaiting_playlist_selection', + mediaInfoReview: null, + ...(mergedPluginExecutionForWaiting ? { pluginExecution: mergedPluginExecutionForWaiting } : {}) + } + }); + + const notificationMessage = [ + '⚠️ Manuelle Prüfung erforderlich!', + decisionContext.obfuscationDetected + ? 'Mehrere gleichlange Playlists erkannt.' + : 'Mehrere Playlists erfüllen MIN_LENGTH_MINUTES.', + '', + 'Empfehlung:', + recommendationFile || '(keine eindeutige Empfehlung)', + '', + 'Bitte Titel manuell bestätigen,', + 'bevor Encoding gestartet wird.' + ].join('\n'); + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Playlist-Auswahl erforderlich', + message: notificationMessage, + priority: 1 + }); + + return { + awaitingPlaylistSelection: true, + playlistAnalysis, + playlistCandidates, + recommendation: playlistAnalysis?.recommendation || null + }; + } + + if (selectedPlaylistId) { + await historyService.appendLog( + jobId, + selectedPlaylistByRuntimeAutoMatch ? 'SYSTEM' : 'USER_ACTION', + selectedPlaylistByRuntimeAutoMatch + ? `Playlist-Auswahl automatisch übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` + : `Playlist-Auswahl übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` + ); + } + + const selectedTitleForReview = pickTitleIdForTrackReview(playlistAnalysis, selectedTitleForContext); + if (selectedTitleForReview === null) { + logger.warn('backup-track-review:no-title-id-fallback-mediainfo', { + jobId, + selectedPlaylistId: selectedPlaylistId || null, + selectedTitleForContext, + hasPlaylistAnalysis: Boolean(playlistAnalysis), + candidateCount: Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates.length : 0, + titleCount: Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles.length : 0 + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + 'MakeMKV-Playlist-Mapping ohne verwertbare Titel-ID. Fallback auf generische RAW-Spurprüfung (ohne Playlist-Mapping).' + ); + return this.runMediainfoReviewForJob(jobId, rawPath, { + ...options, + mediaProfile + }); + } + + const selectedTitleFromAnalysis = Array.isArray(playlistAnalysis?.titles) + ? (playlistAnalysis.titles.find((item) => Number(item?.titleId) === Number(selectedTitleForReview)) || null) + : null; + const resolvedPlaylistId = normalizePlaylistId( + selectedPlaylistId + || selectedTitleFromAnalysis?.playlistId + || playlistAnalysis?.recommendation?.playlistId + || null + ); + if (!resolvedPlaylistId) { + const error = new Error( + `Playlist konnte für MakeMKV Titel #${selectedTitleForReview} nicht aufgelöst werden.` + ); + error.statusCode = 400; + throw error; + } + + if (updatedMakemkvInfoBase && updatedMakemkvInfoBase.analyzeContext) { + updatedMakemkvInfoBase.analyzeContext.selectedTitleId = selectedTitleForReview; + updatedMakemkvInfoBase.analyzeContext.selectedPlaylist = resolvedPlaylistId; + } + + const cachedHandBrakePlaylistEntry = getCachedHandBrakePlaylistEntry(handBrakePlaylistScan, resolvedPlaylistId); + const expectedDurationForCache = Number(selectedTitleFromAnalysis?.durationSeconds || 0) || null; + const expectedSizeForCache = Number(selectedTitleFromAnalysis?.sizeBytes || 0) || null; + const playlistAliasesForResolve = normalizePlaylistAliasIds( + [ + ...(Array.isArray(selectedTitleFromAnalysis?.playlistAliases) ? selectedTitleFromAnalysis.playlistAliases : []), + ...getPlaylistAliasesForPlaylist(playlistAnalysis, resolvedPlaylistId) + ], + resolvedPlaylistId + ); + const hasCachedHandBrakeEntry = Boolean( + isHandBrakePlaylistCacheEntryCompatible( + cachedHandBrakePlaylistEntry, + resolvedPlaylistId, + { + expectedDurationSeconds: expectedDurationForCache, + expectedSizeBytes: expectedSizeForCache, + playlistAliases: playlistAliasesForResolve + } + ) + ); + if (cachedHandBrakePlaylistEntry && !hasCachedHandBrakeEntry) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Cache für ${toPlaylistFile(resolvedPlaylistId)} verworfen (inkompatible Playlist-/Dauerdaten).` + ); + } + + await this.updateProgress( + 'MEDIAINFO_CHECK', + 30, + null, + hasCachedHandBrakeEntry + ? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})` + : `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`, + jobId + ); + + let handBrakeResolveRunInfo = null; + let handBrakeTitleRunInfo = null; + let resolvedHandBrakeTitleId = null; + const reviewTitleSource = 'handbrake'; + const makeMkvSubtitleTracksForSelection = Array.isArray(selectedTitleFromAnalysis?.subtitleTracks) + ? selectedTitleFromAnalysis.subtitleTracks + : []; + let reviewTitleInfo = null; + if (hasCachedHandBrakeEntry) { + resolvedHandBrakeTitleId = Math.trunc(Number(cachedHandBrakePlaylistEntry.handBrakeTitleId)); + reviewTitleInfo = enrichTitleInfoWithForcedSubtitleAvailability( + cachedHandBrakePlaylistEntry.titleInfo, + makeMkvSubtitleTracksForSelection + ); + handBrakeResolveRunInfo = { + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP_CACHE', + stage: 'MEDIAINFO_CHECK', + status: 'CACHED', + exitCode: 0, + startedAt: handBrakePlaylistScan?.generatedAt || null, + endedAt: nowIso(), + durationMs: 0, + cmd: 'cache', + args: [`playlist=${resolvedPlaylistId}`, `title=${resolvedHandBrakeTitleId}`], + highlights: [ + `Cache verwendet: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId}` + ] + }; + handBrakeTitleRunInfo = handBrakeResolveRunInfo; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Track-Analyse aus Cache: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (kein erneuter --scan).` + ); + } else { + try { + const resolveScanLines = []; + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + silent: !this.isPrimaryJob(jobId) + }); + appendLinesInPlace(resolveScanLines, pluginScan.scanLines); + handBrakeResolveRunInfo = pluginScan.runInfo || null; + const resolveScanStatus = String(handBrakeResolveRunInfo?.status || '').trim().toUpperCase(); + if (resolveScanStatus === 'CANCELLED') { + const error = new Error('HandBrake Playlist-Mapping wurde abgebrochen.'); + error.statusCode = 409; + error.runInfo = handBrakeResolveRunInfo; + throw error; + } + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + + const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); + if (!resolveScanJson) { + const error = new Error('HandBrake Playlist-Mapping lieferte kein parsebares JSON.'); + error.runInfo = handBrakeResolveRunInfo; + throw error; + } + const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson); + if (knownPlaylists.length === 0 && resolveScanStatus && resolveScanStatus !== 'SUCCESS') { + const error = new Error( + `HandBrake Playlist-Mapping unvollständig (${resolveScanStatus}). ` + + 'Scan enthält keine erkennbaren Playlist-IDs.' + ); + error.statusCode = resolveScanStatus === 'CANCELLED' ? 409 : 502; + error.runInfo = handBrakeResolveRunInfo; + throw error; + } + + resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, { + expectedMakemkvTitleId: selectedTitleForReview, + expectedDurationSeconds: expectedDurationForCache, + expectedSizeBytes: expectedSizeForCache, + playlistAliases: playlistAliasesForResolve + }); + if (!resolvedHandBrakeTitleId) { + const error = new Error( + `Kein HandBrake-Titel für ${toPlaylistFile(resolvedPlaylistId)} gefunden.` + + ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}` + ); + error.statusCode = 400; + error.runInfo = handBrakeResolveRunInfo; + throw error; + } + + reviewTitleInfo = parseHandBrakeSelectedTitleInfo(resolveScanJson, { + playlistId: resolvedPlaylistId, + handBrakeTitleId: resolvedHandBrakeTitleId, + makeMkvSubtitleTracks: makeMkvSubtitleTracksForSelection + }); + if (!reviewTitleInfo) { + const error = new Error( + `HandBrake lieferte keine verwertbaren Trackdaten für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).` + ); + error.statusCode = 400; + error.runInfo = handBrakeResolveRunInfo; + throw error; + } + if (!isHandBrakePlaylistCacheEntryCompatible({ + playlistId: resolvedPlaylistId, + handBrakeTitleId: resolvedHandBrakeTitleId, + titleInfo: reviewTitleInfo + }, resolvedPlaylistId, { + expectedDurationSeconds: expectedDurationForCache, + expectedSizeBytes: expectedSizeForCache, + playlistAliases: playlistAliasesForResolve + })) { + const error = new Error( + `HandBrake Titel-Mapping inkonsistent für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).` + ); + error.statusCode = 400; + error.runInfo = handBrakeResolveRunInfo; + throw error; + } + + handBrakeTitleRunInfo = handBrakeResolveRunInfo; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).` + ); + + const audioTrackPreview = buildHandBrakeAudioTrackPreview(reviewTitleInfo); + const fallbackCache = normalizeHandBrakePlaylistScanCache(handBrakePlaylistScan) || { + generatedAt: nowIso(), + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + inputPath: rawPath, + byPlaylist: {} + }; + fallbackCache.byPlaylist[resolvedPlaylistId] = { + playlistId: resolvedPlaylistId, + handBrakeTitleId: Math.trunc(Number(resolvedHandBrakeTitleId)), + titleInfo: reviewTitleInfo, + audioTrackPreview, + audioSummary: buildHandBrakeAudioSummary(audioTrackPreview) + }; + handBrakePlaylistScan = normalizeHandBrakePlaylistScanCache(fallbackCache); + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + } catch (error) { + logger.warn('backup-track-review:handbrake-scan-failed', { + jobId, + selectedPlaylistId: resolvedPlaylistId, + selectedTitleForReview, + error: errorToMeta(error) + }); + throw error; + } + } + + if (updatedMakemkvInfoBase && updatedMakemkvInfoBase.analyzeContext) { + updatedMakemkvInfoBase.analyzeContext.handBrakePlaylistScan = handBrakePlaylistScan || null; + } + playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + + let presetProfile = null; + try { + presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, { + titleId: resolvedHandBrakeTitleId, + mediaProfile + }); + } catch (error) { + logger.warn('backup-track-review:preset-profile-failed', { + jobId, + error: errorToMeta(error) + }); + presetProfile = { + source: 'fallback', + message: `Preset-Profil konnte nicht geladen werden: ${error.message}` + }; + } + + const alternativeReviewCandidates = collectAlternativeFeatureReviewCandidates({ + playlistCandidates, + handBrakePlaylistScan, + playlistAnalysis, + selectedPlaylistId: resolvedPlaylistId, + selectedHandBrakeTitleId: resolvedHandBrakeTitleId, + selectedTitleInfo: reviewTitleInfo, + makeMkvSubtitleTracks: makeMkvSubtitleTracksForSelection + }); + const reviewCandidateRows = alternativeReviewCandidates.length > 0 + ? alternativeReviewCandidates + : [{ + playlistId: resolvedPlaylistId, + handBrakeTitleId: resolvedHandBrakeTitleId, + titleInfo: reviewTitleInfo, + sourceRow: null, + durationSeconds: Number(reviewTitleInfo?.durationSeconds || 0) || 0, + sizeBytes: Number(reviewTitleInfo?.sizeBytes || 0) || 0, + playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId), + isSelectedDefault: true + }]; + + const buildSyntheticCandidatePath = (handBrakeTitleId, fallbackTitleId) => ( + path.join( + rawPath, + reviewTitleSource === 'handbrake' && Number.isFinite(Number(handBrakeTitleId)) && Number(handBrakeTitleId) > 0 + ? `handbrake_t${String(Math.trunc(Number(handBrakeTitleId))).padStart(2, '0')}.mkv` + : `makemkv_t${String(fallbackTitleId).padStart(2, '0')}.mkv` + ) + ); + + const reviewCandidatesWithPaths = reviewCandidateRows.map((candidate, index) => { + const fallbackTitleId = Number(selectedTitleForReview || index + 1) || (index + 1); + return { + ...candidate, + syntheticFilePath: buildSyntheticCandidatePath(candidate.handBrakeTitleId, fallbackTitleId) + }; + }); + + const syntheticMediaInfoByPath = Object.fromEntries( + reviewCandidatesWithPaths.map((candidate) => [ + candidate.syntheticFilePath, + buildSyntheticMediaInfoFromMakeMkvTitle(candidate.titleInfo) + ]) + ); + const allowAlternativeTitleSelection = reviewCandidatesWithPaths.length > 1; + let review = buildMediainfoReview({ + mediaFiles: reviewCandidatesWithPaths.map((candidate) => ({ + path: candidate.syntheticFilePath, + size: Number(candidate?.titleInfo?.sizeBytes || 0) + })), + mediaInfoByPath: syntheticMediaInfoByPath, + settings: resolveReviewTrackLanguageSettings( + settings, + mediaProfile, + isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext) + ), + presetProfile, + playlistAnalysis, + preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview, + selectedPlaylistId: allowAlternativeTitleSelection + ? null + : (resolvedPlaylistId || reviewTitleInfo?.playlistId || null), + selectedMakemkvTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview + }); + review = remapReviewTrackIdsToSourceIds(review); + + const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0); + const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60)); + const candidateMetaBySyntheticPath = new Map( + reviewCandidatesWithPaths.map((candidate) => [candidate.syntheticFilePath, candidate]) + ); + + const buildNormalizedReviewTitle = (title) => { + const candidateMeta = candidateMetaBySyntheticPath.get(String(title?.filePath || '').trim()) || null; + const candidateTitleInfo = candidateMeta?.titleInfo || reviewTitleInfo; + const candidatePlaylistInfo = candidateMeta?.playlistInfo || resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId); + const candidateHandBrakeTitleId = normalizeReviewTitleId( + candidateMeta?.handBrakeTitleId + ?? resolvedHandBrakeTitleId + ?? title?.handBrakeTitleId + ?? title?.titleIndex + ?? title?.id + ?? selectedTitleForReview + ); + const durationSeconds = Number(candidateTitleInfo?.durationSeconds || title?.durationSeconds || 0); + const eligibleForEncode = durationSeconds >= minLengthSecondsForReview; + const subtitleTrackMetaBySourceId = new Map( + (Array.isArray(candidateTitleInfo?.subtitleTracks) ? candidateTitleInfo.subtitleTracks : []) + .map((track) => { + const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; + return sourceTrackId ? [sourceTrackId, track] : null; + }) + .filter(Boolean) + ); + const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { + const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; + const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null; + const selectedByRule = Boolean(track?.selectedByRule ?? sourceMeta?.selectedByRule); + const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate); + const subtitleType = String( + sourceMeta?.subtitleType + || track?.subtitleType + || (sourceMeta?.forcedTrack ? 'forced' : (track?.forcedTrack ? 'forced' : 'full')) + ).trim().toLowerCase() === 'forced' + ? 'forced' + : 'full'; + const rawConfidence = String(sourceMeta?.sourceConfidence || track?.sourceConfidence || '') + .trim() + .toLowerCase(); + const confidenceSource = String(sourceMeta?.confidenceSource || track?.confidenceSource || '') + .trim() + .toLowerCase(); + let sourceConfidence = null; + if (subtitleType === 'forced') { + if (rawConfidence === 'high' || rawConfidence === 'medium' || rawConfidence === 'low') { + sourceConfidence = rawConfidence; + } else if (confidenceSource === 'title') { + sourceConfidence = 'medium'; + } else { + sourceConfidence = 'low'; + } + } + const subtitlePreviewBurnIn = Boolean(track?.subtitlePreviewBurnIn ?? sourceMeta?.subtitlePreviewBurnIn); + const subtitlePreviewForced = Boolean( + track?.subtitlePreviewForced + ?? sourceMeta?.subtitlePreviewForced + ?? (selectedByRule && subtitleType === 'forced') + ); + const subtitlePreviewForcedOnly = Boolean( + track?.subtitlePreviewForcedOnly + ?? sourceMeta?.subtitlePreviewForcedOnly + ); + const isForcedOnly = Boolean( + track?.isForcedOnly + ?? sourceMeta?.isForcedOnly + ?? sourceMeta?.forcedOnly + ?? track?.forcedOnly + ?? subtitlePreviewForcedOnly + ?? subtitleType === 'forced' + ); + const fullHasForced = !isForcedOnly && Boolean( + track?.fullHasForced + ?? sourceMeta?.fullHasForced + ?? sourceMeta?.subtitleFullHasForced + ?? track?.subtitleFullHasForced + ); + const subtitlePreviewDefaultTrack = Boolean( + track?.subtitlePreviewDefaultTrack + ?? sourceMeta?.subtitlePreviewDefaultTrack + ?? sourceMeta?.defaultFlag + ?? track?.defaultFlag + ); + const subtitlePreviewFlags = Array.isArray(track?.subtitlePreviewFlags) + ? track.subtitlePreviewFlags + : (Array.isArray(sourceMeta?.subtitlePreviewFlags) ? sourceMeta.subtitlePreviewFlags : []); + const subtitlePreviewSummary = String( + track?.subtitlePreviewSummary + || sourceMeta?.subtitlePreviewSummary + || (selectedByRule + ? 'Übernehmen' + : (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen')) + ).trim() || 'Nicht übernommen'; + const selectedForEncode = selectedByRule; + return { + ...track, + id: sourceTrackId || track?.id || null, + sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null, + language: sourceMeta?.language || track?.language || 'und', + languageLabel: sourceMeta?.languageLabel || track?.languageLabel || track?.language || 'und', + title: sourceMeta?.title ?? track?.title ?? null, + format: sourceMeta?.format || track?.format || null, + defaultFlag: Boolean(sourceMeta?.defaultFlag ?? track?.defaultFlag), + eventCount: Number.isFinite(Number(sourceMeta?.eventCount)) + ? Math.trunc(Number(sourceMeta.eventCount)) + : (Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null), + streamSizeBytes: Number.isFinite(Number(sourceMeta?.streamSizeBytes)) + ? Math.trunc(Number(sourceMeta.streamSizeBytes)) + : (Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null), + forcedTrack: Boolean(sourceMeta?.forcedTrack ?? subtitlePreviewForced), + forcedAvailable: Boolean(sourceMeta?.forcedAvailable), + forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || []), + isForcedOnly, + fullHasForced, + subtitleType, + sourceConfidence, + confidenceSource, + duplicate, + selected: selectedByRule, + selectedByRule, + selectedForEncode, + subtitlePreviewSummary, + subtitlePreviewFlags, + subtitlePreviewBurnIn, + subtitlePreviewForced, + subtitlePreviewForcedOnly, + subtitlePreviewDefaultTrack, + burnIn: selectedForEncode ? subtitlePreviewBurnIn : false, + forced: selectedForEncode ? subtitlePreviewForced : false, + forcedOnly: selectedForEncode ? subtitlePreviewForcedOnly : false, + defaultTrack: selectedForEncode ? subtitlePreviewDefaultTrack : false, + flags: selectedForEncode ? subtitlePreviewFlags : [], + subtitleActionSummary: selectedForEncode ? subtitlePreviewSummary : 'Nicht übernommen' + }; + }); + + return { + ...title, + id: candidateHandBrakeTitleId || title?.id || null, + handBrakeTitleId: candidateHandBrakeTitleId || null, + filePath: rawPath, + fileName: candidateTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(2)), + selectedByMinLength: eligibleForEncode, + eligibleForEncode, + sizeBytes: Number(candidateTitleInfo?.sizeBytes || title?.sizeBytes || 0), + playlistId: candidatePlaylistInfo.playlistId || title?.playlistId || null, + playlistFile: candidatePlaylistInfo.playlistFile || title?.playlistFile || null, + playlistAliases: Array.isArray(candidatePlaylistInfo.playlistAliases) ? candidatePlaylistInfo.playlistAliases : [], + playlistRecommended: Boolean(candidatePlaylistInfo.recommended || title?.playlistRecommended), + playlistEvaluationLabel: candidatePlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null, + playlistSegmentCommand: candidatePlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null, + playlistSegmentFiles: Array.isArray(candidatePlaylistInfo.segmentFiles) && candidatePlaylistInfo.segmentFiles.length > 0 + ? candidatePlaylistInfo.segmentFiles + : (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []), + subtitleTracks + }; + }; + + const normalizedTitles = (Array.isArray(review.titles) ? review.titles : []) + .map((title) => buildNormalizedReviewTitle(title)) + .filter((title) => normalizeReviewTitleId(title?.id)); + + const encodeInputTitleId = normalizeReviewTitleId( + normalizedTitles.find((title) => Boolean(title?.playlistId && normalizePlaylistId(title.playlistId) === normalizePlaylistId(resolvedPlaylistId)))?.id + || normalizedTitles.find((title) => normalizeReviewTitleId(title?.handBrakeTitleId) === normalizeReviewTitleId(resolvedHandBrakeTitleId))?.id + || normalizedTitles[0]?.id + || review?.encodeInputTitleId + || null + ); + review = { + ...review, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + reviewConfirmed: false, + partial: false, + processedFiles: normalizedTitles.length, + totalFiles: normalizedTitles.length, + handBrakeTitleId: resolvedHandBrakeTitleId || null, + selectedPlaylistId: resolvedPlaylistId || null, + selectedMakemkvTitleId: selectedTitleForReview, + titleSelectionRequired: false, + titles: normalizedTitles, + selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [], + encodeInputTitleId, + encodeInputPath: rawPath, + alternativeTitleSelection: normalizedTitles.length > 1 + ? { + enabled: true, + reason: 'same_or_longer_titles', + matchedTitleId: encodeInputTitleId || null, + matchedPlaylistId: normalizePlaylistId(resolvedPlaylistId) || null, + matchedDurationSeconds: Number(reviewTitleInfo?.durationSeconds || 0) || 0, + optionCount: normalizedTitles.length + } + : null, + notes: [ + ...(Array.isArray(review.notes) ? review.notes : []), + 'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.', + `HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).`, + ...(normalizedTitles.length > 1 + ? ['Weitere gleichlange oder längere Titel wurden gefunden. In der Review kann zwischen alternativen Schnittfassungen/Playlist-Varianten umgeschaltet werden.'] + : []) + ] + }; + review = applyEncodeTitleSelectionToPlan(review, encodeInputTitleId, encodeInputTitleId ? [encodeInputTitleId] : []).plan; + + const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( + review, + options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null + ); + const multipartLockResult = applyMultipartSettingsLockToReviewPlan( + reviewPrefillResult.plan, + options?.multipartSettingsLock || null + ); + review = multipartLockResult.plan; + const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(review, { + mediaProfile, + isSeriesSelection: isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext) + }); + review = reviewDefaultUserPresetResult.plan; + + if (!Array.isArray(review.titles) || review.titles.length === 0) { + const error = new Error('Titel-/Spurprüfung aus RAW lieferte keine Titel.'); + error.statusCode = 400; + throw error; + } + + const persistedMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + source: 'raw_backup_handbrake_playlist_scan', + makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo, + makemkvTitleAnalyzeRunInfo: null, + handbrakePlaylistResolveRunInfo: handBrakeResolveRunInfo, + handbrakeTitleRunInfo: handBrakeTitleRunInfo, + handbrakeTitleId: resolvedHandBrakeTitleId || null + }, reviewPluginExecution); + + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()), + mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), + encode_plan_json: JSON.stringify(review), + encode_input_path: review.encodeInputPath || null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Titel-/Spurprüfung aus RAW abgeschlossen (MakeMKV Titel #${selectedTitleForReview}): ${review.titles.length} Titel, Vorauswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.` + ); + if (reviewPrefillResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, ` + + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, ` + + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, ` + + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` + ); + } + if (multipartLockResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` + ); + } + if (playlistDecisionRequired) { + const playlistFiles = playlistCandidates.map((item) => item.playlistFile).filter(Boolean); + const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); + const decisionContext = describePlaylistManualDecision(playlistAnalysis); + await historyService.appendLog( + jobId, + 'SYSTEM', + `${decisionContext.detailText} (RAW). Kandidaten: ${playlistFiles.join(', ') || 'keine'}.` + ); + if (recommendationFile) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Playlist-Empfehlung: ${recommendationFile}` + ); + } + for (const candidate of playlistCandidates) { + await historyService.appendLog(jobId, 'SYSTEM', buildPlaylistCandidateDiagnosticLine(candidate)); + } + } + + const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId); + const readyStatusText = review.titleSelectionRequired + ? 'Titel-/Spurprüfung fertig - Titel per Checkbox wählen' + : (hasEncodableTitle + ? 'Titel-/Spurprüfung fertig - Auswahl bestätigen, dann Encode manuell starten' + : 'Titel-/Spurprüfung fertig - kein Titel erfüllt MIN_LENGTH_MINUTES'); + if (this.isPrimaryJob(jobId)) { + const mergedPluginExecutionForReady = getMergedReviewPluginExecution(); + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: readyStatusText, + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath, + inputPath: review.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: review, + selectedMetadata, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired, + playlistCandidates, + selectedPlaylist: resolvedPlaylistId || null, + selectedTitleId: selectedTitleForReview, + ...(mergedPluginExecutionForReady ? { pluginExecution: mergedPluginExecutionForReady } : {}) + } + }); + } else { + await this.updateProgress('READY_TO_ENCODE', 0, null, readyStatusText, jobId, { + contextPatch: { + jobId, + rawPath, + inputPath: review.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: review, + selectedMetadata, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired, + playlistCandidates, + selectedPlaylist: resolvedPlaylistId || null, + selectedTitleId: selectedTitleForReview + } + }); + } + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - RAW geprüft', + message: `Job #${jobId}: bereit zum manuellen Encode-Start` + }); + + return review; + } + + async runReviewForRawJob(jobId, rawPath, options = {}) { + const useBackupReview = hasBluRayBackupStructure(rawPath); + logger.info('review:dispatch', { + jobId, + rawPath, + mode: options?.mode || 'rip', + useBackupReview + }); + + if (useBackupReview) { + return this.runBackupTrackReviewForJob(jobId, rawPath, options); + } + return this.runMediainfoReviewForJob(jobId, rawPath, options); + } + + async submitRawDecision(jobId, decision) { + const job = await historyService.getJobById(jobId); + if (!job) throw new Error('Job nicht gefunden.'); + + const isWaiting = ( + String(job.status || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' || + String(job.last_state || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' + ); + if (!isWaiting) { + throw new Error(`Job ist nicht im Status WAITING_FOR_USER_DECISION (${job.status})`); + } + + const mkInfo = this.safeParseJson(job.makemkv_info_json) || {}; + const currentAnalyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const playlistDecisionRequired = Boolean(currentAnalyzeContext.playlistDecisionRequired); + const selectedTitleId = currentAnalyzeContext.selectedTitleId ?? null; + const requiresManualPlaylistSelection = Boolean(playlistDecisionRequired && selectedTitleId === null); + + if (decision === 'multipart_orphan_merge') { + const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo }); + if (!isSeriesDiscMediaProfile(mediaProfile)) { + const error = new Error('Multipart movie via RAW-Entscheidung ist nur fuer DVD/Blu-ray verfuegbar.'); + error.statusCode = 409; + throw error; + } + + const selectedMetadata = resolveSelectedMetadataForJob(job, currentAnalyzeContext, null); + const workflowKind = normalizeDvdMetadataWorkflowKind( + selectedMetadata?.workflowKind + || currentAnalyzeContext?.workflowKind + || null + ); + if (workflowKind !== 'film') { + const error = new Error('Multipart movie via RAW-Entscheidung ist nur fuer Film-Metadaten erlaubt.'); + error.statusCode = 409; + throw error; + } + + const rawDecisionContext = currentAnalyzeContext?.rawDecisionContext && typeof currentAnalyzeContext.rawDecisionContext === 'object' + ? currentAnalyzeContext.rawDecisionContext + : {}; + if (!rawDecisionContext?.mergeOrphanEligible) { + const error = new Error('Dieser Job hat kein geeignetes Orphan-RAW fuer Multipart movie.'); + error.statusCode = 409; + throw error; + } + + const rawPathCandidate = normalizeComparablePath( + rawDecisionContext?.matchedRawPathAfterMetadata + || rawDecisionContext?.renamedRawPath + || job.raw_path + || null + ); + if (!rawPathCandidate) { + const error = new Error('Kein gueltiger RAW-Pfad fuer den Multipart-Import gefunden.'); + error.statusCode = 409; + throw error; + } + if (!fs.existsSync(rawPathCandidate) || !fs.statSync(rawPathCandidate).isDirectory()) { + const error = new Error(`RAW-Pfad fuer Multipart-Import nicht gefunden: ${rawPathCandidate}`); + error.statusCode = 404; + throw error; + } + + const pickNextAvailableDiscNumber = (takenValues = new Set(), start = 1) => { + let next = normalizePositiveInteger(start) || 1; + while (takenValues.has(next)) { + next += 1; + } + return next; + }; + + const filmMetadataFingerprint = String(job?.metadata_fingerprint || '').trim() + || buildMovieMetadataFingerprint(mediaProfile, { + imdbId: selectedMetadata?.imdbId || job?.imdb_id || null, + title: selectedMetadata?.title || job?.title || job?.detected_title || null, + year: selectedMetadata?.year || job?.year || null + }) + || null; + + let multipartContainerJob = null; + const parentJobId = normalizePositiveInteger(job?.parent_job_id); + if (parentJobId) { + const candidateParent = await historyService.getJobById(parentJobId).catch(() => null); + if (candidateParent && this.isMultipartMovieContainerHistoryJob(candidateParent)) { + multipartContainerJob = candidateParent; + } + } + if (!multipartContainerJob && filmMetadataFingerprint) { + const db = await getDb(); + const containerRow = await db.get( + ` + SELECT id + FROM jobs + WHERE job_kind = 'multipart_movie_container' + AND media_type = ? + AND metadata_fingerprint = ? + ORDER BY updated_at DESC, id DESC + LIMIT 1 + `, + [mediaProfile, filmMetadataFingerprint] + ); + if (containerRow?.id) { + multipartContainerJob = await historyService.getJobById(containerRow.id).catch(() => null); + } + } + if (!multipartContainerJob) { + multipartContainerJob = await historyService.createJob({ + discDevice: job?.disc_device || null, + status: 'READY_TO_START', + detectedTitle: selectedMetadata?.title || job?.title || job?.detected_title || null, + mediaType: mediaProfile, + jobKind: 'multipart_movie_container' + }); + } + + const multipartContainerJobId = normalizePositiveInteger(multipartContainerJob?.id); + if (!multipartContainerJobId) { + const error = new Error('Multipart-Container konnte nicht angelegt werden.'); + error.statusCode = 500; + throw error; + } + + const existingContainerChildren = await historyService.listChildJobs(multipartContainerJobId); + const takenDiscNumbers = new Set(); + for (const child of Array.isArray(existingContainerChildren) ? existingContainerChildren : []) { + const childId = normalizePositiveInteger(child?.id); + if (!childId || childId === Number(jobId)) { + continue; + } + const childDisc = resolveDiscNumberFromJobLike(child); + if (childDisc) { + takenDiscNumbers.add(childDisc); + } + } + + const suggestedPair = resolveMultipartDiscNumberPair({ + preferredCurrentDiscNumber: normalizePositiveInteger( + rawDecisionContext?.multipartSuggestion?.currentDiscNumber + ?? selectedMetadata?.discNumber + ?? job?.disc_number + ?? null + ), + preferredOrphanDiscNumber: normalizePositiveInteger( + rawDecisionContext?.multipartSuggestion?.orphanDiscNumber + ?? extractMultipartFilmDiscFromRawFolderName(path.basename(rawPathCandidate)) + ?? null + ) + }); + let orphanDiscNumber = suggestedPair.orphanDiscNumber; + if (takenDiscNumbers.has(orphanDiscNumber)) { + orphanDiscNumber = pickNextAvailableDiscNumber(takenDiscNumbers, orphanDiscNumber); + } + takenDiscNumbers.add(orphanDiscNumber); + + let currentDiscNumber = suggestedPair.currentDiscNumber; + if (!currentDiscNumber || takenDiscNumbers.has(currentDiscNumber)) { + currentDiscNumber = pickNextAvailableDiscNumber( + takenDiscNumbers, + currentDiscNumber || (orphanDiscNumber === 1 ? 2 : 1) + ); + } + if (currentDiscNumber === orphanDiscNumber) { + currentDiscNumber = pickNextAvailableDiscNumber(takenDiscNumbers, currentDiscNumber + 1); + } + takenDiscNumbers.add(currentDiscNumber); + + const orphanJob = await historyService.createJob({ + discDevice: null, + status: 'READY_TO_START', + detectedTitle: selectedMetadata?.title || job?.title || job?.detected_title || null, + mediaType: mediaProfile, + jobKind: 'multipart_movie_child' + }); + const orphanJobId = normalizePositiveInteger(orphanJob?.id); + if (!orphanJobId) { + const error = new Error('Orphan-Child-Job konnte nicht erstellt werden.'); + error.statusCode = 500; + throw error; + } + + const currentMultipartSelectedMetadata = normalizeMultipartMovieSelectedMetadata( + selectedMetadata, + currentDiscNumber + ); + const orphanMultipartSelectedMetadata = normalizeMultipartMovieSelectedMetadata( + selectedMetadata, + orphanDiscNumber + ); + const containerSelectedMetadata = normalizeMultipartMovieSelectedMetadata(selectedMetadata, null); + + const orphanAnalyzeContext = { + ...currentAnalyzeContext, + playlistAnalysis: null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + metadataProvider: 'tmdb', + workflowKind: 'film', + selectedMetadata: orphanMultipartSelectedMetadata, + rawDecisionContext: null + }; + const currentAnalyzeContextPatch = { + ...currentAnalyzeContext, + metadataProvider: 'tmdb', + workflowKind: 'film', + selectedMetadata: currentMultipartSelectedMetadata, + rawDecisionContext: null + }; + + const sourceRawState = resolveRawFolderStateFromPath(rawPathCandidate); + const orphanMetadataBase = buildRawMetadataBase({ + title: selectedMetadata?.title || job?.title || job?.detected_title || null, + year: selectedMetadata?.year || job?.year || null, + detected_title: job?.detected_title || null, + media_type: mediaProfile, + is_multipart_movie: 1, + job_kind: 'multipart_movie_child' + }, orphanJobId, { + mediaProfile, + analyzeContext: orphanAnalyzeContext, + selectedMetadata: orphanMultipartSelectedMetadata, + isMultipartMovie: true + }); + const orphanTargetRawPath = path.join( + path.dirname(rawPathCandidate), + buildRawDirName(orphanMetadataBase, orphanJobId, { state: sourceRawState }) + ); + let finalOrphanRawPath = rawPathCandidate; + if (normalizeComparablePath(orphanTargetRawPath) !== normalizeComparablePath(rawPathCandidate)) { + if (fs.existsSync(orphanTargetRawPath)) { + const error = new Error(`RAW-Zielpfad fuer Orphan-Child existiert bereits: ${orphanTargetRawPath}`); + error.statusCode = 409; + throw error; + } + fs.renameSync(rawPathCandidate, orphanTargetRawPath); + await historyService.updateRawPathByOldPath(rawPathCandidate, orphanTargetRawPath); + finalOrphanRawPath = orphanTargetRawPath; + } + + const topLevelSelectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' + ? mkInfo.selectedMetadata + : {}; + const orphanMakemkvInfo = this.withAnalyzeContextMediaProfile({ + ...mkInfo, + source: 'orphan_raw_import', + rawPath: finalOrphanRawPath, + analyzeContext: orphanAnalyzeContext, + selectedMetadata: { + ...topLevelSelectedMetadata, + ...orphanMultipartSelectedMetadata + } + }, mediaProfile); + const currentMakemkvInfo = this.withAnalyzeContextMediaProfile({ + ...mkInfo, + analyzeContext: currentAnalyzeContextPatch, + selectedMetadata: { + ...topLevelSelectedMetadata, + ...currentMultipartSelectedMetadata + } + }, mediaProfile); + const containerMakemkvInfo = this.withAnalyzeContextMediaProfile({ + analyzeContext: { + metadataProvider: 'tmdb', + workflowKind: 'film', + selectedMetadata: containerSelectedMetadata + }, + selectedMetadata: containerSelectedMetadata + }, mediaProfile); + + const commonTitle = selectedMetadata?.title || job?.title || job?.detected_title || null; + const commonYear = selectedMetadata?.year || job?.year || null; + const commonImdbId = selectedMetadata?.imdbId || job?.imdb_id || null; + const commonPoster = selectedMetadata?.poster || job?.poster_url || null; + + await historyService.updateJob(multipartContainerJobId, { + title: commonTitle, + year: commonYear, + imdb_id: commonImdbId, + poster_url: commonPoster, + selected_from_omdb: 0, + omdb_json: null, + status: 'READY_TO_START', + last_state: 'READY_TO_START', + media_type: mediaProfile, + parent_job_id: null, + job_kind: 'multipart_movie_container', + is_multipart_movie: 1, + disc_number: null, + metadata_fingerprint: filmMetadataFingerprint, + makemkv_info_json: JSON.stringify(containerMakemkvInfo) + }); + + await historyService.updateJob(orphanJobId, { + parent_job_id: multipartContainerJobId, + title: commonTitle, + year: commonYear, + imdb_id: commonImdbId, + poster_url: commonPoster, + selected_from_omdb: 0, + omdb_json: null, + status: 'READY_TO_START', + last_state: 'READY_TO_START', + media_type: mediaProfile, + job_kind: 'multipart_movie_child', + is_multipart_movie: 1, + disc_number: orphanDiscNumber, + metadata_fingerprint: filmMetadataFingerprint, + raw_path: finalOrphanRawPath, + output_path: null, + rip_successful: 1, + makemkv_info_json: JSON.stringify(orphanMakemkvInfo), + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + error_message: null, + end_time: null + }); + + const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; + await historyService.updateJob(jobId, { + parent_job_id: multipartContainerJobId, + title: commonTitle, + year: commonYear, + imdb_id: commonImdbId, + poster_url: commonPoster, + selected_from_omdb: 0, + omdb_json: null, + status: nextStatus, + last_state: nextStatus, + media_type: mediaProfile, + job_kind: 'multipart_movie_child', + is_multipart_movie: 1, + disc_number: currentDiscNumber, + metadata_fingerprint: filmMetadataFingerprint, + raw_path: null, + rip_successful: 0, + makemkv_info_json: JSON.stringify(currentMakemkvInfo), + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + error_message: null, + end_time: null + }); + + await historyService.appendLog( + orphanJobId, + 'SYSTEM', + `Orphan-RAW in Multipart-Container uebernommen: Container #${multipartContainerJobId}, Disc ${orphanDiscNumber}, RAW=${finalOrphanRawPath}` + ); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart movie vorbereitet: Container #${multipartContainerJobId}, Laufwerk=Disc ${currentDiscNumber}, Orphan=Disc ${orphanDiscNumber} (Job #${orphanJobId}).` + ); + await historyService.appendLog( + multipartContainerJobId, + 'SYSTEM', + `Multipart movie aus RAW-Entscheidung erstellt: Disc ${currentDiscNumber} (Job #${jobId}) + Disc ${orphanDiscNumber} (Job #${orphanJobId}).` + ); + + await this.upsertMultipartMergeJobForContainer(multipartContainerJobId).catch((mergePrepError) => { + logger.warn('multipart:merge:upsert-on-raw-decision-failed', { + containerJobId: multipartContainerJobId, + jobId, + orphanJobId, + error: errorToMeta(mergePrepError) + }); + }); + + await this.setState(nextStatus, { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : 'Multipart vorbereitet - Laufwerks-Rip startet', + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath: null, + mediaProfile, + selectedMetadata: currentMultipartSelectedMetadata, + waitingForRawDecision: false, + waitingForManualPlaylistSelection: requiresManualPlaylistSelection, + manualDecisionState: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : null, + rawDecisionOptions: null, + multipartContext: { + containerJobId: multipartContainerJobId, + orphanJobId, + currentDiscNumber, + orphanDiscNumber + } + } + }); + + if (requiresManualPlaylistSelection) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Multipart movie vorbereitet. Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor der Laufwerks-Rip gestartet wird.' + ); + return historyService.getJobById(jobId); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Starte Backup/Rip fuer Laufwerk-Disc ${currentDiscNumber}. Orphan-RAW bleibt als Disc ${orphanDiscNumber} (Job #${orphanJobId}) im Container.` + ); + await this.startPreparedJob(jobId); + return historyService.getJobById(jobId); + } + + if (decision === 'delete') { + const fsOptions = { recursive: true, force: true }; + if (job.raw_path && require('fs').existsSync(job.raw_path)) { + await require('fs').promises.rm(job.raw_path, fsOptions); + await historyService.appendLog(jobId, 'SYSTEM', `RAW-Verzeichnis wurde auf Benutzerwunsch gelöscht: ${job.raw_path}`); + } + } else if (decision === 'continue') { + await historyService.appendLog(jobId, 'SYSTEM', `Benutzer hat "Mit vorhandenem RAW weiter machen" gewählt.`); + } else if (decision === 'cancel') { + await historyService.appendLog(jobId, 'SYSTEM', `Benutzer hat auf Grund vorhandenem RAW abgebrochen.`); + await this.cancelJob(jobId); + return historyService.getJobById(jobId); + } else { + throw new Error(`Unbekannte Entscheidung: ${decision}`); + } + + const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; + + await this.setState(nextStatus, { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : (decision === 'continue' + ? 'Metadaten übernommen - vorhandenes RAW erkannt' + : 'Metadaten übernommen - bereit zum Start'), + context: { + ...(this.snapshot.context || {}), + waitingForRawDecision: false, + rawDecisionOptions: null, + manualDecisionState: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : null + } + }); + + if (requiresManualPlaylistSelection) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor Backup/Encoding gestartet wird.' + ); + return historyService.getJobById(jobId); + } + + if (decision === 'continue') { + await historyService.appendLog(jobId, 'SYSTEM', `Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW: ${job.raw_path}`); + await this.startPreparedJob(jobId); + return historyService.getJobById(jobId); + } + + await historyService.appendLog(jobId, 'SYSTEM', 'RAW gelöscht oder ignoriert. Starte Backup/Rip automatisch.'); + await this.startPreparedJob(jobId); + return historyService.getJobById(jobId); + } + + + async selectMetadata({ + jobId, + title, + year, + imdbId, + poster, + selectedPlaylist = null, + selectedHandBrakeTitleId = null, + selectedHandBrakeTitleIds = null, + metadataProvider = null, + providerId = null, + tmdbId = null, + metadataKind = null, + workflowKind = null, + seasonNumber = null, + seasonName = null, + episodeCount = null, + episodes = null, + discNumber = null, + duplicateAction = null, + existingJobId = null, + existingDiscNumber = null + }) { + this.ensureNotBusy('selectMetadata', jobId); + logger.info('metadata:selected', { + jobId, + title, + year, + imdbId, + poster, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, + metadataProvider, + providerId, + tmdbId, + workflowKind, + seasonNumber, + discNumber, + duplicateAction, + existingJobId, + existingDiscNumber + }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const encodePlanForProfile = this.safeParseJson(job.encode_plan_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo, encodePlan: encodePlanForProfile }); + if (mediaProfile === 'converter') { + const converterPlan = this.safeParseJson(job.encode_plan_json) || {}; + const converterInputPath = String(converterPlan?.inputPath || job?.raw_path || '').trim(); + if (!converterInputPath || !fs.existsSync(converterInputPath)) { + const error = new Error(`Converter-Eingabedatei nicht gefunden: ${converterInputPath || '-'}`); + error.statusCode = 404; + throw error; + } + + const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); + const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); + const normalizedSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds); + const effectiveSelectedHandBrakeTitleIds = normalizedSelectedHandBrakeTitleIds.length > 0 + ? normalizedSelectedHandBrakeTitleIds + : (normalizedSelectedHandBrakeTitleId ? [normalizedSelectedHandBrakeTitleId] : []); + const effectiveSelectedHandBrakeTitleId = normalizedSelectedHandBrakeTitleId + || effectiveSelectedHandBrakeTitleIds[0] + || null; + const waitingForDecision = ( + String(job.status || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' + || String(job.last_state || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' + ); + const hasExplicitMetadataPayload = ( + title !== undefined + || year !== undefined + || imdbId !== undefined + || poster !== undefined + ); + + if ((normalizedSelectedPlaylist || effectiveSelectedHandBrakeTitleId) && waitingForDecision && !hasExplicitMetadataPayload) { + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + mediainfo_info_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + effectiveSelectedHandBrakeTitleId + ? `Converter Titel-Auswahl gesetzt: -t ${effectiveSelectedHandBrakeTitleId}${effectiveSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectiveSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}.` + : `Converter Playlist-Auswahl gesetzt: ${toPlaylistFile(normalizedSelectedPlaylist) || normalizedSelectedPlaylist}.` + ); + + try { + await this.runMediainfoReviewForJob(jobId, converterInputPath, { + mode: 'converter', + mediaProfile: 'converter', + selectedPlaylist: normalizedSelectedPlaylist || null, + selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId || null, + selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds, + previousEncodePlan: converterPlan + }); + } catch (error) { + logger.error('metadata:converter:selection:review-failed', { + jobId, + selectedPlaylist: normalizedSelectedPlaylist || null, + selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId || null, + error: errorToMeta(error) + }); + await this.failJob(jobId, 'MEDIAINFO_CHECK', error); + throw error; + } + return historyService.getJobById(jobId); + } + + const hasTitleInput = title !== undefined && title !== null && String(title).trim().length > 0; + const effectiveTitle = hasTitleInput + ? String(title).trim() + : (job.title || job.detected_title || path.basename(converterInputPath, path.extname(converterInputPath)) || 'Converter Job'); + const hasYearInput = year !== undefined && year !== null && String(year).trim() !== ''; + let effectiveYear = job.year ?? null; + if (hasYearInput) { + const parsedYear = Number(year); + effectiveYear = Number.isNaN(parsedYear) ? null : parsedYear; + } + const effectiveImdbId = imdbId === undefined + ? (job.imdb_id || null) + : (imdbId || null); + const selectedFromMetadata = 0; + let posterValue = poster === undefined + ? (job.poster_url || null) + : (poster || null); + let shouldQueuePosterCache = false; + if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { + try { + const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, posterValue, { + source: 'Poster', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + posterValue = cacheResult.localUrl; + } else { + shouldQueuePosterCache = true; + } + } catch (_error) { + shouldQueuePosterCache = true; + } + } + const effectiveDiscNumber = normalizePositiveInteger(discNumber); + + const converterMetadataProvider = String(metadataProvider || 'tmdb').trim().toLowerCase() || 'tmdb'; + const nextEncodePlan = { + ...converterPlan, + mediaProfile: 'converter', + converterMediaType: String(converterPlan?.converterMediaType || '').trim().toLowerCase() || 'video', + inputPath: converterInputPath, + metadata: { + ...(converterPlan?.metadata && typeof converterPlan.metadata === 'object' ? converterPlan.metadata : {}), + title: effectiveTitle, + year: effectiveYear, + imdbId: effectiveImdbId, + poster: posterValue, + metadataProvider: converterMetadataProvider, + providerId: String(providerId || '').trim() || null, + tmdbId: Number(tmdbId || 0) || null, + metadataKind: String(metadataKind || '').trim().toLowerCase() || null, + seasonNumber: Number(seasonNumber || 0) || null, + seasonName: String(seasonName || '').trim() || null, + episodeCount: Number(episodeCount || 0) || 0, + episodes: Array.isArray(episodes) ? episodes : [], + ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) + }, + reviewConfirmed: false + }; + + await historyService.updateJob(jobId, { + title: effectiveTitle, + year: effectiveYear, + imdb_id: effectiveImdbId, + poster_url: posterValue, + selected_from_omdb: selectedFromMetadata, + omdb_json: null, + migrate_tmdb: 1, + status: 'READY_TO_START', + last_state: 'READY_TO_START', + raw_path: converterInputPath, + media_type: 'converter', + encode_plan_json: JSON.stringify(nextEncodePlan), + encode_review_confirmed: 0, + error_message: null + }); + + if (shouldQueuePosterCache && posterValue && !thumbnailService.isLocalUrl(posterValue)) { + historyService.queuePosterCache(jobId, posterValue, { + source: 'Poster', + logFailures: true + }); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Converter-Metadaten übernommen. Starte Mediainfo-Prüfung mit Titel-/Spurauswahl.' + ); + + await this.startPreparedJob(jobId); + return historyService.getJobById(jobId); + } + + const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); + const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); + const normalizedSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds); + const effectiveSelectedHandBrakeTitleIds = normalizedSelectedHandBrakeTitleIds.length > 0 + ? normalizedSelectedHandBrakeTitleIds + : (normalizedSelectedHandBrakeTitleId ? [normalizedSelectedHandBrakeTitleId] : []); + const effectiveSelectedHandBrakeTitleId = normalizedSelectedHandBrakeTitleId + || effectiveSelectedHandBrakeTitleIds[0] + || null; + const waitingForPlaylistSelection = ( + job.status === 'WAITING_FOR_USER_DECISION' + || job.last_state === 'WAITING_FOR_USER_DECISION' + ); + const hasExplicitMetadataPayload = ( + title !== undefined + || year !== undefined + || imdbId !== undefined + || poster !== undefined + ); + + if (effectiveSelectedHandBrakeTitleId && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload && !normalizedSelectedPlaylist) { + const currentMkInfo = mkInfo; + const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; + const updatedMkInfo = this.withAnalyzeContextMediaProfile({ + ...currentMkInfo, + analyzeContext: { + ...currentAnalyzeContext, + selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, + selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds + } + }, mediaProfile); + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + makemkv_info_json: JSON.stringify(updatedMkInfo), + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `DVD Titel-Auswahl gesetzt: -t ${effectiveSelectedHandBrakeTitleId}${effectiveSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectiveSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}.` + ); + + try { + await this.runMediainfoReviewForJob(jobId, job.raw_path, { + mode: 'rip', + mediaProfile, + selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, + selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds + }); + } catch (error) { + logger.error('metadata:handbrake-title-selection:review-failed', { + jobId, + selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, + selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds, + error: errorToMeta(error) + }); + await this.failJob(jobId, 'MEDIAINFO_CHECK', error); + throw error; + } + return historyService.getJobById(jobId); + } + + if (normalizedSelectedPlaylist && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload) { + const currentMkInfo = mkInfo; + const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; + const currentPlaylistAnalysis = currentAnalyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; + const selectedTitleId = pickTitleIdForPlaylist(currentPlaylistAnalysis, normalizedSelectedPlaylist); + const updatedMkInfo = this.withAnalyzeContextMediaProfile({ + ...currentMkInfo, + analyzeContext: { + ...currentAnalyzeContext, + playlistAnalysis: currentPlaylistAnalysis || null, + playlistDecisionRequired: Boolean(currentPlaylistAnalysis?.manualDecisionRequired), + selectedPlaylist: normalizedSelectedPlaylist, + selectedTitleId: selectedTitleId ?? null + } + }, mediaProfile); + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + makemkv_info_json: JSON.stringify(updatedMkInfo), + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Playlist-Auswahl gesetzt: ${toPlaylistFile(normalizedSelectedPlaylist) || normalizedSelectedPlaylist}.` + ); + + try { + await this.runBackupTrackReviewForJob(jobId, job.raw_path, { + mode: 'rip', + selectedPlaylist: normalizedSelectedPlaylist, + selectedTitleId: selectedTitleId ?? null, + mediaProfile + }); + } catch (error) { + logger.error('metadata:playlist-selection:review-failed', { + jobId, + selectedPlaylist: normalizedSelectedPlaylist, + selectedTitleId: selectedTitleId ?? null, + error: errorToMeta(error) + }); + await this.failJob(jobId, 'MEDIAINFO_CHECK', error); + throw error; + } + return historyService.getJobById(jobId); + } + + const hasTitleInput = title !== undefined && title !== null && String(title).trim().length > 0; + const effectiveTitle = hasTitleInput + ? String(title).trim() + : (job.title || job.detected_title || 'Unknown Title'); + const hasYearInput = year !== undefined && year !== null && String(year).trim() !== ''; + let effectiveYear = job.year ?? null; + if (hasYearInput) { + const parsedYear = Number(year); + effectiveYear = Number.isNaN(parsedYear) ? null : parsedYear; + } + let effectiveImdbId = imdbId === undefined + ? (job.imdb_id || null) + : (imdbId || null); + const selectedFromMetadata = 0; + let posterValue = poster === undefined + ? (job.poster_url || null) + : (poster || null); + let shouldQueuePosterCache = false; + if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { + try { + const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, posterValue, { + source: 'Poster', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + posterValue = cacheResult.localUrl; + } else { + shouldQueuePosterCache = true; + } + } catch (_error) { + shouldQueuePosterCache = true; + } + } + let effectiveMetadataProvider = String( + metadataProvider + || mkInfo?.analyzeContext?.metadataProvider + || 'tmdb' + ).trim().toLowerCase() || 'tmdb'; + const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(workflowKind); + const existingWorkflowKind = normalizeDvdMetadataWorkflowKind( + mkInfo?.analyzeContext?.selectedMetadata?.workflowKind + || mkInfo?.analyzeContext?.workflowKind + || mkInfo?.selectedMetadata?.workflowKind + || null + ); + const providerWorkflowKind = effectiveMetadataProvider === 'tmdb' + ? (isSeriesDiscMediaProfile(mediaProfile) ? 'series' : 'film') + : null; + const effectiveWorkflowKind = requestedWorkflowKind || existingWorkflowKind || providerWorkflowKind; + const normalizedDuplicateAction = String(duplicateAction || '').trim().toLowerCase(); + const requestedExistingJobId = normalizePositiveInteger(existingJobId); + const requestedExistingDiscNumber = normalizePositiveInteger(existingDiscNumber); + if (isSeriesDiscMediaProfile(mediaProfile)) { + if (effectiveWorkflowKind === 'series') { + effectiveMetadataProvider = 'tmdb'; + } + } + let effectiveProviderId = String( + providerId + || mkInfo?.analyzeContext?.selectedMetadata?.providerId + || '' + ).trim() || null; + let effectiveTmdbId = Number( + tmdbId + || mkInfo?.analyzeContext?.selectedMetadata?.tmdbId + || 0 + ) || null; + let effectiveMetadataKind = String( + metadataKind + || mkInfo?.analyzeContext?.selectedMetadata?.metadataKind + || '' + ).trim().toLowerCase() || null; + let effectiveDiscNumber = normalizePositiveInteger(discNumber); + let effectiveSeasonNumber = Number( + seasonNumber + || mkInfo?.analyzeContext?.selectedMetadata?.seasonNumber + || 0 + ) || null; + let effectiveSeasonName = String( + seasonName + || mkInfo?.analyzeContext?.selectedMetadata?.seasonName + || '' + ).trim() || null; + let effectiveEpisodeCount = Number( + episodeCount + || mkInfo?.analyzeContext?.selectedMetadata?.episodeCount + || 0 + ) || 0; + const hasExplicitEpisodePayload = Array.isArray(episodes) && episodes.length > 0; + let effectiveEpisodes = hasExplicitEpisodePayload + ? episodes + : (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes) + ? mkInfo.analyzeContext.selectedMetadata.episodes + : []); + let tmdbDetails = null; + let tmdbLanguage = null; + if (effectiveMetadataProvider === 'tmdb') { + try { + const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile); + tmdbLanguage = String(seriesSettings?.dvd_series_language || '').trim() || null; + } catch (_settingsError) { + tmdbLanguage = null; + } + } + + const isDvdTmdbMetadataSelection = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'series'; + if (isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film') { + if (!effectiveProviderId && effectiveTmdbId) { + effectiveProviderId = `tmdb:${effectiveTmdbId}`; + } + effectiveMetadataKind = 'movie'; + effectiveSeasonNumber = null; + effectiveSeasonName = null; + effectiveEpisodeCount = 0; + effectiveEpisodes = []; + } + if (isDvdTmdbMetadataSelection && !effectiveDiscNumber) { + const error = new Error(`Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`); + error.statusCode = 400; + throw error; + } + + let seasonDetails = null; + let seasonCredits = null; + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveWorkflowKind !== 'film') { + try { + const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { + language: tmdbLanguage, + appendToResponse: ['credits', 'external_ids'] + }); + tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails); + if (!effectiveImdbId && tmdbDetails?.imdbId) { + effectiveImdbId = tmdbDetails.imdbId; + } + if ((!effectiveYear || Number(effectiveYear) <= 0) && tmdbDetails?.firstAirDate) { + const tmdbYear = Number(String(tmdbDetails.firstAirDate).slice(0, 4)); + if (Number.isFinite(tmdbYear) && tmdbYear > 0) { + effectiveYear = Math.trunc(tmdbYear); + } + } + const createdBy = tmdbService.normalizeNameList(seriesDetails?.created_by, { maxItems: 3 }); + const genres = tmdbService.normalizeNameList(seriesDetails?.genres, { maxItems: 3 }); + tmdbDetails = { + ...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}), + createdBy, + genres + }; + } catch (tmdbDetailsErr) { + logger.warn('metadata:tmdb-details-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + error: errorToMeta(tmdbDetailsErr) + }); + } + } + + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveWorkflowKind === 'film') { + try { + const movieDetails = await tmdbService.getMovieDetailsWithCredits(effectiveTmdbId, { + language: tmdbLanguage, + appendToResponse: ['credits', 'external_ids'], + ensureCredits: true + }); + const movieSummary = tmdbService.buildMovieDetailsSummary(movieDetails); + const genres = tmdbService.normalizeNameList(movieDetails?.genres, { maxItems: 3 }); + const seasonCast = tmdbService.normalizeNameList(movieDetails?.credits?.cast, { maxItems: 6 }); + tmdbDetails = { + ...(movieSummary && typeof movieSummary === 'object' ? movieSummary : {}), + genres, + seasonCast + }; + if (!effectiveImdbId && tmdbDetails?.imdbId) { + effectiveImdbId = tmdbDetails.imdbId; + } + if ((!effectiveYear || Number(effectiveYear) <= 0) && tmdbDetails?.releaseDate) { + const tmdbYear = Number(String(tmdbDetails.releaseDate).slice(0, 4)); + if (Number.isFinite(tmdbYear) && tmdbYear > 0) { + effectiveYear = Math.trunc(tmdbYear); + } + } + } catch (tmdbMovieErr) { + logger.warn('metadata:tmdb-movie-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + error: errorToMeta(tmdbMovieErr) + }); + } + } + + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) { + try { + seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber, { + language: tmdbLanguage + }); + seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber, { + language: tmdbLanguage + }); + const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); + if (seasonSummary) { + const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; + if (fetchedEpisodes.length > 0 && !hasExplicitEpisodePayload) { + effectiveEpisodes = fetchedEpisodes; + } + const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; + if (fetchedEpisodeCount > 0) { + effectiveEpisodeCount = fetchedEpisodeCount; + } + if (!effectiveSeasonName && seasonSummary.name) { + effectiveSeasonName = String(seasonSummary.name).trim() || null; + } + } + const seasonVoteAverageRaw = Number(seasonDetails?.vote_average || 0); + const seasonVoteAverage = Number.isFinite(seasonVoteAverageRaw) && seasonVoteAverageRaw > 0 + ? Number(seasonVoteAverageRaw.toFixed(1)) + : null; + const seasonRuntimeValues = Array.isArray(seasonDetails?.episodes) + ? seasonDetails.episodes.map((episode) => Number(episode?.runtime || 0)).filter((val) => Number.isFinite(val) && val > 0) + : []; + const seasonRuntimeLabel = tmdbService.formatRuntimeLabel(seasonRuntimeValues); + const seasonCast = tmdbService.normalizeNameList(seasonCredits?.cast, { maxItems: 6 }); + tmdbDetails = { + ...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}), + seasonNumber: effectiveSeasonNumber, + seasonVoteAverage, + seasonRuntime: seasonRuntimeLabel, + seasonCast + }; + } catch (tmdbSeasonErr) { + logger.warn('metadata:tmdb-season-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + seasonNumber: effectiveSeasonNumber, + error: errorToMeta(tmdbSeasonErr) + }); + } + } + + let selectedMetadata = { + title: effectiveTitle, + year: effectiveYear, + imdbId: effectiveImdbId, + poster: posterValue, + metadataProvider: effectiveMetadataProvider, + ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), + providerId: effectiveProviderId, + tmdbId: effectiveTmdbId, + metadataKind: effectiveMetadataKind, + ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}), + seasonNumber: effectiveSeasonNumber, + seasonName: effectiveSeasonName, + episodeCount: effectiveEpisodeCount, + episodes: effectiveEpisodes, + ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) + }; + + const isDiscFilmMetadataSelection = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film'; + const filmMetadataFingerprint = isDiscFilmMetadataSelection + ? buildMovieMetadataFingerprint(mediaProfile, { + imdbId: effectiveImdbId, + title: effectiveTitle, + year: effectiveYear + }) + : null; + let multipartContainerJobId = null; + let multipartExistingJobId = null; + let multipartCurrentDiscNumber = null; + let multipartExistingDiscNumber = null; + let multipartExistingSelectedMetadataForRawRename = null; + let multipartExistingAnalyzeContextForRawRename = null; + let shouldDetachMultipartContainer = false; + + if (isDiscFilmMetadataSelection) { + const currentParentJobId = normalizePositiveInteger(job.parent_job_id || null); + if (currentParentJobId) { + const currentParentJob = await historyService.getJobById(currentParentJobId).catch(() => null); + if (String(currentParentJob?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container') { + shouldDetachMultipartContainer = true; + } + } + + if (filmMetadataFingerprint) { + const db = await getDb(); + let duplicateRows = await db.all( + ` + SELECT * + FROM jobs + WHERE id != ? + AND media_type = ? + AND metadata_fingerprint = ? + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') + ORDER BY updated_at DESC, id DESC + LIMIT 25 + `, + [Number(jobId), mediaProfile, filmMetadataFingerprint] + ); + if ((!Array.isArray(duplicateRows) || duplicateRows.length === 0) && effectiveImdbId) { + duplicateRows = await db.all( + ` + SELECT * + FROM jobs + WHERE id != ? + AND media_type = ? + AND LOWER(TRIM(COALESCE(imdb_id, ''))) = LOWER(TRIM(?)) + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') + ORDER BY updated_at DESC, id DESC + LIMIT 25 + `, + [Number(jobId), mediaProfile, String(effectiveImdbId || '').trim()] + ); + } + if ((!Array.isArray(duplicateRows) || duplicateRows.length === 0) && effectiveTitle) { + if (effectiveYear) { + duplicateRows = await db.all( + ` + SELECT * + FROM jobs + WHERE id != ? + AND media_type = ? + AND LOWER(TRIM(COALESCE(title, ''))) = LOWER(TRIM(?)) + AND CAST(COALESCE(year, 0) AS INTEGER) = ? + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') + ORDER BY updated_at DESC, id DESC + LIMIT 25 + `, + [Number(jobId), mediaProfile, String(effectiveTitle || '').trim(), Number(effectiveYear)] + ); + } else { + duplicateRows = await db.all( + ` + SELECT * + FROM jobs + WHERE id != ? + AND media_type = ? + AND LOWER(TRIM(COALESCE(title, ''))) = LOWER(TRIM(?)) + AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') + ORDER BY updated_at DESC, id DESC + LIMIT 25 + `, + [Number(jobId), mediaProfile, String(effectiveTitle || '').trim()] + ); + } + } + + const duplicateCandidates = Array.isArray(duplicateRows) ? duplicateRows : []; + const firstDuplicate = duplicateCandidates[0] || null; + const isMultipartAction = normalizedDuplicateAction === 'multipart_movie'; + const allowDuplicate = normalizedDuplicateAction === 'allow_new'; + + let resolvedExistingJob = null; + if (requestedExistingJobId && requestedExistingJobId !== Number(jobId)) { + resolvedExistingJob = await historyService.getJobById(requestedExistingJobId).catch(() => null); + } else if (firstDuplicate) { + resolvedExistingJob = await historyService.getJobById(firstDuplicate.id).catch(() => null); + } + + const throwDuplicateConflict = (existingJob) => { + const existingDisc = resolveDiscNumberFromJobLike(existingJob); + const error = new Error('Metadaten bereits in der Historie gefunden. Bitte Auswahl übernehmen oder Multipart movie wählen.'); + error.statusCode = 409; + error.details = [ + { + code: 'METADATA_DUPLICATE_FOUND', + fingerprint: filmMetadataFingerprint, + mediaProfile, + existingJob: existingJob + ? { + id: Number(existingJob.id || 0) || null, + title: String(existingJob.title || existingJob.detected_title || '').trim() || null, + year: Number(existingJob.year || 0) || null, + status: String(existingJob.status || '').trim() || null, + lastState: String(existingJob.last_state || '').trim() || null, + mediaType: String(existingJob.media_type || existingJob.mediaType || '').trim().toLowerCase() || null, + jobKind: String(existingJob.job_kind || existingJob.jobKind || '').trim().toLowerCase() || null, + discNumber: existingDisc, + isMultipartMovie: Number(existingJob.is_multipart_movie || 0) === 1 + } + : null + } + ]; + throw error; + }; + + if (resolvedExistingJob && !allowDuplicate && !isMultipartAction) { + throwDuplicateConflict(resolvedExistingJob); + } + + if (isMultipartAction) { + if (!resolvedExistingJob || Number(resolvedExistingJob?.id || 0) === Number(jobId)) { + throwDuplicateConflict(resolvedExistingJob || firstDuplicate); + } + + const existingMediaProfile = normalizeMediaProfile(resolvedExistingJob?.media_type || resolvedExistingJob?.mediaType || null); + if (existingMediaProfile !== normalizeMediaProfile(mediaProfile)) { + const error = new Error('Multipart movie benötigt denselben Medientyp (DVD oder Blu-ray).'); + error.statusCode = 409; + error.details = [{ code: 'MULTIPART_MEDIA_MISMATCH' }]; + throw error; + } + + const existingMkInfo = this.safeParseJson(resolvedExistingJob?.makemkv_info_json) + || (resolvedExistingJob?.makemkvInfo && typeof resolvedExistingJob.makemkvInfo === 'object' + ? resolvedExistingJob.makemkvInfo + : {}); + const existingAnalyzeContext = existingMkInfo?.analyzeContext && typeof existingMkInfo.analyzeContext === 'object' + ? existingMkInfo.analyzeContext + : {}; + const existingSelectedMetadata = resolveSelectedMetadataForJob( + resolvedExistingJob, + existingAnalyzeContext, + null + ); + const existingIsSeries = isSeriesDvdMetadataSelection(existingMediaProfile, existingSelectedMetadata, existingAnalyzeContext); + if (existingIsSeries) { + const error = new Error('Multipart movie ist nur für Film-Jobs erlaubt.'); + error.statusCode = 409; + error.details = [{ code: 'MULTIPART_SERIES_NOT_ALLOWED' }]; + throw error; + } + + const existingFingerprint = buildMovieMetadataFingerprint(existingMediaProfile, { + imdbId: existingSelectedMetadata?.imdbId || resolvedExistingJob?.imdb_id || null, + title: existingSelectedMetadata?.title || resolvedExistingJob?.title || resolvedExistingJob?.detected_title || null, + year: existingSelectedMetadata?.year || resolvedExistingJob?.year || null + }); + if (existingFingerprint && existingFingerprint !== filmMetadataFingerprint) { + const error = new Error('Ausgewählter bestehender Job passt nicht zu den aktuellen Film-Metadaten.'); + error.statusCode = 409; + error.details = [{ code: 'MULTIPART_METADATA_MISMATCH' }]; + throw error; + } + + multipartCurrentDiscNumber = normalizePositiveInteger(effectiveDiscNumber); + multipartExistingDiscNumber = requestedExistingDiscNumber || resolveDiscNumberFromJobLike(resolvedExistingJob); + if (!multipartCurrentDiscNumber || !multipartExistingDiscNumber) { + const error = new Error('Für Multipart movie sind Disc-Nummern für beide Jobs erforderlich (>= 1).'); + error.statusCode = 400; + error.details = [{ code: 'MULTIPART_DISC_REQUIRED' }]; + throw error; + } + if (multipartCurrentDiscNumber === multipartExistingDiscNumber) { + const error = new Error('Disc-Nummern im Multipart-Container müssen eindeutig sein.'); + error.statusCode = 409; + error.details = [{ code: 'MULTIPART_DISC_ALREADY_EXISTS', discNumber: multipartCurrentDiscNumber }]; + throw error; + } + + const loadMultipartContainerById = async (candidateId) => { + const normalizedCandidateId = normalizePositiveInteger(candidateId); + if (!normalizedCandidateId) { + return null; + } + const row = await historyService.getJobById(normalizedCandidateId).catch(() => null); + if (!row) { + return null; + } + return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container' + ? row + : null; + }; + + let multipartContainerJob = null; + multipartContainerJob = await loadMultipartContainerById(job.parent_job_id); + if (!multipartContainerJob) { + multipartContainerJob = await loadMultipartContainerById(resolvedExistingJob?.parent_job_id); + } + if (!multipartContainerJob) { + const existingContainerByFingerprint = await db.get( + ` + SELECT * + FROM jobs + WHERE job_kind = 'multipart_movie_container' + AND media_type = ? + AND metadata_fingerprint = ? + ORDER BY updated_at DESC, id DESC + LIMIT 1 + `, + [mediaProfile, filmMetadataFingerprint] + ); + if (existingContainerByFingerprint?.id) { + multipartContainerJob = await historyService.getJobById(existingContainerByFingerprint.id).catch(() => null); + } + } + if (!multipartContainerJob) { + multipartContainerJob = await historyService.createJob({ + discDevice: job.disc_device || resolvedExistingJob?.disc_device || null, + status: job.status || 'METADATA_SELECTION', + detectedTitle: effectiveTitle || job.detected_title || null, + mediaType: mediaProfile, + jobKind: 'multipart_movie_container' + }); + } + + multipartContainerJobId = normalizePositiveInteger(multipartContainerJob?.id); + if (!multipartContainerJobId) { + const error = new Error('Multipart-Container konnte nicht angelegt werden.'); + error.statusCode = 500; + throw error; + } + + const containerChildren = await historyService.listChildJobs(multipartContainerJobId); + const conflictingDiscChild = (Array.isArray(containerChildren) ? containerChildren : []).find((child) => { + const childId = normalizePositiveInteger(child?.id); + if (!childId || childId === Number(jobId) || childId === Number(resolvedExistingJob?.id || 0)) { + return false; + } + const childDisc = resolveDiscNumberFromJobLike(child); + return childDisc === multipartCurrentDiscNumber || childDisc === multipartExistingDiscNumber; + }); + if (conflictingDiscChild) { + const conflictDisc = resolveDiscNumberFromJobLike(conflictingDiscChild); + const error = new Error(`Disc ${conflictDisc || '?'} ist im Multipart-Container bereits vergeben.`); + error.statusCode = 409; + error.details = [ + { + code: 'MULTIPART_DISC_ALREADY_EXISTS', + containerJobId: multipartContainerJobId, + existingJobId: Number(conflictingDiscChild?.id || 0) || null, + discNumber: conflictDisc || null + } + ]; + throw error; + } + + multipartExistingJobId = normalizePositiveInteger(resolvedExistingJob?.id); + const patchedExistingSelectedMetadata = { + ...existingSelectedMetadata, + workflowKind: 'film', + metadataProvider: 'tmdb', + metadataKind: 'movie', + providerId: null, + tmdbId: null, + seasonNumber: null, + seasonName: null, + episodeCount: 0, + episodes: [], + discNumber: multipartExistingDiscNumber + }; + const existingAnalyzeSelectedMetadata = existingAnalyzeContext?.selectedMetadata && typeof existingAnalyzeContext.selectedMetadata === 'object' + ? existingAnalyzeContext.selectedMetadata + : {}; + const existingTopLevelSelectedMetadata = existingMkInfo?.selectedMetadata && typeof existingMkInfo.selectedMetadata === 'object' + ? existingMkInfo.selectedMetadata + : {}; + const patchedExistingMkInfo = this.withAnalyzeContextMediaProfile({ + ...(existingMkInfo && typeof existingMkInfo === 'object' ? existingMkInfo : {}), + selectedMetadata: { + ...existingTopLevelSelectedMetadata, + ...patchedExistingSelectedMetadata + }, + analyzeContext: { + ...existingAnalyzeContext, + metadataProvider: 'tmdb', + workflowKind: 'film', + selectedMetadata: { + ...existingAnalyzeSelectedMetadata, + ...patchedExistingSelectedMetadata + } + } + }, mediaProfile); + multipartExistingSelectedMetadataForRawRename = patchedExistingSelectedMetadata; + multipartExistingAnalyzeContextForRawRename = { + ...existingAnalyzeContext, + metadataProvider: 'tmdb', + workflowKind: 'film', + selectedMetadata: patchedExistingSelectedMetadata + }; + + if (multipartExistingJobId) { + await historyService.updateJob(multipartExistingJobId, { + parent_job_id: multipartContainerJobId, + media_type: mediaProfile, + job_kind: 'multipart_movie_child', + is_multipart_movie: 1, + disc_number: multipartExistingDiscNumber, + metadata_fingerprint: filmMetadataFingerprint, + makemkv_info_json: JSON.stringify(patchedExistingMkInfo) + }); + } + + const containerSelectedMetadataPatch = { + ...selectedMetadata, + workflowKind: 'film', + metadataProvider: 'tmdb', + metadataKind: 'movie', + providerId: null, + tmdbId: null, + seasonNumber: null, + seasonName: null, + episodeCount: 0, + episodes: [], + discNumber: null + }; + const containerMkInfo = this.withAnalyzeContextMediaProfile({ + analyzeContext: { + metadataProvider: 'tmdb', + workflowKind: 'film', + selectedMetadata: containerSelectedMetadataPatch + }, + selectedMetadata: containerSelectedMetadataPatch + }, mediaProfile); + await historyService.updateJob(multipartContainerJobId, { + title: effectiveTitle, + year: effectiveYear, + imdb_id: effectiveImdbId, + poster_url: posterValue, + selected_from_omdb: selectedFromMetadata, + omdb_json: null, + status: job.status || 'METADATA_SELECTION', + last_state: job.status || 'METADATA_SELECTION', + media_type: mediaProfile, + job_kind: 'multipart_movie_container', + is_multipart_movie: 1, + disc_number: null, + metadata_fingerprint: filmMetadataFingerprint, + makemkv_info_json: JSON.stringify(containerMkInfo) + }); + + shouldDetachMultipartContainer = false; + } + } + } + + const shouldDetachSeriesContainer = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film'; + let parentContainerJobId = shouldDetachSeriesContainer + ? null + : normalizePositiveInteger(job.parent_job_id || null); + const isDvdSeriesSelection = isDvdTmdbMetadataSelection; + if (isDvdSeriesSelection) { + const normalizeEpisodeRows = (rows) => { + const list = Array.isArray(rows) ? rows : []; + return list.filter((entry) => entry && typeof entry === 'object'); + }; + const hasEpisodeRows = (rows) => Array.isArray(rows) && rows.length > 0; + const toPositiveIntOrNull = (value) => { + const parsed = Number(value || 0); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; + const extractSelectedMetadataFromJobRow = (row) => { + const rowMkInfo = this.safeParseJson(row?.makemkv_info_json) || {}; + const rowAnalyzeContext = rowMkInfo?.analyzeContext && typeof rowMkInfo.analyzeContext === 'object' + ? rowMkInfo.analyzeContext + : {}; + const rowSelectedMetadata = rowAnalyzeContext?.selectedMetadata && typeof rowAnalyzeContext.selectedMetadata === 'object' + ? rowAnalyzeContext.selectedMetadata + : (rowMkInfo?.selectedMetadata && typeof rowMkInfo.selectedMetadata === 'object' + ? rowMkInfo.selectedMetadata + : {}); + return rowSelectedMetadata && typeof rowSelectedMetadata === 'object' + ? rowSelectedMetadata + : {}; + }; + const resolveDiscNumberFromJobRow = (row) => { + const rowSelectedMetadata = extractSelectedMetadataFromJobRow(row); + return normalizePositiveInteger(rowSelectedMetadata?.discNumber); + }; + let containerJobRow = null; + + if (!parentContainerJobId) { + const existingContainer = await historyService.findSeriesContainerJob(effectiveTmdbId, effectiveSeasonNumber); + if (existingContainer) { + parentContainerJobId = Number(existingContainer.id); + } else { + const likelyContainer = await historyService.findLikelySeriesContainerJob({ + title: effectiveTitle, + year: effectiveYear + }); + if (likelyContainer) { + const likelyMkInfo = this.safeParseJson(likelyContainer?.makemkv_info_json) || {}; + const likelySelected = likelyMkInfo?.analyzeContext?.selectedMetadata + || likelyMkInfo?.selectedMetadata + || {}; + const likelyTmdbId = Number(likelySelected?.tmdbId || 0) || null; + const likelySeason = Number(likelySelected?.seasonNumber || 0) || null; + const seasonCompatible = !likelySeason || likelySeason === Number(effectiveSeasonNumber); + const tmdbCompatible = !likelyTmdbId || likelyTmdbId === Number(effectiveTmdbId); + if (seasonCompatible && tmdbCompatible) { + parentContainerJobId = Number(likelyContainer.id); + logger.info('dvd-series:container:assigned-likely', { + jobId, + parentContainerJobId, + tmdbId: effectiveTmdbId, + seasonNumber: effectiveSeasonNumber + }); + } + } + } + if (!parentContainerJobId) { + const containerJob = await historyService.createJob({ + discDevice: job.disc_device || null, + status: job.status || 'METADATA_SELECTION', + detectedTitle: effectiveTitle || job.detected_title || null, + mediaType: mediaProfile, + jobKind: 'dvd_series_container' + }); + parentContainerJobId = Number(containerJob?.id || 0) || null; + } + } + + if (parentContainerJobId) { + containerJobRow = await historyService.getJobById(parentContainerJobId).catch(() => null); + } + + if (parentContainerJobId && effectiveDiscNumber) { + const containerChildren = await historyService.listChildJobs(parentContainerJobId); + const conflictingDiscChild = (Array.isArray(containerChildren) ? containerChildren : []).find((childRow) => { + const childId = Number(childRow?.id || 0); + if (!childId || childId === Number(jobId)) { + return false; + } + const childDiscNumber = resolveDiscNumberFromJobRow(childRow); + return childDiscNumber === effectiveDiscNumber; + }); + + if (conflictingDiscChild) { + const existingChildJobId = Number(conflictingDiscChild?.id || 0) || null; + const error = new Error( + `Disk ${effectiveDiscNumber} ist fuer diese Serie/Staffel bereits vorhanden (Job #${existingChildJobId || '?'}). Bitte andere Disk-Nummer waehlen.` + ); + error.statusCode = 409; + error.details = [ + { + code: 'SERIES_DISC_ALREADY_EXISTS', + containerJobId: Number(parentContainerJobId), + existingJobId: existingChildJobId, + discNumber: Number(effectiveDiscNumber), + tmdbId: Number(effectiveTmdbId || 0) || null, + seasonNumber: Number(effectiveSeasonNumber || 0) || null + } + ]; + throw error; + } + } + + const currentEpisodes = normalizeEpisodeRows(effectiveEpisodes); + const currentEpisodeCount = toPositiveIntOrNull(effectiveEpisodeCount) || 0; + const needsEpisodeFallback = !hasEpisodeRows(currentEpisodes) || currentEpisodeCount <= 0; + if (needsEpisodeFallback && effectiveTmdbId && effectiveSeasonNumber) { + const fallbackRows = []; + if (containerJobRow) { + fallbackRows.push(containerJobRow); + } + const siblingRowsForEpisodes = await historyService.listSeriesSiblingJobs(effectiveTmdbId, effectiveSeasonNumber); + fallbackRows.push(...(Array.isArray(siblingRowsForEpisodes) ? siblingRowsForEpisodes : [])); + + for (const fallbackRow of fallbackRows) { + const fallbackSelectedMetadata = extractSelectedMetadataFromJobRow(fallbackRow); + const fallbackEpisodes = normalizeEpisodeRows(fallbackSelectedMetadata?.episodes); + if (!hasEpisodeRows(fallbackEpisodes)) { + continue; + } + effectiveEpisodes = fallbackEpisodes; + const fallbackEpisodeCount = toPositiveIntOrNull(fallbackSelectedMetadata?.episodeCount); + effectiveEpisodeCount = fallbackEpisodeCount || fallbackEpisodes.length; + if (!effectiveSeasonName) { + effectiveSeasonName = String(fallbackSelectedMetadata?.seasonName || '').trim() || null; + } + break; + } + } + + selectedMetadata = { + ...selectedMetadata, + seasonName: effectiveSeasonName, + episodeCount: effectiveEpisodeCount, + episodes: effectiveEpisodes + }; + + if (parentContainerJobId) { + const containerSelectedMetadata = extractSelectedMetadataFromJobRow(containerJobRow); + const nextEpisodes = normalizeEpisodeRows(selectedMetadata?.episodes); + const fallbackEpisodes = normalizeEpisodeRows(containerSelectedMetadata?.episodes); + const mergedEpisodes = hasEpisodeRows(nextEpisodes) + ? nextEpisodes + : fallbackEpisodes; + const selectedEpisodeCount = toPositiveIntOrNull(selectedMetadata?.episodeCount); + const fallbackEpisodeCount = toPositiveIntOrNull(containerSelectedMetadata?.episodeCount); + const mergedEpisodeCount = selectedEpisodeCount + || fallbackEpisodeCount + || (hasEpisodeRows(mergedEpisodes) ? mergedEpisodes.length : 0); + const mergedSeasonName = String( + selectedMetadata?.seasonName + || containerSelectedMetadata?.seasonName + || '' + ).trim() || null; + const containerSelectedMetadataPatch = { + ...selectedMetadata, + seasonName: mergedSeasonName, + episodeCount: mergedEpisodeCount, + episodes: mergedEpisodes + }; + selectedMetadata = containerSelectedMetadataPatch; + const containerMkInfo = this.withAnalyzeContextMediaProfile({ + analyzeContext: { + metadataProvider: effectiveMetadataProvider, + selectedMetadata: containerSelectedMetadataPatch + } + }, mediaProfile); + await historyService.updateJob(parentContainerJobId, { + title: effectiveTitle, + year: effectiveYear, + imdb_id: effectiveImdbId, + poster_url: posterValue, + selected_from_omdb: selectedFromMetadata, + omdb_json: null, + status: job.status || 'METADATA_SELECTION', + last_state: job.status || 'METADATA_SELECTION', + media_type: mediaProfile, + job_kind: 'dvd_series_container', + makemkv_info_json: JSON.stringify(containerMkInfo) + }); + } + + const siblingRows = await historyService.listSeriesSiblingJobs(effectiveTmdbId, effectiveSeasonNumber); + for (const sibling of siblingRows) { + const siblingId = Number(sibling?.id || 0); + if (!siblingId || siblingId === Number(jobId)) { + continue; + } + const siblingParentId = normalizePositiveInteger(sibling.parent_job_id || null); + const needsParentUpdate = siblingParentId !== parentContainerJobId; + const needsKindUpdate = String(sibling?.job_kind || '').trim().toLowerCase() !== 'dvd_series_child'; + if (!needsParentUpdate && !needsKindUpdate) { + continue; + } + await historyService.updateJob(siblingId, { + parent_job_id: parentContainerJobId, + job_kind: 'dvd_series_child', + ...(sibling?.media_type ? {} : { media_type: mediaProfile }) + }); + } + } + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const currentJobKind = String(job?.job_kind || '').trim().toLowerCase(); + const isCurrentJobMultipart = Number(job?.is_multipart_movie || 0) === 1 || currentJobKind === 'multipart_movie_child'; + const isMultipartMovieSelection = Boolean( + isDiscFilmMetadataSelection + && (multipartContainerJobId || normalizedDuplicateAction === 'multipart_movie' || isCurrentJobMultipart) + ); + const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' + ? 'backup' + : 'mkv'; + const isBackupMode = ripMode === 'backup'; + + if (isMultipartMovieSelection && multipartExistingJobId && multipartExistingSelectedMetadataForRawRename) { + try { + const existingJobForRawRename = await historyService.getJobById(multipartExistingJobId).catch(() => null); + const existingJobHasActiveProcess = this._isActiveProcessForJob( + multipartExistingJobId, + { cleanupStale: true } + ); + if (existingJobHasActiveProcess) { + await historyService.appendLog( + multipartExistingJobId, + 'SYSTEM', + 'Multipart-RAW-Rename verschoben: Job ist aktiv, Pfad bleibt waehrend Analyze/Encode stabil.' + ); + logger.info('metadata:multipart-existing-raw-rename-deferred-active-job', { + jobId: multipartExistingJobId, + status: String(existingJobForRawRename?.status || '').trim().toUpperCase() || null, + lastState: String(existingJobForRawRename?.last_state || '').trim().toUpperCase() || null + }); + } + const existingStoredRawPath = String(existingJobForRawRename?.raw_path || '').trim(); + const resolvedExistingRawPath = this.resolveCurrentRawPathForSettings( + settings, + mediaProfile, + existingStoredRawPath + ) || existingStoredRawPath || null; + if ( + !existingJobHasActiveProcess + && resolvedExistingRawPath + && fs.existsSync(resolvedExistingRawPath) + && fs.statSync(resolvedExistingRawPath).isDirectory() + ) { + const existingRawState = resolveRawFolderStateFromPath(resolvedExistingRawPath); + const targetRawState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE + ? RAW_FOLDER_STATES.INCOMPLETE + : RAW_FOLDER_STATES.RIP_COMPLETE; + const existingMetadataBase = buildRawMetadataBase({ + title: existingJobForRawRename?.title || effectiveTitle || null, + year: existingJobForRawRename?.year || effectiveYear || null, + detected_title: existingJobForRawRename?.detected_title || effectiveTitle || null, + media_type: mediaProfile, + is_multipart_movie: 1, + job_kind: 'multipart_movie_child' + }, multipartExistingJobId, { + mediaProfile, + analyzeContext: multipartExistingAnalyzeContextForRawRename, + selectedMetadata: multipartExistingSelectedMetadataForRawRename, + isMultipartMovie: true + }); + const existingTargetRawPath = path.join( + path.dirname(resolvedExistingRawPath), + buildRawDirName(existingMetadataBase, multipartExistingJobId, { state: targetRawState }) + ); + if ( + normalizeComparablePath(resolvedExistingRawPath) !== normalizeComparablePath(existingTargetRawPath) + && !fs.existsSync(existingTargetRawPath) + ) { + fs.renameSync(resolvedExistingRawPath, existingTargetRawPath); + await historyService.updateRawPathByOldPath(resolvedExistingRawPath, existingTargetRawPath); + await historyService.appendLog( + multipartExistingJobId, + 'SYSTEM', + `Multipart-RAW umbenannt: ${resolvedExistingRawPath} -> ${existingTargetRawPath}` + ); + logger.info('metadata:multipart-existing-raw-renamed', { + jobId: multipartExistingJobId, + from: resolvedExistingRawPath, + to: existingTargetRawPath + }); + } + } + } catch (multipartRawRenameError) { + logger.warn('metadata:multipart-existing-raw-rename-failed', { + jobId: multipartExistingJobId, + error: errorToMeta(multipartRawRenameError) + }); + } + } + + const metadataBase = buildRawMetadataBase({ + title: selectedMetadata.title || job.detected_title || null, + year: selectedMetadata.year || null, + detected_title: job.detected_title || null, + media_type: mediaProfile, + is_multipart_movie: isMultipartMovieSelection ? 1 : 0, + job_kind: isMultipartMovieSelection ? 'multipart_movie_child' : (job?.job_kind || null) + }, jobId, { + mediaProfile, + analyzeContext: mkInfo?.analyzeContext || null, + selectedMetadata, + isMultipartMovie: isMultipartMovieSelection + }); + const rawStorage = resolveSeriesAwareRawStorage( + settings, + mediaProfile, + selectedMetadata, + mkInfo?.analyzeContext || null + ); + const rawBaseDir = String( + rawStorage?.rawBaseDir + || settings?.raw_dir + || settingsService.DEFAULT_RAW_DIR + || '' + ).trim(); + const metadataMatchedRawPath = findExistingRawDirectory(rawBaseDir, metadataBase); + const metadataMatchedRawJobId = metadataMatchedRawPath + ? extractRawFolderJobId(path.basename(metadataMatchedRawPath)) + : null; + const existingRawPath = isMultipartMovieSelection + ? (metadataMatchedRawJobId === Number(jobId) ? metadataMatchedRawPath : null) + : metadataMatchedRawPath; + const resolvedCurrentJobRawPath = this.resolveCurrentRawPathForSettings( + settings, + mediaProfile, + job.raw_path + ) || String(job.raw_path || '').trim() || null; + const currentJobRawPathPresent = Boolean(resolvedCurrentJobRawPath); + const currentJobRawPathExists = Boolean( + resolvedCurrentJobRawPath + && fs.existsSync(resolvedCurrentJobRawPath) + ); + const normalizedMetadataMatchedRawPath = normalizeComparablePath(metadataMatchedRawPath); + const normalizedCurrentJobRawPath = normalizeComparablePath(resolvedCurrentJobRawPath); + const metadataMatchedIsCurrentJobRaw = Boolean( + normalizedMetadataMatchedRawPath + && normalizedCurrentJobRawPath + && normalizedMetadataMatchedRawPath === normalizedCurrentJobRawPath + ); + let metadataMatchedLinkedJobId = null; + if (normalizedMetadataMatchedRawPath) { + const dbForRawDecision = await getDb(); + const linkedRawRows = await dbForRawDecision.all( + ` + SELECT id, raw_path + FROM jobs + WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' + ` + ); + const linkedRow = (Array.isArray(linkedRawRows) ? linkedRawRows : []).find((row) => { + const rowId = normalizePositiveInteger(row?.id); + if (!rowId || rowId === Number(jobId)) { + return false; + } + return normalizeComparablePath(row?.raw_path) === normalizedMetadataMatchedRawPath; + }); + metadataMatchedLinkedJobId = normalizePositiveInteger(linkedRow?.id); + } + const metadataMatchedLooksOrphan = Boolean( + normalizedMetadataMatchedRawPath + && !metadataMatchedIsCurrentJobRaw + && !metadataMatchedLinkedJobId + ); + const orphanDiscHint = metadataMatchedRawPath + ? extractMultipartFilmDiscFromRawFolderName(path.basename(metadataMatchedRawPath)) + : null; + const multipartDiscSuggestions = resolveMultipartDiscNumberPair({ + preferredCurrentDiscNumber: normalizePositiveInteger( + selectedMetadata?.discNumber + ?? effectiveDiscNumber + ?? null + ), + preferredOrphanDiscNumber: orphanDiscHint + }); + let updatedRawPath = existingRawPath || (currentJobRawPathPresent ? resolvedCurrentJobRawPath : null); + const shouldAutoReviewFromRaw = Boolean(existingRawPath || currentJobRawPathExists); + if (existingRawPath) { + const existingRawState = resolveRawFolderStateFromPath(existingRawPath); + const renameState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE + ? RAW_FOLDER_STATES.INCOMPLETE + : RAW_FOLDER_STATES.RIP_COMPLETE; + const renamedDirName = buildRawDirName(metadataBase, jobId, { state: renameState }); + const renamedRawPath = path.join(rawBaseDir, renamedDirName); + if (existingRawPath !== renamedRawPath && !fs.existsSync(renamedRawPath)) { + try { + fs.renameSync(existingRawPath, renamedRawPath); + updatedRawPath = renamedRawPath; + await historyService.updateRawPathByOldPath(existingRawPath, renamedRawPath); + logger.info('metadata:raw-dir-renamed', { from: existingRawPath, to: renamedRawPath, jobId, state: renameState }); + } catch (renameError) { + logger.warn('metadata:raw-dir-rename-failed', { existingRawPath, renamedRawPath, error: errorToMeta(renameError) }); + } + } + } + const basePlaylistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylist); + const playlistDecision = isBackupMode + ? { + ...basePlaylistDecision, + playlistAnalysis: null, + playlistDecisionRequired: false, + candidatePlaylists: [], + selectedPlaylist: null, + selectedTitleId: null, + recommendation: null + } + : basePlaylistDecision; + const isRawImportMetadataJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + const requiresManualPlaylistSelection = Boolean( + playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null + ); + const requiresRawDecision = shouldAutoReviewFromRaw && !isRawImportMetadataJob && !isMultipartMovieSelection; + const canMergeWithMatchedOrphanRaw = Boolean( + requiresRawDecision + && isDiscFilmMetadataSelection + && !isMultipartMovieSelection + && metadataMatchedLooksOrphan + ); + const rawDecisionContext = { + matchedRawPathBeforeMetadata: existingRawPath || null, + matchedRawPathAfterMetadata: updatedRawPath || null, + matchedRawFolderJobId: metadataMatchedRawJobId || null, + matchedRawLinkedJobId: metadataMatchedLinkedJobId || null, + matchedRawLooksOrphan: metadataMatchedLooksOrphan, + mergeOrphanEligible: canMergeWithMatchedOrphanRaw, + multipartSuggestion: canMergeWithMatchedOrphanRaw + ? { + orphanDiscNumber: multipartDiscSuggestions.orphanDiscNumber, + currentDiscNumber: multipartDiscSuggestions.currentDiscNumber + } + : null + }; + const nextStatus = (requiresManualPlaylistSelection || requiresRawDecision) ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; + + const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({ + ...mkInfo, + analyzeContext: { + ...(mkInfo?.analyzeContext || {}), + playlistAnalysis: playlistDecision.playlistAnalysis || mkInfo?.analyzeContext?.playlistAnalysis || null, + playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), + selectedPlaylist: playlistDecision.selectedPlaylist || null, + selectedTitleId: playlistDecision.selectedTitleId ?? null, + metadataProvider: effectiveMetadataProvider, + ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), + selectedMetadata, + rawDecisionContext: requiresRawDecision ? rawDecisionContext : null + } + }, mediaProfile); + + const seriesDetachPatch = (shouldDetachSeriesContainer && !multipartContainerJobId) + ? { + parent_job_id: null, + job_kind: resolveJobKindForMediaProfile(mediaProfile), + media_type: mediaProfile + } + : {}; + const multipartAttachPatch = multipartContainerJobId + ? { + parent_job_id: multipartContainerJobId, + job_kind: 'multipart_movie_child', + media_type: mediaProfile, + is_multipart_movie: 1, + disc_number: multipartCurrentDiscNumber || normalizePositiveInteger(effectiveDiscNumber), + metadata_fingerprint: filmMetadataFingerprint || null + } + : {}; + const multipartDetachPatch = (shouldDetachMultipartContainer && !multipartContainerJobId) + ? { + parent_job_id: null, + job_kind: resolveJobKindForMediaProfile(mediaProfile), + media_type: mediaProfile, + is_multipart_movie: 0 + } + : {}; + const filmMetadataPatch = isDiscFilmMetadataSelection + ? { + metadata_fingerprint: filmMetadataFingerprint || null, + disc_number: multipartContainerJobId + ? (multipartCurrentDiscNumber || normalizePositiveInteger(effectiveDiscNumber)) + : (normalizePositiveInteger(effectiveDiscNumber) || null), + ...(multipartContainerJobId + ? { is_multipart_movie: 1 } + : (shouldDetachMultipartContainer ? { is_multipart_movie: 0 } : {})) + } + : {}; + await historyService.updateJob(jobId, { + title: effectiveTitle, + year: effectiveYear, + imdb_id: effectiveImdbId, + poster_url: posterValue, + selected_from_omdb: selectedFromMetadata, + omdb_json: null, + migrate_tmdb: 1, + status: nextStatus, + last_state: nextStatus, + raw_path: updatedRawPath, + makemkv_info_json: JSON.stringify(updatedMakemkvInfo), + ...(parentContainerJobId ? { parent_job_id: parentContainerJobId, job_kind: 'dvd_series_child', media_type: mediaProfile } : {}), + ...multipartAttachPatch, + ...multipartDetachPatch, + ...filmMetadataPatch, + ...seriesDetachPatch + }); + + if (multipartContainerJobId) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart movie aktiviert: Container #${multipartContainerJobId}, Disc ${multipartCurrentDiscNumber || '-'}` + ); + if (multipartExistingJobId && Number(multipartExistingJobId) !== Number(jobId)) { + await historyService.appendLog( + multipartExistingJobId, + 'SYSTEM', + `Multipart movie aktiviert: Container #${multipartContainerJobId}, Disc ${multipartExistingDiscNumber || '-'}` + ); + } + await this.upsertMultipartMergeJobForContainer(multipartContainerJobId).catch((mergePrepError) => { + logger.warn('multipart:merge:upsert-on-metadata-select-failed', { + containerJobId: multipartContainerJobId, + jobId, + error: errorToMeta(mergePrepError) + }); + }); + } + + // Bild in Cache laden (async, blockiert nicht) + if (shouldQueuePosterCache && posterValue && !thumbnailService.isLocalUrl(posterValue)) { + historyService.queuePosterCache(jobId, posterValue, { + source: 'Poster', + logFailures: true + }); + } + + const keepCurrentPipelineSession = this._hasForeignInteractiveSession(jobId); + + if (existingRawPath) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorhandenes RAW-Verzeichnis erkannt: ${existingRawPath}` + ); + } else if (currentJobRawPathExists) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten-Match im RAW-Root nicht gefunden (${metadataBase}). Verwende vorhandenes Job-RAW: ${resolvedCurrentJobRawPath}` + ); + } else if (currentJobRawPathPresent) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten-Match im RAW-Root nicht gefunden (${metadataBase}). Job-RAW ist aktuell nicht verfügbar: ${resolvedCurrentJobRawPath}` + ); + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Kein bestehendes RAW-Verzeichnis zu den Metadaten gefunden (${metadataBase}).` + ); + } + if (rawStorage.usingSeriesRawPath) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} RAW-Pfad aktiv: ${rawBaseDir}` + ); + } + + if (!keepCurrentPipelineSession) { + await this.setState(nextStatus, { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: requiresRawDecision + ? 'waiting_for_raw_decision' + : (requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : (shouldAutoReviewFromRaw + ? 'Metadaten übernommen - vorhandenes RAW erkannt' + : 'Metadaten übernommen - bereit zum Start')), + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath: updatedRawPath, + mediaProfile, + selectedMetadata, + playlistAnalysis: playlistDecision.playlistAnalysis || null, + playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), + playlistCandidates: playlistDecision.candidatePlaylists, + selectedPlaylist: playlistDecision.selectedPlaylist || null, + selectedTitleId: playlistDecision.selectedTitleId ?? null, + waitingForManualPlaylistSelection: requiresManualPlaylistSelection, + waitingForRawDecision: requiresRawDecision, + rawDecisionOptions: requiresRawDecision + ? { + allowMultipartMergeWithOrphan: Boolean(rawDecisionContext?.mergeOrphanEligible), + orphanDiscNumber: normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.orphanDiscNumber), + currentDiscNumber: normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.currentDiscNumber) + } + : null, + manualDecisionState: requiresRawDecision + ? 'waiting_for_raw_decision' + : (requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : null) + } + }); + } else { + const foreignActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten übernommen. Aktive Session bleibt bei interaktivem Job #${foreignActiveJobId || 'unbekannt'}.` + ); + } + + if (requiresRawDecision) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorhandenes RAW zur Disk gefunden: ${updatedRawPath}. Warte auf Entscheidung.` + ); + if (rawDecisionContext?.mergeOrphanEligible) { + const orphanDiscSuggestion = normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.orphanDiscNumber); + const currentDiscSuggestion = normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.currentDiscNumber); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Optional verfuegbar: Multipart movie aus Orphan-RAW erstellen (Orphan Disc ${orphanDiscSuggestion || '?'} + Laufwerk Disc ${currentDiscSuggestion || '?'}).` + ); + } + return historyService.getJobById(jobId); + } + + if (requiresManualPlaylistSelection) { + const playlistFiles = playlistDecision.candidatePlaylists + .map((item) => item.playlistFile) + .filter(Boolean); + const recommendationFile = toPlaylistFile(playlistDecision.recommendation?.playlistId); + const decisionContext = describePlaylistManualDecision(playlistDecision.playlistAnalysis); + await historyService.appendLog( + jobId, + 'SYSTEM', + `${decisionContext.detailText} Status=waiting_for_manual_playlist_selection. Kandidaten: ${playlistFiles.join(', ') || 'keine'}.` + ); + if (recommendationFile) { + await historyService.appendLog(jobId, 'SYSTEM', `Empfehlung laut MakeMKV-TINFO-Analyse: ${recommendationFile}`); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor Backup/Encoding gestartet wird.' + ); + return historyService.getJobById(jobId); + } + + if (playlistDecision.playlistDecisionRequired && playlistDecision.selectedPlaylist) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Manuelle Playlist-Auswahl übernommen: ${toPlaylistFile(playlistDecision.selectedPlaylist) || playlistDecision.selectedPlaylist}` + ); + } + + if (shouldAutoReviewFromRaw) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW: ${updatedRawPath}` + ); + const startResult = await this.startPreparedJob(jobId); + logger.info('metadata:auto-track-review-started', { + jobId, + stage: startResult?.stage || null, + reusedRaw: Boolean(startResult?.reusedRaw), + selectedPlaylist: playlistDecision.selectedPlaylist || null, + selectedTitleId: playlistDecision.selectedTitleId ?? null + }); + return historyService.getJobById(jobId); + } + + if (isRawImportMetadataJob) { + const error = new Error('RAW-Import-Job: kein verwertbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); + error.statusCode = 409; + throw error; + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten übernommen. Starte Backup/Rip automatisch.' + ); + const startResult = await this.startPreparedJob(jobId); + logger.info('metadata:auto-start', { + jobId, + stage: startResult?.stage || null, + reusedRaw: Boolean(startResult?.reusedRaw), + selectedPlaylist: playlistDecision.selectedPlaylist || null, + selectedTitleId: playlistDecision.selectedTitleId ?? null + }); + + return historyService.getJobById(jobId); + } + + async startPreparedJob(jobId, options = {}) { + const resolvedPreparedJob = await this.resolveExistingJobForAction(jobId, 'start_prepared'); + jobId = resolvedPreparedJob.resolvedJobId; + const optionPreloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(jobId) + ? options.preloadedJob + : null; + const immediate = Boolean(options?.immediate); + if (!immediate) { + const preloadedJob = optionPreloadedJob || resolvedPreparedJob.job || await historyService.getJobById(jobId); + if (!preloadedJob.title && !preloadedJob.detected_title) { + const error = new Error('Start nicht möglich: keine Metadaten vorhanden.'); + error.statusCode = 400; + throw error; + } + + const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json); + const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); + const isRawImportPreparedJob = String(preloadedMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, { + makemkvInfo: preloadedMakemkvInfo, + encodePlan: preloadedEncodePlan + }); + if (preloadedMediaProfile === 'audiobook') { + return this.startAudiobookEncode(jobId, { ...options, preloadedJob }); + } + if (preloadedMediaProfile === 'converter') { + const preloadedPlan = this.safeParseJson(preloadedJob.encode_plan_json) || {}; + const converterMediaType = String(preloadedPlan?.converterMediaType || '').trim().toLowerCase(); + if (converterMediaType === 'audio') { + return this.startConverterEncode(jobId, { ...options, preloadedJob }); + } + return this.startPreparedConverterVideoJob(jobId, { ...options, preloadedJob }); + } + if (this.isMultipartMovieMergeHistoryJob(preloadedJob)) { + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + jobId, + () => this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }), + { preloadedJob } + ); + } + + const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE'; + if (isReadyToEncode) { + const preloadedAnalyzeContext = preloadedMakemkvInfo?.analyzeContext && typeof preloadedMakemkvInfo.analyzeContext === 'object' + ? preloadedMakemkvInfo.analyzeContext + : {}; + const preloadedActiveContextForJob = Number(this.snapshot?.activeJobId) === Number(jobId) + ? (this.snapshot?.context || {}) + : null; + const preloadedSelectedMetadata = resolveSelectedMetadataForJob( + preloadedJob, + preloadedAnalyzeContext, + preloadedActiveContextForJob + ); + const preloadedSelectedTitleIds = resolveSeriesBatchSelectedTitleIdsFromPlan(preloadedEncodePlan); + const preloadedPlanMode = String(preloadedEncodePlan?.mode || '').trim().toLowerCase(); + const preloadedIsPreRipPlan = preloadedPlanMode === 'pre_rip' || Boolean(preloadedEncodePlan?.preRip); + const shouldDispatchSeriesBatchNow = ( + !preloadedIsPreRipPlan + && isSeriesDvdMetadataSelection(preloadedMediaProfile, preloadedSelectedMetadata, preloadedAnalyzeContext) + && preloadedSelectedTitleIds.length > 1 + ); + if (shouldDispatchSeriesBatchNow) { + return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }); + } + + // Check whether this confirmed job will rip first (pre_rip mode) or encode directly. + // Pre-rip jobs bypass the encode queue because the next step is a rip, not an encode. + const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); + const jobMode = String(jobEncodePlan?.mode || '').trim().toLowerCase(); + let willRipFirst = jobMode === 'pre_rip' || Boolean(jobEncodePlan?.preRip); + if (willRipFirst) { + const rawReuse = await this.resolveUsableRawInputForReadyJob(jobId, preloadedJob, jobEncodePlan); + if (rawReuse.hasUsableRawInput) { + willRipFirst = false; + } + } + if (willRipFirst) { + return this.startPreparedJob(jobId, { ...options, immediate: true }); + } + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + jobId, + () => this.startPreparedJob(jobId, { ...options, immediate: true }), + { preloadedJob } + ); + } + + let hasUsableRawInput = false; + if (preloadedJob.raw_path) { + try { + if (fs.existsSync(preloadedJob.raw_path)) { + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, preloadedJob); + hasUsableRawInput = Boolean(findPreferredRawInput(preloadedJob.raw_path, { + playlistAnalysis: playlistDecision.playlistAnalysis, + selectedPlaylistId: playlistDecision.selectedPlaylist + })); + } + } catch (_error) { + hasUsableRawInput = false; + } + } + + if (!hasUsableRawInput) { + if (isRawImportPreparedJob) { + const error = new Error('RAW-Import-Job: kein nutzbares RAW gefunden. Laufwerks-Rip wird nicht gestartet.'); + error.statusCode = 409; + throw error; + } + // No raw input yet → will rip from disc. Bypass the encode queue entirely. + return this.startPreparedJob(jobId, { ...options, immediate: true }); + } + + return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }); + } + + const job = optionPreloadedJob || resolvedPreparedJob.job || await historyService.getJobById(jobId); + + const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json); + const jobEncodePlan = this.safeParseJson(job.encode_plan_json); + const isRawImportPreparedJob = String(jobMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + const jobMediaProfile = this.resolveMediaProfileForJob(job, { + makemkvInfo: jobMakemkvInfo, + encodePlan: jobEncodePlan + }); + if (jobMediaProfile === 'audiobook') { + return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job }); + } + if (jobMediaProfile === 'converter') { + const converterPlan = this.safeParseJson(job.encode_plan_json) || {}; + const converterMediaType = String(converterPlan?.converterMediaType || '').trim().toLowerCase(); + if (converterMediaType === 'audio') { + return this.startConverterEncode(jobId, { ...options, immediate: true, preloadedJob: job }); + } + return this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job }); + } + + this.ensureNotBusy('startPreparedJob', jobId); + logger.info('startPreparedJob:requested', { jobId }); + this.cancelRequestedByJob.delete(Number(jobId)); + + if (!job.title && !job.detected_title) { + const error = new Error('Start nicht möglich: keine Metadaten vorhanden.'); + error.statusCode = 400; + throw error; + } + + await resetProcessLogIfLifecycleAllows(jobId, job); + if (this.isMultipartMovieMergeHistoryJob(job)) { + return this.startMultipartMergeJob(jobId, { ...options, preloadedJob: job }); + } + + const encodePlanForReadyState = this.safeParseJson(job.encode_plan_json); + const readyMode = String(encodePlanForReadyState?.mode || '').trim().toLowerCase(); + const isPreRipReadyState = readyMode === 'pre_rip' || Boolean(encodePlanForReadyState?.preRip); + const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE'; + if (isReadyToEncode) { + if (!Number(job.encode_review_confirmed || 0)) { + const error = new Error('Encode-Start nicht erlaubt: Mediainfo-Prüfung muss zuerst bestätigt werden.'); + error.statusCode = 409; + throw error; + } + + const seriesBatchStart = await this.startSeriesBatchFromPrepared(jobId, job, options); + if (seriesBatchStart?.handled) { + return seriesBatchStart.result; + } + + if (isPreRipReadyState) { + const rawReuse = await this.resolveUsableRawInputForReadyJob(jobId, job, encodePlanForReadyState); + if (rawReuse.hasUsableRawInput) { + const updatePayload = { + status: 'ENCODING', + last_state: 'ENCODING', + error_message: null, + end_time: null + }; + if ( + rawReuse.resolvedRawPath + && normalizeComparablePath(rawReuse.resolvedRawPath) !== normalizeComparablePath(job.raw_path) + ) { + updatePayload.raw_path = rawReuse.resolvedRawPath; + } + if ( + rawReuse.inputPath + && normalizeComparablePath(rawReuse.inputPath) !== normalizeComparablePath(job.encode_input_path) + ) { + updatePayload.encode_input_path = rawReuse.inputPath; + } + await historyService.updateJob(jobId, updatePayload); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Pre-Rip Auswahl erkannt, nutzbares RAW gefunden (${rawReuse.resolvedRawPath}). Starte Encode direkt aus RAW; Laufwerk wird nicht angesprochen.` + ); + + this.startEncodingFromPrepared(jobId).catch((error) => { + logger.error('startPreparedJob:encode-background-from-prerip-raw-failed', { + jobId, + error: errorToMeta(error) + }); + if (error?.jobAlreadyFailed) { + return; + } + this.failJob(jobId, 'ENCODING', error).catch((failError) => { + logger.error('startPreparedJob:encode-background-from-prerip-raw-failJob-failed', { + jobId, + error: errorToMeta(failError) + }); + }); + }); + + return { started: true, stage: 'ENCODING', reusedRaw: true }; + } + + if (isRawImportPreparedJob) { + const error = new Error('RAW-Import-Job: kein nutzbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); + error.statusCode = 409; + throw error; + } + + await historyService.updateJob(jobId, { + status: 'RIPPING', + last_state: 'RIPPING', + error_message: null, + end_time: null + }); + if (job?.parent_job_id) { + await historyService.updateJob(job.parent_job_id, { + status: 'RIPPING', + last_state: 'RIPPING', + error_message: null, + end_time: null + }); + } + + this.startRipEncode(jobId).catch((error) => { + logger.error('startPreparedJob:rip-background-failed', { jobId, error: errorToMeta(error) }); + }); + + return { started: true, stage: 'RIPPING' }; + } + + await historyService.updateJob(jobId, { + status: 'ENCODING', + last_state: 'ENCODING', + error_message: null, + end_time: null + }); + + this.startEncodingFromPrepared(jobId).catch((error) => { + logger.error('startPreparedJob:encode-background-failed', { jobId, error: errorToMeta(error) }); + if (error?.jobAlreadyFailed) { + return; + } + this.failJob(jobId, 'ENCODING', error).catch((failError) => { + logger.error('startPreparedJob:encode-background-failJob-failed', { + jobId, + error: errorToMeta(failError) + }); + }); + }); + + return { started: true, stage: 'ENCODING' }; + } + + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: encodePlanForReadyState + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' + ? 'backup' + : 'mkv'; + const enforcePlaylistBeforeStart = ripMode !== 'backup'; + if (enforcePlaylistBeforeStart && playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null) { + const error = new Error( + 'Start nicht möglich: waiting_for_manual_playlist_selection aktiv. Bitte zuerst selected_playlist setzen.' + ); + error.statusCode = 409; + throw error; + } + + let existingRawInput = null; + if (job.raw_path) { + try { + if (fs.existsSync(job.raw_path)) { + existingRawInput = findPreferredRawInput(job.raw_path, { + playlistAnalysis: playlistDecision.playlistAnalysis, + selectedPlaylistId: playlistDecision.selectedPlaylist + }); + } + } catch (error) { + logger.warn('startPreparedJob:existing-raw-check-failed', { + jobId, + rawPath: job.raw_path, + error: errorToMeta(error) + }); + } + } + + // An Incomplete_ raw folder means the rip never finished — skip it and re-rip. + if (existingRawInput && resolveRawFolderStateFromPath(job.raw_path) === RAW_FOLDER_STATES.INCOMPLETE) { + logger.info('startPreparedJob:raw-incomplete-skip', { + jobId, + rawPath: job.raw_path + }); + existingRawInput = null; + } + + if (existingRawInput) { + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + start_time: nowIso(), + end_time: null, + error_message: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorhandenes RAW wird verwendet. Starte Titel-/Spurprüfung: ${job.raw_path}` + ); + + this.runReviewForRawJob(jobId, job.raw_path, { + mode: 'rip', + mediaProfile + }).catch((error) => { + logger.error('startPreparedJob:review-background-failed', { jobId, error: errorToMeta(error) }); + this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => { + logger.error('startPreparedJob:review-background-failJob-failed', { + jobId, + error: errorToMeta(failError) + }); + }); + }); + + return { + started: true, + stage: 'MEDIAINFO_CHECK', + reusedRaw: true, + rawPath: job.raw_path + }; + } + + if (job.raw_path) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Kein verwertbares RAW unter ${job.raw_path} gefunden. Starte neuen Rip.` + ); + } + + if (isRawImportPreparedJob) { + const error = new Error('RAW-Import-Job: kein verwertbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); + error.statusCode = 409; + throw error; + } + + await historyService.updateJob(jobId, { + status: 'RIPPING', + last_state: 'RIPPING', + error_message: null, + end_time: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + output_path: null + }); + if (job?.parent_job_id) { + await historyService.updateJob(job.parent_job_id, { + status: 'RIPPING', + last_state: 'RIPPING', + error_message: null, + end_time: null + }); + } + + this.startRipEncode(jobId).catch((error) => { + logger.error('startPreparedJob:background-failed', { jobId, error: errorToMeta(error) }); + }); + + return { started: true, stage: 'RIPPING' }; + } + + async startPreparedConverterVideoJob(jobId, options = {}) { + const immediate = Boolean(options?.immediate); + const preloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(jobId) + ? options.preloadedJob + : null; + const job = preloadedJob || await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; + const statusUpper = String(job.status || '').trim().toUpperCase(); + const lastStateUpper = String(job.last_state || '').trim().toUpperCase(); + const isReadyToEncode = statusUpper === 'READY_TO_ENCODE' || lastStateUpper === 'READY_TO_ENCODE'; + + if (isReadyToEncode) { + if (!Number(job.encode_review_confirmed || 0)) { + const error = new Error('Encode-Start nicht erlaubt: Mediainfo-Prüfung muss zuerst bestätigt werden.'); + error.statusCode = 409; + throw error; + } + if (!immediate) { + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + jobId, + () => this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job }), + { preloadedJob: job } + ); + } + + await historyService.updateJob(jobId, { + status: 'ENCODING', + last_state: 'ENCODING', + error_message: null, + end_time: null + }); + + this.startConverterEncode(jobId, { immediate: true, preloadedJob: job }).catch((error) => { + logger.error('startPreparedConverterVideoJob:encode-background-failed', { + jobId, + error: errorToMeta(error) + }); + if (error?.jobAlreadyFailed) { + return; + } + this.failJob(jobId, 'ENCODING', error).catch((failError) => { + logger.error('startPreparedConverterVideoJob:encode-background-failJob-failed', { + jobId, + error: errorToMeta(failError) + }); + }); + }); + + return { started: true, stage: 'ENCODING' }; + } + + if (statusUpper === 'METADATA_LOOKUP' || statusUpper === 'METADATA_SELECTION') { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten-Auswahl übersprungen. Starte Review ohne vorherige Metadaten-Zuordnung.' + ); + } + if (statusUpper === 'WAITING_FOR_USER_DECISION') { + const error = new Error('Start nicht möglich: Bitte zuerst die erforderliche Titel/Playlist-Auswahl treffen.'); + error.statusCode = 409; + throw error; + } + + const inputPath = String(encodePlan?.inputPath || job?.raw_path || '').trim(); + if (!inputPath || !fs.existsSync(inputPath)) { + const error = new Error(`Mediainfo-Prüfung nicht möglich: Eingabedatei fehlt (${inputPath || '-'})`); + error.statusCode = 400; + throw error; + } + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + start_time: nowIso(), + end_time: null, + error_message: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Converter-Review gestartet (Video): ${path.basename(inputPath)}` + ); + + this.runMediainfoReviewForJob(jobId, inputPath, { + mode: 'converter', + mediaProfile: 'converter', + previousEncodePlan: encodePlan + }).catch((error) => { + logger.error('startPreparedConverterVideoJob:review-background-failed', { + jobId, + error: errorToMeta(error) + }); + this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => { + logger.error('startPreparedConverterVideoJob:review-background-failJob-failed', { + jobId, + error: errorToMeta(failError) + }); + }); + }); + + return { + started: true, + stage: 'MEDIAINFO_CHECK', + reusedRaw: true, + rawPath: inputPath + }; + } + + async confirmEncodeReview(jobId, options = {}) { + this.ensureNotBusy('confirmEncodeReview', jobId); + const skipPipelineStateUpdate = Boolean(options?.skipPipelineStateUpdate); + logger.info('confirmEncodeReview:requested', { + jobId, + selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null, + selectedEncodeTitleIdsCount: Array.isArray(options?.selectedEncodeTitleIds) + ? options.selectedEncodeTitleIds.length + : 0, + selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection), + episodeAssignmentsProvided: Boolean(options?.episodeAssignments && typeof options.episodeAssignments === 'object'), + skipPipelineStateUpdate, + selectedHandBrakePreset: options?.selectedHandBrakePreset ?? null, + selectedPostEncodeScriptIdsCount: Array.isArray(options?.selectedPostEncodeScriptIds) + ? options.selectedPostEncodeScriptIds.length + : 0 + }); + + let job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + if (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE') { + const currentStatus = String(job.status || job.last_state || '').trim().toUpperCase(); + const recoverableStatus = currentStatus === 'ERROR' || currentStatus === 'CANCELLED'; + const recoveryPlan = this.safeParseJson(job.encode_plan_json); + const recoveryMode = String(recoveryPlan?.mode || '').trim().toLowerCase(); + const recoveryPreRip = recoveryMode === 'pre_rip' || Boolean(recoveryPlan?.preRip); + const recoveryHasInput = recoveryPreRip + ? Boolean(recoveryPlan?.encodeInputTitleId) + : Boolean(job?.encode_input_path || recoveryPlan?.encodeInputPath || job?.raw_path); + const recoveryHasConfirmedPlan = Boolean( + recoveryPlan + && Array.isArray(recoveryPlan?.titles) + && recoveryPlan.titles.length > 0 + && (Number(job?.encode_review_confirmed || 0) === 1 || Boolean(recoveryPlan?.reviewConfirmed)) + && recoveryHasInput + ); + if (recoverableStatus && recoveryHasConfirmedPlan) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Bestätigung angefordert obwohl Status ${currentStatus}. Letzte Encode-Auswahl wird automatisch geladen.` + ); + await this.restartEncodeWithLastSettings(jobId, { + immediate: true, + triggerReason: 'confirm_auto_prepare' + }); + job = await historyService.getJobById(jobId); + } + } + + if (!job || (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE')) { + const error = new Error('Bestätigung nicht möglich: Job ist nicht im Status READY_TO_ENCODE.'); + error.statusCode = 409; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json); + if (!encodePlan || !Array.isArray(encodePlan.titles)) { + const error = new Error('Bestätigung nicht möglich: keine Mediainfo-Auswertung vorhanden.'); + error.statusCode = 400; + throw error; + } + const multipartSettingsLock = encodePlan?.multipartSettingsLock && typeof encodePlan.multipartSettingsLock === 'object' + ? encodePlan.multipartSettingsLock + : null; + const multipartSettingsLocked = Boolean( + multipartSettingsLock?.enabled + && this.isMultipartMovieDiscChildHistoryJob(job) + ); + + const lockedSelectedEncodeTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); + const lockedSelectedEncodeTitleIds = normalizeReviewTitleIdList( + Array.isArray(encodePlan?.selectedTitleIds) + ? encodePlan.selectedTitleIds + : [lockedSelectedEncodeTitleId] + ); + const selectedEncodeTitleId = multipartSettingsLocked + ? (lockedSelectedEncodeTitleId || null) + : (options?.selectedEncodeTitleId ?? null); + const selectedEncodeTitleIds = multipartSettingsLocked + ? lockedSelectedEncodeTitleIds + : normalizeReviewTitleIdList(options?.selectedEncodeTitleIds); + const effectiveSelectedEncodeTitleIds = selectedEncodeTitleIds.length > 0 + ? selectedEncodeTitleIds + : normalizeReviewTitleIdList(selectedEncodeTitleId ? [selectedEncodeTitleId] : []); + const planWithSelectionResult = applyEncodeTitleSelectionToPlan( + encodePlan, + selectedEncodeTitleId, + effectiveSelectedEncodeTitleIds + ); + let planForConfirm = planWithSelectionResult.plan; + const trackSelectionTargets = normalizeReviewTitleIdList( + Array.isArray(planForConfirm?.selectedTitleIds) && planForConfirm.selectedTitleIds.length > 0 + ? planForConfirm.selectedTitleIds + : [planForConfirm?.encodeInputTitleId] + ); + let selectedTrackSelectionPayload = options?.selectedTrackSelection && typeof options.selectedTrackSelection === 'object' + ? options.selectedTrackSelection + : null; + if (multipartSettingsLocked) { + const manualSelectionByTitle = planForConfirm?.manualTrackSelectionByTitle + && typeof planForConfirm.manualTrackSelectionByTitle === 'object' + ? planForConfirm.manualTrackSelectionByTitle + : {}; + const lockedTrackSelectionPayload = {}; + for (const trackSelectionTitleId of trackSelectionTargets) { + const lockedEntry = manualSelectionByTitle[trackSelectionTitleId] + || manualSelectionByTitle[String(trackSelectionTitleId)] + || null; + if (lockedEntry && typeof lockedEntry === 'object') { + lockedTrackSelectionPayload[trackSelectionTitleId] = { + subtitleSelectionMode: normalizeSubtitleSelectionMode( + lockedEntry.subtitleSelectionMode, + lockedEntry.subtitleVariantSelection ? 'variants' : 'track_ids' + ), + audioTrackIds: Array.isArray(lockedEntry.audioTrackIds) ? lockedEntry.audioTrackIds : [], + subtitleTrackIds: Array.isArray(lockedEntry.subtitleTrackIds) ? lockedEntry.subtitleTrackIds : [], + subtitleVariantSelection: lockedEntry.subtitleVariantSelection || {}, + subtitleLanguageOrder: Array.isArray(lockedEntry.subtitleLanguageOrder) ? lockedEntry.subtitleLanguageOrder : [] + }; + continue; + } + const fallbackSelection = extractManualSelectionPayloadFromPlan({ + ...planForConfirm, + encodeInputTitleId: trackSelectionTitleId + }); + if (fallbackSelection && typeof fallbackSelection === 'object') { + lockedTrackSelectionPayload[trackSelectionTitleId] = { + subtitleSelectionMode: normalizeSubtitleSelectionMode( + fallbackSelection.subtitleSelectionMode, + fallbackSelection.subtitleVariantSelection ? 'variants' : 'track_ids' + ), + audioTrackIds: Array.isArray(fallbackSelection.audioTrackIds) ? fallbackSelection.audioTrackIds : [], + subtitleTrackIds: Array.isArray(fallbackSelection.subtitleTrackIds) ? fallbackSelection.subtitleTrackIds : [], + subtitleVariantSelection: fallbackSelection.subtitleVariantSelection || {}, + subtitleLanguageOrder: Array.isArray(fallbackSelection.subtitleLanguageOrder) ? fallbackSelection.subtitleLanguageOrder : [] + } + } + } + selectedTrackSelectionPayload = Object.keys(lockedTrackSelectionPayload).length > 0 + ? lockedTrackSelectionPayload + : null; + } + const trackSelectionResults = []; + if (selectedTrackSelectionPayload && trackSelectionTargets.length > 0) { + for (const trackSelectionTitleId of trackSelectionTargets) { + const perTitleSelection = selectedTrackSelectionPayload?.[trackSelectionTitleId] + || selectedTrackSelectionPayload?.[String(trackSelectionTitleId)] + || null; + if (!perTitleSelection || typeof perTitleSelection !== 'object') { + continue; + } + const selectionResult = applyManualTrackSelectionToPlan( + { + ...planForConfirm, + encodeInputTitleId: trackSelectionTitleId + }, + { + [trackSelectionTitleId]: perTitleSelection + } + ); + if ( + Array.isArray(selectionResult.subtitleSelectionValidationErrors) + && selectionResult.subtitleSelectionValidationErrors.length > 0 + ) { + const error = new Error( + `Subtitle-Auswahl ungültig (Titel #${trackSelectionTitleId}): ${selectionResult.subtitleSelectionValidationErrors.join(' | ')}` + ); + error.statusCode = 400; + throw error; + } + planForConfirm = { + ...selectionResult.plan, + encodeInputTitleId: planWithSelectionResult?.plan?.encodeInputTitleId || planForConfirm?.encodeInputTitleId || null, + encodeInputPath: planWithSelectionResult?.plan?.encodeInputPath || planForConfirm?.encodeInputPath || null + }; + trackSelectionResults.push({ + titleId: trackSelectionTitleId, + audioTrackIds: selectionResult.audioTrackIds, + subtitleTrackIds: selectionResult.subtitleTrackIds, + subtitleForcedTrackIndexes: selectionResult.subtitleForcedTrackIndexes, + subtitleSelectionValidationErrors: selectionResult.subtitleSelectionValidationErrors + }); + } + } + const primaryTrackSelectionResult = trackSelectionResults.find((entry) => + Number(entry?.titleId) === Number(planForConfirm?.encodeInputTitleId) + ) || trackSelectionResults[0] || { + audioTrackIds: [], + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] + }; + const normalizedEpisodeAssignments = normalizeEpisodeAssignmentsPayload( + multipartSettingsLocked + ? (planForConfirm?.episodeAssignments || null) + : options?.episodeAssignments, + planForConfirm?.selectedTitleIds || [] + ); + const titleById = new Map( + (Array.isArray(planForConfirm?.titles) ? planForConfirm.titles : []) + .map((title) => [Number(title?.id), title]) + ); + const remappedTitlesWithEpisodes = (Array.isArray(planForConfirm?.titles) ? planForConfirm.titles : []).map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const assignment = titleId ? normalizedEpisodeAssignments[String(titleId)] : null; + if (!assignment) { + return title; + } + const episodeTitle = String(assignment?.episodeTitle || '').trim() || null; + const episodeNumberStart = assignment?.episodeNumberStart + ?? assignment?.episodeNoStart + ?? assignment?.episodeNumber + ?? null; + const episodeNumberEnd = assignment?.episodeNumberEnd + ?? assignment?.episodeNoEnd + ?? null; + const normalizedEpisodeSpan = normalizePositiveInteger( + assignment?.episodeSpan + ?? ( + normalizeEpisodeNumberValue(episodeNumberStart) !== null + && normalizeEpisodeNumberValue(episodeNumberEnd) !== null + && normalizeEpisodeNumberValue(episodeNumberEnd) >= normalizeEpisodeNumberValue(episodeNumberStart) + ? (normalizeEpisodeNumberValue(episodeNumberEnd) - normalizeEpisodeNumberValue(episodeNumberStart) + 1) + : null + ) + ); + return { + ...title, + episodeId: assignment?.episodeId || assignment?.episodeIdStart || null, + episodeIdStart: assignment?.episodeIdStart || assignment?.episodeId || null, + episodeIdEnd: assignment?.episodeIdEnd || null, + episodeNumber: assignment?.episodeNumber ?? episodeNumberStart ?? null, + episodeNumberStart: episodeNumberStart ?? null, + episodeNumberEnd: episodeNumberEnd ?? episodeNumberStart ?? null, + episodeSpan: normalizedEpisodeSpan, + episodeRange: assignment?.episodeRange || null, + seasonNumber: assignment?.seasonNumber ?? title?.seasonNumber ?? null, + episodeTitleStart: assignment?.episodeTitleStart || null, + episodeTitleEnd: assignment?.episodeTitleEnd || null, + episodeTitle, + fileName: episodeTitle || title?.fileName || `Title #${titleId}` + }; + }); + const normalizedEpisodeAssignmentsWithPath = Object.fromEntries( + Object.entries(normalizedEpisodeAssignments).map(([titleIdKey, assignment]) => { + const resolvedTitle = titleById.get(Number(titleIdKey)) || null; + return [ + titleIdKey, + { + ...assignment, + filePath: resolvedTitle?.filePath || null + } + ]; + }) + ); + planForConfirm = { + ...planForConfirm, + titles: remappedTitlesWithEpisodes, + episodeAssignments: normalizedEpisodeAssignmentsWithPath + }; + const hasExplicitPostScriptSelection = !multipartSettingsLocked && options?.selectedPostEncodeScriptIds !== undefined; + const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection + ? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || []) + : normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []); + const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { + strict: true + }); + + const hasExplicitPreScriptSelection = !multipartSettingsLocked && options?.selectedPreEncodeScriptIds !== undefined; + const selectedPreEncodeScriptIds = hasExplicitPreScriptSelection + ? normalizeScriptIdList(options?.selectedPreEncodeScriptIds || []) + : normalizeScriptIdList(planForConfirm?.preEncodeScriptIds || encodePlan?.preEncodeScriptIds || []); + const selectedPreEncodeScripts = await scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }); + + const hasExplicitPostChainSelection = !multipartSettingsLocked && options?.selectedPostEncodeChainIds !== undefined; + const selectedPostEncodeChainIds = hasExplicitPostChainSelection + ? normalizeChainIdList(options?.selectedPostEncodeChainIds || []) + : normalizeChainIdList(planForConfirm?.postEncodeChainIds || encodePlan?.postEncodeChainIds || []); + + const hasExplicitPreChainSelection = !multipartSettingsLocked && options?.selectedPreEncodeChainIds !== undefined; + const selectedPreEncodeChainIds = hasExplicitPreChainSelection + ? normalizeChainIdList(options?.selectedPreEncodeChainIds || []) + : normalizeChainIdList(planForConfirm?.preEncodeChainIds || encodePlan?.preEncodeChainIds || []); + + const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase(); + const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip); + + if (planForConfirm?.playlistDecisionRequired && !planForConfirm?.encodeInputPath && !planForConfirm?.encodeInputTitleId) { + const error = new Error('Bestätigung nicht möglich: Bitte zuerst einen Titel per Checkbox auswählen.'); + error.statusCode = 400; + throw error; + } + + const readyMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: planForConfirm + }); + const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); + const settingsExtraArgsForProfile = String(confirmSettings?.handbrake_extra_args || '').trim(); + + // Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan. + const hasUserPresetIdField = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId'); + const hasHandBrakePresetField = Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset'); + const explicitUserPresetId = hasUserPresetIdField + ? normalizePositiveInteger(options?.selectedUserPresetId) + : null; + const rawHandBrakePreset = hasHandBrakePresetField + ? String(options?.selectedHandBrakePreset || '').trim() + : ''; + const hasExplicitUserPresetSelection = !multipartSettingsLocked && explicitUserPresetId !== null; + const hasExplicitHandBrakePresetSelection = !multipartSettingsLocked && Boolean(rawHandBrakePreset); + const explicitPresetClearRequested = !multipartSettingsLocked + && (hasUserPresetIdField || hasHandBrakePresetField) + && explicitUserPresetId === null + && !rawHandBrakePreset; + let resolvedUserPreset = null; + if (hasExplicitUserPresetSelection) { + resolvedUserPreset = await userPresetService.getPresetById(explicitUserPresetId); + if (!resolvedUserPreset) { + const error = new Error(`User-Preset ${explicitUserPresetId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + } + if (!resolvedUserPreset && hasExplicitHandBrakePresetSelection) { + resolvedUserPreset = rawHandBrakePreset + ? { + name: `HandBrake: ${rawHandBrakePreset}`, + handbrakePreset: rawHandBrakePreset, + extraArgs: settingsExtraArgsForProfile + } + : null; + } + if (!resolvedUserPreset && explicitPresetClearRequested && settingsExtraArgsForProfile) { + resolvedUserPreset = { + name: 'Settings CLI-Parameter', + handbrakePreset: null, + extraArgs: settingsExtraArgsForProfile + }; + } + if (!resolvedUserPreset && !explicitPresetClearRequested) { + resolvedUserPreset = normalizeUserPresetForPlan( + planForConfirm?.userPreset || encodePlan?.userPreset || null + ); + } + + const confirmedPlan = { + ...planForConfirm, + postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), + postEncodeScripts: selectedPostEncodeScripts.map((item) => ({ + id: Number(item.id), + name: item.name + })), + preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)), + preEncodeScripts: selectedPreEncodeScripts.map((item) => ({ + id: Number(item.id), + name: item.name + })), + postEncodeChainIds: selectedPostEncodeChainIds, + preEncodeChainIds: selectedPreEncodeChainIds, + reviewConfirmed: true, + reviewConfirmedAt: nowIso(), + userPreset: normalizeUserPresetForPlan(resolvedUserPreset) + }; + const confirmedConverterMediaType = String( + confirmedPlan?.converterMediaType || encodePlan?.converterMediaType || '' + ).trim().toLowerCase(); + const requiresExplicitPreset = readyMediaProfile === 'dvd' + || readyMediaProfile === 'bluray' + || (readyMediaProfile === 'converter' && confirmedConverterMediaType !== 'audio'); + const planPresetName = explicitPresetClearRequested + ? '' + : String( + planForConfirm?.userPreset?.handbrakePreset + || encodePlan?.userPreset?.handbrakePreset + || planForConfirm?.selectors?.preset + || encodePlan?.selectors?.preset + || '' + ).trim(); + const planExtraArgs = explicitPresetClearRequested + ? '' + : String( + planForConfirm?.userPreset?.extraArgs + || encodePlan?.userPreset?.extraArgs + || planForConfirm?.selectors?.extraArgs + || encodePlan?.selectors?.extraArgs + || '' + ).trim(); + const effectivePresetName = explicitPresetClearRequested + ? '' + : String( + resolvedUserPreset?.handbrakePreset + || rawHandBrakePreset + || planPresetName + || confirmSettings?.handbrake_preset + || '' + ).trim(); + const effectiveExtraArgs = explicitPresetClearRequested + ? String( + resolvedUserPreset?.extraArgs + || settingsExtraArgsForProfile + || '' + ).trim() + : String( + resolvedUserPreset?.extraArgs + || planExtraArgs + || settingsExtraArgsForProfile + || '' + ).trim(); + if (requiresExplicitPreset && !effectivePresetName && !effectiveExtraArgs) { + const error = new Error('Bestätigung nicht möglich: Kein Preset oder Extra-Args gesetzt. Bitte User-/HandBrake-Preset wählen oder passende Settings hinterlegen.'); + error.statusCode = 400; + throw error; + } + const actionDisplaySettings = { + ...(confirmSettings && typeof confirmSettings === 'object' ? confirmSettings : {}), + handbrake_preset: effectivePresetName || null, + handbrake_extra_args: effectiveExtraArgs + }; + confirmedPlan.titles = refreshAudioTrackActionsForPlanTitles( + confirmedPlan.titles, + actionDisplaySettings, + null + ); + + const resolvedConfirmRawPath = this.resolveCurrentRawPathForSettings( + confirmSettings, + readyMediaProfile, + job.raw_path + ); + const activeConfirmRawPath = resolvedConfirmRawPath || String(job.raw_path || '').trim() || null; + + let inputPath = isPreRipMode + ? null + : (job.encode_input_path || confirmedPlan.encodeInputPath || this.snapshot.context?.inputPath || null); + if (!isPreRipMode && activeConfirmRawPath) { + const needsInputRefresh = !inputPath + || !fs.existsSync(inputPath) + || !isPathInsideDirectory(activeConfirmRawPath, inputPath); + if (needsInputRefresh) { + const selectedPlaylistId = normalizePlaylistId( + confirmedPlan?.selectedPlaylistId + || confirmedPlan?.selectedPlaylist + || null + ); + if (hasBluRayBackupStructure(activeConfirmRawPath)) { + inputPath = activeConfirmRawPath; + } else { + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylistId); + inputPath = findPreferredRawInput(activeConfirmRawPath, { + playlistAnalysis: playlistDecision.playlistAnalysis, + selectedPlaylistId: selectedPlaylistId || playlistDecision.selectedPlaylist + })?.path || null; + } + } + } + confirmedPlan.encodeInputPath = inputPath; + const hasEncodableTitle = isPreRipMode + ? Boolean(confirmedPlan?.encodeInputTitleId) + : Boolean(inputPath); + + await historyService.updateJob(jobId, { + encode_review_confirmed: 1, + encode_plan_json: JSON.stringify(confirmedPlan), + encode_input_path: inputPath, + ...(activeConfirmRawPath ? { raw_path: activeConfirmRawPath } : {}) + }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Primär-Titel #${confirmedPlan.encodeInputTitleId}.` : ''}` + + ` Gewählte Titel: ${Array.isArray(confirmedPlan?.selectedTitleIds) && confirmedPlan.selectedTitleIds.length > 0 ? confirmedPlan.selectedTitleIds.map((id) => `#${id}`).join(', ') : 'none'}.` + + ` Audio-Spuren (Primär): ${primaryTrackSelectionResult.audioTrackIds.length > 0 ? primaryTrackSelectionResult.audioTrackIds.join(',') : 'none'}.` + + ` Subtitle-Spuren (Primär): ${primaryTrackSelectionResult.subtitleTrackIds.length > 0 ? primaryTrackSelectionResult.subtitleTrackIds.join(',') : 'none'}.` + + ` Subtitle-Forced-Indizes (Primär): ${Array.isArray(primaryTrackSelectionResult.subtitleForcedTrackIndexes) && primaryTrackSelectionResult.subtitleForcedTrackIndexes.length > 0 ? primaryTrackSelectionResult.subtitleForcedTrackIndexes.join(',') : 'none'}.` + + ` Episoden-Zuordnung: ${Object.keys(confirmedPlan?.episodeAssignments || {}).length > 0 ? Object.keys(confirmedPlan.episodeAssignments).length : 0}.` + + ` Pre-Encode-Scripte: ${selectedPreEncodeScripts.length > 0 ? selectedPreEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + + ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.` + + ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + + ` Post-Encode-Ketten: ${selectedPostEncodeChainIds.length > 0 ? selectedPostEncodeChainIds.join(',') : 'none'}.` + + (multipartSettingsLocked + ? ` Multipart-Lock aktiv${multipartSettingsLock?.sourceJobId ? ` (Quelle Job #${multipartSettingsLock.sourceJobId}` : ''}${multipartSettingsLock?.sourceDiscNumber ? `${multipartSettingsLock?.sourceJobId ? ', ' : ' ('}Disc ${multipartSettingsLock.sourceDiscNumber}` : ''}${multipartSettingsLock?.sourceJobId || multipartSettingsLock?.sourceDiscNumber ? ')' : ''}.` + : '') + + (resolvedUserPreset + ? ` User-Preset: "${resolvedUserPreset.name}"${resolvedUserPreset.id ? ` (ID ${resolvedUserPreset.id})` : ''}.` + : '') + ); + + if (!skipPipelineStateUpdate) { + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: hasEncodableTitle + ? (isPreRipMode + ? 'Spurauswahl bestätigt - Backup/Rip + Encode manuell starten' + : 'Mediainfo bestätigt - Encode manuell starten') + : (isPreRipMode + ? 'Spurauswahl bestätigt - kein passender Titel gewählt' + : 'Mediainfo bestätigt - kein Titel erfüllt MIN_LENGTH_MINUTES'), + context: { + ...(this.snapshot.context || {}), + jobId, + inputPath, + hasEncodableTitle, + mediaProfile: readyMediaProfile, + mediaInfoReview: confirmedPlan, + reviewConfirmed: true + } + }); + } + + return historyService.getJobById(jobId); + } + + async reencodeFromRaw(sourceJobId, options = {}) { + this.ensureNotBusy('reencodeFromRaw', sourceJobId); + logger.info('reencodeFromRaw:requested', { sourceJobId }); + this.cancelRequestedByJob.delete(Number(sourceJobId)); + + const sourceJob = await historyService.getJobById(sourceJobId); + if (!sourceJob) { + const error = new Error(`Quelle-Job ${sourceJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + if (!sourceJob.raw_path) { + const error = new Error('Re-Encode nicht möglich: raw_path fehlt.'); + error.statusCode = 400; + throw error; + } + + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(sourceJob.status)) { + const error = new Error('Re-Encode nicht möglich: Quelljob ist noch aktiv.'); + error.statusCode = 409; + throw error; + } + + const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); + + const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); + const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, { + makemkvInfo: mkInfo, + encodePlan: sourceEncodePlan, + rawPath: sourceJob.raw_path + }); + if (reencodeMediaProfile === 'audiobook') { + const reencodeSettings = await settingsService.getSettingsMap(); + const resolvedAudiobookRawPath = this.resolveCurrentRawPathForSettings( + reencodeSettings, + reencodeMediaProfile, + sourceJob.raw_path + ); + if (!resolvedAudiobookRawPath) { + const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + + const rawInput = findPreferredRawInput(resolvedAudiobookRawPath); + if (!rawInput) { + const error = new Error('Re-Encode nicht möglich: keine AAX-Datei im RAW-Pfad gefunden.'); + error.statusCode = 400; + throw error; + } + + const refreshedPlan = { + ...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}), + mediaProfile: 'audiobook', + jobKind: 'audiobook', + mode: 'audiobook', + encodeInputPath: rawInput.path + }; + + const reencodeDeleteFolders = Array.isArray(options?.deleteFolders) + ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + if (reencodeDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(sourceJobId, reencodeDeleteFolders); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `Audiobook-Re-Encode: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('reencodeFromRaw:audiobook:delete-specific-folders-failed', { + jobId: sourceJobId, + error: errorToMeta(error) + }); + } + } + + await resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); + await historyService.updateJob(sourceJobId, { + media_type: 'audiobook', + job_kind: 'audiobook', + status: 'READY_TO_START', + last_state: 'READY_TO_START', + start_time: null, + end_time: null, + error_message: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: JSON.stringify(refreshedPlan), + encode_input_path: rawInput.path, + encode_review_confirmed: 1, + raw_path: resolvedAudiobookRawPath + }); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `Audiobook-Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input: ${rawInput.path}` + ); + + return this.startPreparedJob(sourceJobId); + } + + if (reencodeMediaProfile === 'cd') { + const cdReencodeSettings = await settingsService.getEffectiveSettingsMap('cd'); + const resolvedCdRawPath = this.resolveCurrentRawPathForSettings( + cdReencodeSettings, + 'cd', + sourceJob.raw_path + ); + if (!resolvedCdRawPath) { + const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht gefunden (${sourceJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + + // WAV-Dateien im RAW-Verzeichnis suchen + const wavFiles = fs.existsSync(resolvedCdRawPath) + ? fs.readdirSync(resolvedCdRawPath).filter((f) => /\.wav$/i.test(f)) + : []; + if (wavFiles.length === 0) { + const error = new Error('CD-Encode nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.'); + error.statusCode = 400; + throw error; + } + + const cdEncodePlan = sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}; + const cdMkInfo = mkInfo && typeof mkInfo === 'object' ? mkInfo : {}; + const cdHandbrakeInfo = this.safeParseJson(sourceJob.handbrake_info_json) || {}; + const selectedMeta = cdMkInfo?.selectedMetadata && typeof cdMkInfo.selectedMetadata === 'object' + ? cdMkInfo.selectedMetadata + : {}; + const tocTracks = Array.isArray(cdMkInfo?.tracks) && cdMkInfo.tracks.length > 0 + ? cdMkInfo.tracks + : (Array.isArray(cdEncodePlan?.tracks) ? cdEncodePlan.tracks : []); + const selectedTrackPositions = normalizeCdTrackPositionList( + Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0 + ? cdEncodePlan.selectedTracks + : tocTracks.map((t) => Number(t?.position)).filter((p) => Number.isFinite(p) && p > 0) + ); + const format = String(cdEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac'; + const formatOptions = cdEncodePlan?.formatOptions && typeof cdEncodePlan.formatOptions === 'object' + ? cdEncodePlan.formatOptions + : {}; + const directReencodeEligibility = evaluateCdDirectReencodeEligibility({ + job: sourceJob, + encodePlan: cdEncodePlan, + mkInfo: cdMkInfo, + handbrakeInfo: cdHandbrakeInfo + }); + if (!directReencodeEligibility.eligible) { + const reasonText = directReencodeEligibility.reasons.join(' | '); + await historyService.appendLog( + sourceJobId, + 'SYSTEM', + `CD-Encode aus RAW blockiert. Bitte zuerst "Vorprüfung starten". Gründe: ${reasonText}` + ).catch(() => {}); + const error = new Error( + `Direktes CD-Encode ist nicht erlaubt: ${reasonText} Bitte zuerst "Vorprüfung starten".` + ); + error.statusCode = 409; + error.details = directReencodeEligibility.reasons.map((message) => ({ + field: 'cd_reencode', + message + })); + throw error; + } + const cdOutputTemplate = String( + cdReencodeSettings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE + ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + const cdOutputBaseDir = String(cdReencodeSettings.movie_dir || '').trim() + || String(cdReencodeSettings.raw_dir || '').trim() + || settingsService.DEFAULT_CD_DIR; + const cdOutputOwner = String(cdReencodeSettings.movie_dir_owner || cdReencodeSettings.raw_dir_owner || '').trim(); + const baseOutputDir = cdRipService.buildOutputDir(selectedMeta, cdOutputBaseDir, cdOutputTemplate); + + // Handle output conflict resolution + const keepBoth = Boolean(options?.keepBoth); + const deleteFolders = Array.isArray(options?.deleteFolders) + ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + if (deleteFolders.length > 0) { + await historyService.deleteSpecificOutputFolders(sourceJobId, deleteFolders); + } + const baseOutputStillExists = (() => { + try { + return fs.existsSync(baseOutputDir); + } catch (_error) { + return false; + } + })(); + // Conflict strategy: + // - keepBoth => always number (_X) + // - replace + remaining sibling folders => continue numbering (_X) + // - replace + all removed => reuse base folder without numbering + const needsNumberedOutput = keepBoth || baseOutputStillExists; + const outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir; + + const reencodeVirtualDrivePath = `__virtual__${sourceJobId}`; + const cdLiveTrackRowsReenc = buildCdLiveTrackRows(selectedTrackPositions, tocTracks, selectedMeta?.artist); + const initialCdLiveReenc = buildCdLiveProgressSnapshot({ + trackRows: cdLiveTrackRowsReenc, + phase: 'encode', + trackIndex: cdLiveTrackRowsReenc.length > 0 ? 1 : 0, + trackTotal: cdLiveTrackRowsReenc.length, + trackPosition: cdLiveTrackRowsReenc[0]?.position || null, + ripCompletedCount: cdLiveTrackRowsReenc.length, + encodeCompletedCount: 0 + }); + + await resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); + await historyService.updateJob(sourceJobId, { + status: 'CD_RIPPING', + last_state: 'CD_RIPPING', + start_time: new Date().toISOString(), + end_time: null, + error_message: null, + output_path: outputDir + }); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `CD-Encode aus RAW angefordert (skipRip). Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}, RAW=${resolvedCdRawPath}` + ); + + this._setCdDriveState(reencodeVirtualDrivePath, { + state: 'CD_RIPPING', + jobId: sourceJobId, + progress: 0, + eta: null, + statusText: 'Tracks werden encodiert …', + context: { + jobId: sourceJobId, + mediaProfile: 'cd', + tracks: tocTracks, + selectedMetadata: selectedMeta, + devicePath: null, + virtualDrivePath: reencodeVirtualDrivePath, + skipRip: true, + cdparanoiaCmd: 'cdparanoia', + rawWavDir: resolvedCdRawPath, + outputPath: outputDir, + outputTemplate: cdOutputTemplate, + cdRipConfig: cdEncodePlan, + cdLive: initialCdLiveReenc + } + }); + + this._runCdRip({ + jobId: sourceJobId, + devicePath: reencodeVirtualDrivePath, + cdparanoiaCmd: 'cdparanoia', + rawWavDir: resolvedCdRawPath, + rawBaseDir: null, + cdMetadataBase: null, + outputDir, + format, + formatOptions, + outputTemplate: cdOutputTemplate, + rawOwner: null, + outputOwner: cdOutputOwner, + selectedTrackPositions, + tocTracks, + selectedMeta, + encodePlan: cdEncodePlan, + skipRip: true + }).catch((error) => { + logger.error('reencodeFromRaw:cd:background-failed', { jobId: sourceJobId, error: errorToMeta(error) }); + this.failJob(sourceJobId, 'CD_ENCODING', error).catch((failError) => { + logger.error('reencodeFromRaw:cd:failJob-failed', { jobId: sourceJobId, error: errorToMeta(failError) }); + }); + }); + + return { jobId: sourceJobId, started: true, queued: false }; + } + + const ripSuccessful = this.isRipSuccessful(sourceJob); + if (!ripSuccessful) { + const error = new Error( + `Re-Encode nicht möglich: RAW-Rip ist nicht abgeschlossen (MakeMKV Status ${mkInfo?.status || 'unknown'}).` + ); + error.statusCode = 400; + throw error; + } + const reencodeSettings = await settingsService.getSettingsMap(); + const resolvedReencodeRawPath = this.resolveCurrentRawPathForSettings( + reencodeSettings, + reencodeMediaProfile, + sourceJob.raw_path + ); + if (!resolvedReencodeRawPath) { + const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + + await resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); + + // Handle explicit folder deletion requested by the user (video/audiobook) + const reencodeDeleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders : []; + if (reencodeDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(sourceJobId, reencodeDeleteFolders); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `Re-Encode: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('reencodeFromRaw:delete-specific-folders-failed', { + jobId: sourceJobId, + error: errorToMeta(error) + }); + } + } + + const rawInput = findPreferredRawInput(resolvedReencodeRawPath); + if (!rawInput) { + const error = new Error('Re-Encode nicht möglich: keine Datei im RAW-Pfad gefunden.'); + error.statusCode = 400; + throw error; + } + + const resetMakemkvInfoJson = (mkInfo && typeof mkInfo === 'object') + ? JSON.stringify({ + ...mkInfo, + analyzeContext: { + ...(mkInfo?.analyzeContext || {}), + selectedPlaylist: null, + selectedTitleId: null + } + }) + : (sourceJob.makemkv_info_json || null); + + const reencodeJobUpdate = { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + start_time: nowIso(), + end_time: null, + error_message: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + makemkv_info_json: resetMakemkvInfoJson + }; + if (resolvedReencodeRawPath !== sourceJob.raw_path) { + reencodeJobUpdate.raw_path = resolvedReencodeRawPath; + } + await historyService.updateJob(sourceJobId, reencodeJobUpdate); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input-Kandidat: ${rawInput.path}` + ); + + this.runReviewForRawJob(sourceJobId, resolvedReencodeRawPath, { + mode: 'reencode', + sourceJobId, + forcePlaylistReselection: true, + mediaProfile: this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, rawPath: resolvedReencodeRawPath }) + }).catch((error) => { + logger.error('reencodeFromRaw:background-failed', { jobId: sourceJobId, sourceJobId, error: errorToMeta(error) }); + this.failJob(sourceJobId, 'MEDIAINFO_CHECK', error).catch((failError) => { + logger.error('reencodeFromRaw:background-failJob-failed', { + jobId: sourceJobId, + sourceJobId, + error: errorToMeta(failError) + }); + }); + }); + + return { + started: true, + stage: 'MEDIAINFO_CHECK', + sourceJobId, + jobId: sourceJobId + }; + } + + async restartCdReviewFromRaw(jobId, options = {}) { + const resolvedCdReviewJob = await this.resolveExistingJobForAction(jobId, 'restart_cd_review'); + jobId = resolvedCdReviewJob.resolvedJobId; + this.ensureNotBusy('restartCdReviewFromRaw', jobId); + logger.info('restartCdReviewFromRaw:requested', { jobId, options }); + this.cancelRequestedByJob.delete(Number(jobId)); + + const sourceJob = resolvedCdReviewJob.job || await historyService.getJobById(jobId); + + const currentStatus = String(sourceJob.status || '').trim().toUpperCase(); + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(currentStatus)) { + const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); + error.statusCode = 409; + throw error; + } + + const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json) || {}; + const encodePlan = this.safeParseJson(sourceJob.encode_plan_json) || {}; + const keepBoth = Boolean(options?.keepBoth); + const reviewDeleteFolders = Array.isArray(options?.deleteFolders) + ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, encodePlan }); + + if (mediaProfile !== 'cd') { + const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist kein Audio-CD-Job (erkanntes Profil: ${mediaProfile || 'unbekannt'}).`); + error.statusCode = 400; + throw error; + } + + const cdSettings = await settingsService.getEffectiveSettingsMap('cd'); + const resolvedRawPath = this.resolveCurrentRawPathForSettings(cdSettings, 'cd', sourceJob.raw_path); + if (!resolvedRawPath) { + const error = new Error(`CD-Vorprüfung nicht möglich: RAW-Verzeichnis nicht erreichbar (${sourceJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + + const wavFiles = fs.existsSync(resolvedRawPath) + ? fs.readdirSync(resolvedRawPath).filter((f) => /\.wav$/i.test(f)) + : []; + if (wavFiles.length === 0) { + const error = new Error('CD-Vorprüfung nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.'); + error.statusCode = 400; + throw error; + } + + if (reviewDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, reviewDeleteFolders); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `CD-Vorprüfung: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('restartCdReviewFromRaw:delete-specific-folders-failed', { + jobId, + error: errorToMeta(error) + }); + } + } + + const shouldPersistOutputConflict = keepBoth || reviewDeleteFolders.length > 0; + const nextEncodePlan = encodePlan && typeof encodePlan === 'object' + ? { ...encodePlan } + : {}; + + const tocTracks = Array.isArray(mkInfo.tracks) && mkInfo.tracks.length > 0 + ? mkInfo.tracks + : (Array.isArray(encodePlan.tracks) && encodePlan.tracks.length > 0 ? encodePlan.tracks : []); + // If no track info available (e.g. orphan import with plain WAV files), derive tracks from sorted WAV files + const effectiveTocTracks = tocTracks.length > 0 + ? tocTracks + : wavFiles + .filter((f) => /\.wav$/i.test(f)) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + .map((f, i) => ({ position: i + 1, title: `Track ${i + 1}`, artist: null, selected: true })); + const previousSelectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' + ? mkInfo.selectedMetadata + : {}; + const resolvedMbId = String( + previousSelectedMetadata?.mbId + || previousSelectedMetadata?.musicBrainzId + || previousSelectedMetadata?.musicbrainzId + || previousSelectedMetadata?.musicbrainz_id + || previousSelectedMetadata?.music_brainz_id + || previousSelectedMetadata?.musicbrainz + || previousSelectedMetadata?.mbid + || '' + ).trim() || null; + const selectedMetadata = { + title: normalizeCdTrackText(previousSelectedMetadata?.title) + || normalizeCdTrackText(sourceJob?.title) + || normalizeCdTrackText(sourceJob?.detected_title) + || 'Audio CD', + artist: normalizeCdTrackText(previousSelectedMetadata?.artist) || null, + year: Number.isFinite(Number(previousSelectedMetadata?.year)) + ? Math.trunc(Number(previousSelectedMetadata.year)) + : (Number.isFinite(Number(sourceJob?.year)) ? Math.trunc(Number(sourceJob.year)) : null), + mbId: resolvedMbId, + coverUrl: normalizeCdTrackText( + previousSelectedMetadata?.coverUrl + || previousSelectedMetadata?.poster + || previousSelectedMetadata?.posterUrl + || sourceJob?.poster_url + ) || null + }; + + const resetTracks = effectiveTocTracks.map((track, index) => { + const position = Number(track?.position); + const normalizedPosition = Number.isFinite(position) && position > 0 ? Math.trunc(position) : (index + 1); + return { + ...track, + position: normalizedPosition, + title: normalizeCdTrackText(track?.title) || `Track ${normalizedPosition}`, + artist: normalizeCdTrackText(track?.artist) || selectedMetadata.artist || null, + selected: track?.selected !== false + }; + }); + nextEncodePlan.tracks = resetTracks; + nextEncodePlan.selectedTracks = resetTracks + .filter((track) => track.selected !== false) + .map((track) => Number(track.position)) + .filter((position) => Number.isFinite(position) && position > 0); + nextEncodePlan.directReencodeReady = false; + nextEncodePlan.directReencodeReadyAt = null; + if (shouldPersistOutputConflict) { + nextEncodePlan.outputConflict = { + strategy: keepBoth ? 'keep_both' : 'replace', + keepBoth, + requestedAt: nowIso(), + deletedFolderCount: reviewDeleteFolders.length + }; + } else { + delete nextEncodePlan.outputConflict; + } + const updatedMkInfo = { + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + tracks: resetTracks, + selectedMetadata + }; + const cdparanoiaCmd = String(mkInfo.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; + + const jobUpdatePayload = { + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', + start_time: null, + end_time: null, + error_message: null, + encode_plan_json: JSON.stringify(nextEncodePlan), + makemkv_info_json: JSON.stringify(updatedMkInfo) + }; + await historyService.updateJob(jobId, jobUpdatePayload); + + const virtualDrivePath = `__virtual__${jobId}`; + this._setCdDriveState(virtualDrivePath, { + state: 'CD_READY_TO_RIP', + jobId, + device: null, + progress: 0, + eta: null, + statusText: 'CD bereit zum Encodieren', + context: { + jobId, + mediaProfile: 'cd', + devicePath: null, + virtualDrivePath, + skipRip: true, + rawWavDir: resolvedRawPath, + cdparanoiaCmd, + tracks: resetTracks, + selectedMetadata, + detectedTitle: sourceJob.detected_title || sourceJob.title || 'Audio CD' + } + }); + + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + + await historyService.appendLog( + jobId, + 'USER_ACTION', + `CD-Vorprüfung aus RAW gestartet (skipRip, Metadaten aus letztem Lauf übernommen). ${wavFiles.length} WAV-Datei(en) in: ${resolvedRawPath}` + ); + + return { jobId, tracks: resetTracks, selectedMetadata }; + } + + async runMediainfoForFile(jobId, inputPath, options = {}) { + const lines = []; + const config = await settingsService.buildMediaInfoConfig(inputPath, { + mediaProfile: options?.mediaProfile || null, + settingsMap: options?.settingsMap || null + }); + logger.info('mediainfo:command', { jobId, inputPath, cmd: config.cmd, args: config.args }); + + const runInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'MEDIAINFO', + cmd: config.cmd, + args: config.args, + collectLines: lines, + collectStderrLines: false + }); + + const parsed = parseMediainfoJsonOutput(lines.join('\n')); + if (!parsed) { + const error = new Error(`Mediainfo-Ausgabe konnte nicht als JSON gelesen werden (${path.basename(inputPath)}).`); + error.runInfo = runInfo; + throw error; + } + + return { + runInfo, + parsed + }; + } + + async runDvdTrackFallbackForFile(jobId, inputPath, options = {}) { + const lines = []; + const scanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { + mediaProfile: options?.mediaProfile || null, + settingsMap: options?.settingsMap || null + }); + logger.info('mediainfo:track-fallback:handbrake-scan:command', { + jobId, + inputPath, + cmd: scanConfig.cmd, + args: scanConfig.args + }); + + const runInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_DVD_TRACK_FALLBACK', + cmd: scanConfig.cmd, + args: scanConfig.args, + collectLines: lines, + collectStderrLines: false + }); + + const parsedScan = parseMediainfoJsonOutput(lines.join('\n')); + if (!parsedScan) { + return { + runInfo, + parsedMediaInfo: null, + titleInfo: null + }; + } + + const titleInfo = parseHandBrakeSelectedTitleInfo(parsedScan); + if (!titleInfo) { + return { + runInfo, + parsedMediaInfo: null, + titleInfo: null + }; + } + + return { + runInfo, + parsedMediaInfo: buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo), + titleInfo + }; + } + + async runMediainfoReviewForJob(jobId, rawPath, options = {}) { + this.ensureNotBusy('runMediainfoReviewForJob', jobId); + logger.info('mediainfo:review:start', { jobId, rawPath, options }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + options = await this.resolveMultipartReviewLockOptions(job, options); + + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + mediaProfile: options?.mediaProfile, + makemkvInfo: mkInfo, + rawPath + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); + let reviewPluginExecution = null; + const analyzeContext = mkInfo?.analyzeContext || {}; + let effectiveAnalyzeContext = analyzeContext; + const selectedPlaylistId = normalizePlaylistId( + analyzeContext.selectedPlaylist + || this.snapshot.context?.selectedPlaylist + || null + ); + const playlistAnalysis = analyzeContext.playlistAnalysis + || this.snapshot.context?.playlistAnalysis + || null; + const preferredEncodeTitleId = normalizeNonNegativeInteger(analyzeContext.selectedTitleId); + const rawMedia = collectRawMediaCandidates(rawPath, { + playlistAnalysis, + selectedPlaylistId + }); + const mediaFiles = rawMedia.mediaFiles; + if (mediaFiles.length === 0) { + const error = new Error('Mediainfo-Prüfung nicht möglich: keine Datei im RAW-Pfad gefunden.'); + error.statusCode = 400; + throw error; + } + const isConverterReview = String(options?.mode || '').trim().toLowerCase() === 'converter' + || String(mediaProfile || '').trim().toLowerCase() === 'converter'; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); + let effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + let reviewSettings = isConverterReview + ? { + ...settings, + makemkv_min_length_minutes: 0, + handbrake_preset: '', + handbrake_extra_args: '' + } + : (effectiveIsSeriesDvdReview + ? { + ...settings, + makemkv_min_length_minutes: 0 + } + : settings); + reviewSettings = resolveReviewTrackLanguageSettings(reviewSettings, mediaProfile, effectiveIsSeriesDvdReview); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Mediainfo-Quelle: ${rawMedia.source} (${mediaFiles.length} Datei(en))` + ); + let presetProfile = null; + try { + presetProfile = await settingsService.buildHandBrakePresetProfile(mediaFiles[0].path, { + mediaProfile, + settingsMap: settings + }); + } catch (error) { + logger.warn('mediainfo:review:preset-profile-failed', { + jobId, + error: errorToMeta(error) + }); + presetProfile = { + source: 'fallback', + message: `Preset-Profil konnte nicht geladen werden: ${error.message}` + }; + } + + if (effectiveIsSeriesDvdReview) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Mindestlängen-Filter für Titelprüfung ist deaktiviert.` + ); + } + + if (this.isPrimaryJob(jobId)) { + await this.setState('MEDIAINFO_CHECK', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'Mediainfo-Prüfung läuft', + context: { + jobId, + rawPath, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + selectedMetadata + } + }); + } + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)) + }); + + const mediaInfoByPath = {}; + const mediaInfoRuns = []; + let effectiveMediaFiles = mediaFiles; + + const buildReviewSnapshot = (processedCount) => { + const processedFiles = effectiveMediaFiles + .slice(0, processedCount) + .filter((item) => Boolean(mediaInfoByPath[item.path])); + + if (processedFiles.length === 0) { + return null; + } + + return { + ...buildMediainfoReview({ + mediaFiles: processedFiles, + mediaInfoByPath, + settings: reviewSettings, + presetProfile, + playlistAnalysis, + preferredEncodeTitleId, + selectedPlaylistId, + selectedMakemkvTitleId: preferredEncodeTitleId + }), + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + reviewConfirmed: false, + partial: processedFiles.length < effectiveMediaFiles.length, + processedFiles: processedFiles.length, + totalFiles: effectiveMediaFiles.length + }; + }; + + // For DVD sources, use HandBrake scan directly instead of mediainfo (mediainfo + // is unreliable on VOB files and often returns incomplete track information). + const isDvdSource = rawMedia.source === 'dvd' + || rawMedia.source === 'dvd_image' + || rawMedia.source === 'single_extensionless'; + let dvdHandBrakeScanJson = null; + let dvdHandBrakeScanInputPath = null; + let dvdSelectedTitleInfo = null; // set when HandBrake scan succeeds; used to skip re-selection + let dvdScannedCandidates = null; // set when scan yields multiple MIN_LENGTH candidates needing user selection + let dvdAllTitles = null; // full DVD title list from initial scan + + if (isDvdSource && mediaFiles.length > 0) { + // For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir). + // For image/extensionless: scan the file directly. + dvdHandBrakeScanInputPath = rawMedia.source === 'dvd' + ? path.dirname(path.dirname(mediaFiles[0].path)) + : mediaFiles[0].path; + + await historyService.appendLog( + jobId, + 'SYSTEM', + `DVD-Analyse via HandBrake Scan: ${path.basename(dvdHandBrakeScanInputPath)}` + ); + await this.updateProgress('MEDIAINFO_CHECK', 10, null, 'HandBrake DVD-Scan läuft', jobId); + + const dvdScanLines = []; + let dvdScanRunInfo = null; + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath: dvdHandBrakeScanInputPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_DVD', + silent: !this.isPrimaryJob(jobId) + }); + appendLinesInPlace(dvdScanLines, pluginScan.scanLines); + dvdScanRunInfo = pluginScan.runInfo || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + + dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n')); + if (dvdHandBrakeScanJson) { + const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson); + dvdAllTitles = allDvdTitles; + const seriesScanLines = Array.isArray(pluginScan?.scanAnalysisLines) && pluginScan.scanAnalysisLines.length > 0 + ? pluginScan.scanAnalysisLines + : dvdScanLines; + const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, seriesScanLines); + effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; + const wasSeriesDvdReview = effectiveIsSeriesDvdReview; + effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection( + mediaProfile, + selectedMetadata, + effectiveAnalyzeContext + ); + if (effectiveIsSeriesDvdReview && !wasSeriesDvdReview) { + reviewSettings = { + ...reviewSettings, + makemkv_min_length_minutes: 0 + }; + reviewSettings = resolveReviewTrackLanguageSettings(reviewSettings, mediaProfile, true); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} nachträglich im HandBrake-Scan erkannt: Mindestlängen-Filter für Titelprüfung wurde deaktiviert.` + ); + } + if (seriesScanContextResult.derived) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Titelklassifizierung aus HandBrake-Scan aktualisiert (PlayAll/Kurz/Duplikate markiert).' + ); + } + const minLengthMinutes = Number(reviewSettings?.makemkv_min_length_minutes || 0); + const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); + const filteredDvdCandidatesRaw = minLengthSeconds > 0 + ? allDvdTitles.filter((t) => t.durationSeconds >= minLengthSeconds) + : allDvdTitles; + const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates(filteredDvdCandidatesRaw, effectiveAnalyzeContext); + const filteredDvdCandidates = filteredSeriesResult.candidates; + const minLengthLabel = minLengthSeconds > 0 + ? `≥ ${minLengthMinutes} min` + : 'ohne Mindestlänge'; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD-Scan: ${allDvdTitles.length} Titel gesamt, ${filteredDvdCandidates.length} ${minLengthLabel}.` + ); + if (filteredSeriesResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Filter auf DVD-Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` + ); + } + // For synthetic mediaInfo: prefer the single MIN_LENGTH candidate; otherwise fall back to + // the best available title (longest / main feature) as a placeholder for the review. + const preferredTitleId = filteredDvdCandidates.length === 1 + ? filteredDvdCandidates[0].handBrakeTitleId + : null; + const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson, { + handBrakeTitleId: preferredTitleId || undefined + }); + if (titleInfo) { + const syntheticMediaInfo = buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo); + mediaInfoByPath[dvdHandBrakeScanInputPath] = syntheticMediaInfo; + effectiveMediaFiles = [{ path: dvdHandBrakeScanInputPath, size: 0 }]; + const audioCount = Array.isArray(titleInfo.audioTracks) ? titleInfo.audioTracks.length : 0; + const subtitleCount = Array.isArray(titleInfo.subtitleTracks) ? titleInfo.subtitleTracks.length : 0; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD-Scan: Audio=${audioCount}, Subtitle=${subtitleCount}, Dauer=${formatDurationClock(titleInfo.durationSeconds)}` + ); + if (filteredDvdCandidates.length === 1) { + // Exactly one title passes MIN_LENGTH → auto-select it. + dvdSelectedTitleInfo = titleInfo; + } else if (filteredDvdCandidates.length > 1) { + // Multiple candidates → user must choose; store for downstream selection dialog. + dvdScannedCandidates = filteredDvdCandidates; + } else { + // No title passes MIN_LENGTH filter → auto-select best available (degraded mode). + dvdSelectedTitleInfo = titleInfo; + } + } else { + const error = new Error('HandBrake DVD-Scan: Kein Titel erkannt.'); + error.runInfo = dvdScanRunInfo; + throw error; + } + } else { + const error = new Error('HandBrake DVD-Scan: Ausgabe nicht lesbar.'); + error.runInfo = dvdScanRunInfo; + throw error; + } + + mediaInfoRuns.push({ filePath: dvdHandBrakeScanInputPath, runInfo: dvdScanRunInfo, fallbackRunInfo: null }); + + const partialReview = buildReviewSnapshot(1); + if (this.isPrimaryJob(jobId)) { + await this.setState('MEDIAINFO_CHECK', { + activeJobId: jobId, + progress: 50, + eta: null, + statusText: `HandBrake DVD-Scan abgeschlossen: ${path.basename(dvdHandBrakeScanInputPath)}`, + context: { + jobId, + rawPath, + inputPath: partialReview?.encodeInputPath || null, + hasEncodableTitle: Boolean(partialReview?.encodeInputPath), + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: partialReview, + selectedMetadata, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } + } + + // For non-DVD sources (or when HandBrake scan failed), run mediainfo on each file. + if (!isDvdSource || !dvdHandBrakeScanJson) { + for (let i = 0; i < mediaFiles.length; i += 1) { + const file = mediaFiles[i]; + const percent = Number((((i + 1) / mediaFiles.length) * 100).toFixed(2)); + await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`, jobId); + + const result = await this.runMediainfoForFile(jobId, file.path, { + mediaProfile, + settingsMap: settings + }); + let parsedMediaInfo = result.parsed; + let fallbackRunInfo = null; + if (shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, file.path)) { + try { + const fallback = await this.runDvdTrackFallbackForFile(jobId, file.path, { + mediaProfile, + settingsMap: settings + }); + if (fallback?.parsedMediaInfo) { + parsedMediaInfo = fallback.parsedMediaInfo; + fallbackRunInfo = fallback.runInfo || null; + const audioCount = Array.isArray(fallback?.titleInfo?.audioTracks) + ? fallback.titleInfo.audioTracks.length + : 0; + const subtitleCount = Array.isArray(fallback?.titleInfo?.subtitleTracks) + ? fallback.titleInfo.subtitleTracks.length + : 0; + await historyService.appendLog( + jobId, + 'SYSTEM', + `DVD Track-Fallback aktiv (${path.basename(file.path)}): Audio=${audioCount}, Subtitle=${subtitleCount}.` + ); + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + `DVD Track-Fallback ohne Ergebnis (${path.basename(file.path)}).` + ); + } + } catch (error) { + logger.warn('mediainfo:track-fallback:failed', { + jobId, + inputPath: file.path, + error: errorToMeta(error) + }); + } + } + + mediaInfoByPath[file.path] = parsedMediaInfo; + mediaInfoRuns.push({ + filePath: file.path, + runInfo: result.runInfo, + fallbackRunInfo + }); + + const partialReview = buildReviewSnapshot(i + 1); + if (this.isPrimaryJob(jobId)) { + await this.setState('MEDIAINFO_CHECK', { + activeJobId: jobId, + progress: percent, + eta: null, + statusText: `Mediainfo ${i + 1}/${mediaFiles.length} analysiert: ${path.basename(file.path)}`, + context: { + jobId, + rawPath, + inputPath: partialReview?.encodeInputPath || null, + hasEncodableTitle: Boolean(partialReview?.encodeInputPath), + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: partialReview, + selectedMetadata + } + }); + } + } + } + + const review = buildMediainfoReview({ + mediaFiles: effectiveMediaFiles, + mediaInfoByPath, + settings: reviewSettings, + presetProfile, + playlistAnalysis, + preferredEncodeTitleId, + selectedPlaylistId, + selectedMakemkvTitleId: preferredEncodeTitleId + }); + + let enrichedReview = { + ...review, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + reviewConfirmed: false, + partial: false, + processedFiles: effectiveMediaFiles.length, + totalFiles: effectiveMediaFiles.length + }; + const shouldApplySeriesBackupShortTitleFilter = Boolean( + effectiveIsSeriesDvdReview + && hasBluRayBackupStructure(rawPath) + && (!Array.isArray(playlistAnalysis?.titles) || playlistAnalysis.titles.length === 0) + ); + if (shouldApplySeriesBackupShortTitleFilter) { + const shortTitleFilterResult = filterSeriesBackupReviewTitles(enrichedReview); + if (shortTitleFilterResult.applied) { + enrichedReview = shortTitleFilterResult.plan; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Kurztitel-Filter aktiv (Backup-Fallback): ${shortTitleFilterResult.sourceCount} -> ${shortTitleFilterResult.resultCount}` + + ` Titel (Schwelle ${formatDurationClock(shortTitleFilterResult.shortThresholdSeconds)}).` + ); + } + } + const shouldEnrichSeriesBackupLanguages = Boolean( + effectiveIsSeriesDvdReview + && hasBluRayBackupStructure(rawPath) + && Array.isArray(enrichedReview?.titles) + && enrichedReview.titles.length > 0 + ); + if (shouldEnrichSeriesBackupLanguages) { + const hasUnknownTrackLanguages = (Array.isArray(enrichedReview?.titles) ? enrichedReview.titles : []) + .some((title) => { + const audioUnknown = (Array.isArray(title?.audioTracks) ? title.audioTracks : []) + .some((track) => isUnknownTrackLanguage(track?.language || track?.languageLabel)); + const subtitleUnknown = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []) + .some((track) => isUnknownTrackLanguage(track?.language || track?.languageLabel)); + return audioUnknown || subtitleUnknown; + }); + if (hasUnknownTrackLanguages) { + try { + let languageCandidates = buildSeriesBackupLanguageEnrichmentCandidatesFromPlaylistCache( + effectiveAnalyzeContext?.handBrakePlaylistScan || null + ); + if (languageCandidates.length <= 0) { + const handBrakeScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_SERIES_LANG_MAP', + silent: !this.isPrimaryJob(jobId) + }); + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + handBrakeScan.pluginExecution + ); + const handBrakeScanRunInfo = handBrakeScan.runInfo || null; + mediaInfoRuns.push({ filePath: rawPath, runInfo: handBrakeScanRunInfo, fallbackRunInfo: null }); + const handBrakeScanJson = parseMediainfoJsonOutput((handBrakeScan.scanLines || []).join('\n')); + if (handBrakeScanJson) { + languageCandidates = buildSeriesBackupLanguageEnrichmentCandidatesFromHandBrakeScan(handBrakeScanJson); + } + } + + const languageEnrichment = enrichSeriesBackupReviewLanguagesFromCandidates( + enrichedReview, + languageCandidates + ); + if (languageEnrichment.applied) { + enrichedReview = languageEnrichment.plan; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} Sprachabgleich aus HandBrake-Scan: ` + + `${languageEnrichment.matchedTitles} Titel gemappt, ` + + `Audio ${languageEnrichment.enrichedAudioTracks}, ` + + `Untertitel ${languageEnrichment.enrichedSubtitleTracks} aktualisiert.` + ); + } + } catch (error) { + logger.warn('mediainfo:review:series-backup-language-enrichment-failed', { + jobId, + rawPath, + error: errorToMeta(error) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} Sprachabgleich aus HandBrake-Scan fehlgeschlagen: ${error?.message || 'Unbekannter Fehler'}` + ); + } + } + } + const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( + enrichedReview, + options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null + ); + const multipartLockResult = applyMultipartSettingsLockToReviewPlan( + reviewPrefillResult.plan, + options?.multipartSettingsLock || null + ); + enrichedReview = multipartLockResult.plan; + const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(enrichedReview, { + mediaProfile, + isSeriesSelection: effectiveIsSeriesDvdReview + }); + enrichedReview = reviewDefaultUserPresetResult.plan; + const previousEncodePlan = options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null; + const converterReviewMode = String(options?.mode || '').trim().toLowerCase() === 'converter' + || String(options?.mediaProfile || '').trim().toLowerCase() === 'converter' + || String(previousEncodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter'; + if (converterReviewMode && previousEncodePlan) { + const normalizeConverterItem = (item) => { + if (!item || typeof item !== 'object') return null; + const type = String(item?.type || '').trim().toLowerCase(); + const id = Number(item?.id); + if ((type !== 'script' && type !== 'chain') || !Number.isFinite(id) || id <= 0) { + return null; + } + return { type, id: Math.trunc(id) }; + }; + const preservedPreItems = Array.isArray(previousEncodePlan?.preEncodeItems) + ? previousEncodePlan.preEncodeItems.map(normalizeConverterItem).filter(Boolean) + : []; + const preservedPostItems = Array.isArray(previousEncodePlan?.postEncodeItems) + ? previousEncodePlan.postEncodeItems.map(normalizeConverterItem).filter(Boolean) + : []; + enrichedReview = { + ...enrichedReview, + mode: 'converter', + mediaProfile: 'converter', + converterMediaType: String(previousEncodePlan?.converterMediaType || '').trim().toLowerCase() || 'video', + inputPath: String(previousEncodePlan?.inputPath || job?.raw_path || rawPath || '').trim() || null, + inputPaths: Array.isArray(previousEncodePlan?.inputPaths) ? previousEncodePlan.inputPaths : null, + outputPath: String(previousEncodePlan?.outputPath || '').trim() || null, + outputDir: String(previousEncodePlan?.outputDir || '').trim() || null, + outputFormat: String(previousEncodePlan?.outputFormat || '').trim().toLowerCase() || null, + userPreset: previousEncodePlan?.userPreset || null, + audioFormatOptions: previousEncodePlan?.audioFormatOptions && typeof previousEncodePlan.audioFormatOptions === 'object' + ? previousEncodePlan.audioFormatOptions + : {}, + audioTrackTemplate: String(previousEncodePlan?.audioTrackTemplate || '').trim() || null, + audioFiles: Array.isArray(previousEncodePlan?.audioFiles) ? previousEncodePlan.audioFiles : null, + metadata: previousEncodePlan?.metadata && typeof previousEncodePlan.metadata === 'object' + ? previousEncodePlan.metadata + : null, + tracks: Array.isArray(previousEncodePlan?.tracks) ? previousEncodePlan.tracks : null, + isFolder: Boolean(previousEncodePlan?.isFolder), + isSharedAudio: Boolean(previousEncodePlan?.isSharedAudio), + preEncodeItems: preservedPreItems, + postEncodeItems: preservedPostItems, + preEncodeScriptIds: normalizeScriptIdList(previousEncodePlan?.preEncodeScriptIds || []), + postEncodeScriptIds: normalizeScriptIdList(previousEncodePlan?.postEncodeScriptIds || []), + preEncodeChainIds: normalizeChainIdList(previousEncodePlan?.preEncodeChainIds || []), + postEncodeChainIds: normalizeChainIdList(previousEncodePlan?.postEncodeChainIds || []), + reviewConfirmed: false + }; + } + + // HandBrake title scan for DVD structures (VIDEO_TS directories / ISO images) + const needsHandBrakeTitleScan = rawMedia.source === 'dvd' + || rawMedia.source === 'dvd_image' + || rawMedia.source === 'single_extensionless'; + if (needsHandBrakeTitleScan) { + // Use the same path that was used for the DVD HandBrake scan above, or fall back + // to the existing logic for cases where the scan was not yet performed. + const dvdScanInputPath = dvdHandBrakeScanInputPath + || (rawMedia.source === 'dvd' ? rawPath : mediaFiles[0].path); + const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId); + const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList( + options?.selectedHandBrakeTitleIds + || analyzeContext?.selectedHandBrakeTitleIds + ); + const effectivePreSelectedHandBrakeTitleIds = preSelectedHandBrakeTitleIds.length > 0 + ? preSelectedHandBrakeTitleIds + : (preSelectedHandBrakeTitleId ? [preSelectedHandBrakeTitleId] : []); + if (effectivePreSelectedHandBrakeTitleIds.length > 0) { + const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( + dvdHandBrakeScanJson, + effectivePreSelectedHandBrakeTitleIds, + { encodeInputPath: dvdScanInputPath } + ); + const resolvedSelectedTitleIds = selectedReviewTitles + .map((title) => normalizeReviewTitleId(title?.id)) + .filter(Boolean); + const resolvedPrimaryTitleId = normalizeReviewTitleId(preSelectedHandBrakeTitleId) + || resolvedSelectedTitleIds[0] + || null; + if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { + const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); + const normalizedTitles = selectedReviewTitles.map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = selectedSet.has(Number(titleId)); + return { + ...title, + selectedForEncode, + encodeInput: titleId === resolvedPrimaryTitleId + }; + }); + enrichedReview = { + ...enrichedReview, + titles: normalizedTitles, + selectedTitleIds: resolvedSelectedTitleIds, + encodeInputTitleId: resolvedPrimaryTitleId, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds, + encodeInputPath: dvdScanInputPath, + titleSelectionRequired: false + }; + } else { + enrichedReview = { + ...enrichedReview, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: effectivePreSelectedHandBrakeTitleIds, + encodeInputPath: dvdScanInputPath + }; + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Auswahl (manuell) übernommen: -t ${resolvedPrimaryTitleId || '-'}${effectivePreSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectivePreSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}` + ); + } else if (dvdSelectedTitleInfo) { + // Exactly one title passed the MIN_LENGTH filter (or fallback to best available) — + // auto-select it without requiring user input. + enrichedReview = { + ...enrichedReview, + handBrakeTitleId: dvdSelectedTitleInfo.handBrakeTitleId, + handBrakeTitleIds: [dvdSelectedTitleInfo.handBrakeTitleId], + encodeInputPath: dvdScanInputPath + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD Titel automatisch gewählt (aus Scan): -t ${dvdSelectedTitleInfo.handBrakeTitleId}` + ); + } else if (dvdScannedCandidates && dvdScannedCandidates.length > 0) { + if (effectiveIsSeriesDvdReview) { + const seriesTitleIds = normalizeReviewTitleIdList( + dvdScannedCandidates.map((item) => item?.handBrakeTitleId) + ); + const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( + dvdScannedCandidates, + Array.isArray(dvdAllTitles) && dvdAllTitles.length > 0 ? dvdAllTitles : dvdScannedCandidates + ); + if (seriesPlayAllDecision.required) { + const manualTitleSourceRows = Array.isArray(dvdAllTitles) && dvdAllTitles.length > 0 + ? dvdAllTitles + : dvdScannedCandidates; + const manualCandidateIds = normalizeReviewTitleIdList([ + ...(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) + ? seriesPlayAllDecision.defaultSelectedTitleIds + : []), + seriesPlayAllDecision.longestCandidateTitleId + ]); + const candidatesData = manualCandidateIds + .map((titleId) => { + const matched = manualTitleSourceRows.find((item) => + normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id) === titleId + ) || null; + const durationSeconds = Number(matched?.durationSeconds || 0); + return { + handBrakeTitleId: titleId, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(1)), + audioTrackCount: Number(matched?.audioTrackCount || 0), + subtitleTrackCount: Number(matched?.subtitleTrackCount || 0), + sizeBytes: Number(matched?.sizeBytes || 0) + }; + }) + .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0); + const allTitlesData = manualTitleSourceRows + .map((item) => { + const titleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); + if (!titleId) { + return null; + } + const durationSeconds = Number(item?.durationSeconds || 0); + return { + handBrakeTitleId: titleId, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(1)), + audioTrackCount: Number(item?.audioTrackCount || 0), + subtitleTrackCount: Number(item?.subtitleTrackCount || 0), + sizeBytes: Number(item?.sizeBytes || 0) + }; + }) + .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0) + .sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId); + const decisionSelectableTitleIds = normalizeReviewTitleIdList([ + seriesPlayAllDecision.longestCandidateTitleId + ]); + const preselectedTitleIds = normalizeReviewTitleIdList( + (Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) + ? seriesPlayAllDecision.defaultSelectedTitleIds + : [] + ).filter((titleId) => candidatesData.some((row) => Number(row.handBrakeTitleId) === Number(titleId))) + ); + const effectivePreselectedTitleIds = preselectedTitleIds.length > 0 + ? preselectedTitleIds + : (candidatesData[0]?.handBrakeTitleId ? [candidatesData[0].handBrakeTitleId] : []); + enrichedReview = { + ...enrichedReview, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: { + reason: seriesPlayAllDecision.reason, + longestCandidateTitleId: seriesPlayAllDecision.longestCandidateTitleId, + selectedDurationSumSeconds: seriesPlayAllDecision.selectedDurationSumSeconds, + longestCandidateDurationSeconds: seriesPlayAllDecision.longestCandidateDurationSeconds, + durationDeltaSeconds: seriesPlayAllDecision.durationDeltaSeconds, + toleranceSeconds: seriesPlayAllDecision.toleranceSeconds, + longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio + }, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, + selectedHandBrakeTitleIds: effectivePreselectedTitleIds, + selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, + encodeInputPath: null + }; + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + analyzeContext: effectiveAnalyzeContext + }, mediaProfile)), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), + encode_plan_json: JSON.stringify(enrichedReview), + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Grenzfall erkannt: Längster Titel -t ${seriesPlayAllDecision.longestCandidateTitleId || '-'} ` + + `(${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}) liegt innerhalb der ` + + `PlayAll-Toleranz (Summe Episoden=${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'}, ` + + `Delta=${seriesPlayAllDecision.durationDeltaSeconds || 0}s, Toleranz=${seriesPlayAllDecision.toleranceSeconds || 0}s). ` + + 'Bitte manuell entscheiden: PlayAll oder Doppelfolge.' + ); + const dvdWaitingStatusText = 'Serien-Grenzfall: PlayAll oder Doppelfolge?'; + const dvdWaitingContextPatch = { + jobId, + rawPath, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, + selectedHandBrakeTitleIds: effectivePreselectedTitleIds, + selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + }; + if (this.isPrimaryJob(jobId)) { + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: dvdWaitingStatusText, + context: { + ...dvdWaitingContextPatch, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } else { + await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { + contextPatch: dvdWaitingContextPatch + }); + } + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Serien-Grenzfall', + message: `Job #${jobId}: Bitte entscheiden, ob Titel ${seriesPlayAllDecision.longestCandidateTitleId || '-'} PlayAll oder Doppelfolge ist` + }); + return enrichedReview; + } + const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( + dvdHandBrakeScanJson, + seriesTitleIds, + { encodeInputPath: dvdScanInputPath } + ); + const resolvedSelectedTitleIds = normalizeReviewTitleIdList( + selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) + ); + const resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; + if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { + const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); + const normalizedTitles = selectedReviewTitles.map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = selectedSet.has(Number(titleId)); + return { + ...title, + selectedForEncode, + encodeInput: titleId === resolvedPrimaryTitleId + }; + }); + enrichedReview = { + ...enrichedReview, + titles: normalizedTitles, + selectedTitleIds: resolvedSelectedTitleIds, + encodeInputTitleId: resolvedPrimaryTitleId, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds, + encodeInputPath: dvdScanInputPath, + titleSelectionRequired: false, + handBrakeTitleDecisionRequired: false, + handBrakeTitleCandidates: [] + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)}: ${resolvedSelectedTitleIds.length} Episoden-Titel aus initialem HandBrake-Scan übernommen (ohne erneuten Titelscan).` + ); + } + } else { + // Multiple titles passed the MIN_LENGTH filter during the initial DVD scan — + // present them to the user without running a second HandBrake scan. + const candidatesData = dvdScannedCandidates.map((t) => ({ + handBrakeTitleId: t.handBrakeTitleId, + durationSeconds: t.durationSeconds, + durationMinutes: Number((t.durationSeconds / 60).toFixed(1)), + audioTrackCount: t.audioTrackCount, + subtitleTrackCount: t.subtitleTrackCount, + sizeBytes: t.sizeBytes || 0 + })); + enrichedReview = { + ...enrichedReview, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeDvdInputPath: dvdScanInputPath, + encodeInputPath: null + }; + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + analyzeContext: effectiveAnalyzeContext + }, mediaProfile)), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), + encode_plan_json: JSON.stringify(enrichedReview), + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD Titelauswahl erforderlich: ${candidatesData.length} Kandidaten. Nutzer muss Titel wählen.` + ); + const dvdWaitingStatusText = `DVD Titelauswahl: ${candidatesData.length} Kandidaten gefunden`; + const dvdWaitingContextPatch = { + jobId, + rawPath, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeDvdInputPath: dvdScanInputPath, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + }; + if (this.isPrimaryJob(jobId)) { + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: dvdWaitingStatusText, + context: { + ...dvdWaitingContextPatch, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } else { + await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { + contextPatch: dvdWaitingContextPatch + }); + } + void this.notifyPushover('metadata_ready', { + title: 'Ripster - DVD Titelauswahl', + message: `Job #${jobId}: ${candidatesData.length} DVD-Titel gefunden, manuelle Auswahl erforderlich` + }); + return enrichedReview; + } + } else { + // No pre-scanned result available — run a fresh HandBrake scan for title selection. + let dvdTitleScanJson = null; + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake Titel-Scan für DVD-Struktur wird gestartet...' + ); + await this.updateProgress('MEDIAINFO_CHECK', 100, null, 'HandBrake DVD Titel-Scan läuft', jobId); + const dvdTitleScanLines = []; + let dvdTitleScanRunInfo = null; + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath: dvdScanInputPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_DVD_TITLES', + silent: !this.isPrimaryJob(jobId) + }); + appendLinesInPlace(dvdTitleScanLines, pluginScan.scanLines); + dvdTitleScanRunInfo = pluginScan.runInfo || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n')); + if (dvdTitleScanJson) { + const allTitles = parseHandBrakeTitleList(dvdTitleScanJson); + const seriesScanLines = Array.isArray(pluginScan?.scanAnalysisLines) && pluginScan.scanAnalysisLines.length > 0 + ? pluginScan.scanAnalysisLines + : dvdTitleScanLines; + const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, seriesScanLines); + effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; + const wasSeriesDvdReview = effectiveIsSeriesDvdReview; + effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection( + mediaProfile, + selectedMetadata, + effectiveAnalyzeContext + ); + if (effectiveIsSeriesDvdReview && !wasSeriesDvdReview) { + reviewSettings = { + ...reviewSettings, + makemkv_min_length_minutes: 0 + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} nachträglich im HandBrake-Titelscan erkannt: Mindestlängen-Filter für Titelprüfung wurde deaktiviert.` + ); + } + if (seriesScanContextResult.derived) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Titelklassifizierung aus HandBrake-Titelscan aktualisiert (PlayAll/Kurz/Duplikate markiert).' + ); + } + const minLengthMinutes = Number(reviewSettings?.makemkv_min_length_minutes || 0); + const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); + const titleCandidatesRaw = allTitles.filter((t) => t.durationSeconds >= minLengthSeconds); + const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates(titleCandidatesRaw, effectiveAnalyzeContext); + const titleCandidates = filteredSeriesResult.candidates; + const minLengthLabel = minLengthSeconds > 0 + ? `≥ ${minLengthMinutes} min` + : 'ohne Mindestlänge'; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD Titel-Scan: ${allTitles.length} gesamt, ${titleCandidates.length} ${minLengthLabel}.` + ); + if (filteredSeriesResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Filter auf DVD-Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` + ); + } + if (titleCandidates.length === 1) { + enrichedReview = { + ...enrichedReview, + handBrakeTitleId: titleCandidates[0].handBrakeTitleId, + handBrakeTitleIds: [titleCandidates[0].handBrakeTitleId], + encodeInputPath: dvdScanInputPath + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD Titel automatisch gewählt: -t ${titleCandidates[0].handBrakeTitleId}` + ); + } else if (titleCandidates.length > 1) { + if (effectiveIsSeriesDvdReview) { + const seriesTitleIds = normalizeReviewTitleIdList( + titleCandidates.map((item) => item?.handBrakeTitleId) + ); + const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( + titleCandidates, + allTitles + ); + if (seriesPlayAllDecision.required) { + const manualCandidateIds = normalizeReviewTitleIdList([ + ...(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) + ? seriesPlayAllDecision.defaultSelectedTitleIds + : []), + seriesPlayAllDecision.longestCandidateTitleId + ]); + const candidatesData = manualCandidateIds + .map((titleId) => { + const matched = allTitles.find((item) => + normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id) === titleId + ) || null; + const durationSeconds = Number(matched?.durationSeconds || 0); + return { + handBrakeTitleId: titleId, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(1)), + audioTrackCount: Number(matched?.audioTrackCount || 0), + subtitleTrackCount: Number(matched?.subtitleTrackCount || 0), + sizeBytes: Number(matched?.sizeBytes || 0) + }; + }) + .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0); + const allTitlesData = allTitles + .map((item) => { + const titleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); + if (!titleId) { + return null; + } + const durationSeconds = Number(item?.durationSeconds || 0); + return { + handBrakeTitleId: titleId, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(1)), + audioTrackCount: Number(item?.audioTrackCount || 0), + subtitleTrackCount: Number(item?.subtitleTrackCount || 0), + sizeBytes: Number(item?.sizeBytes || 0) + }; + }) + .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0) + .sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId); + const decisionSelectableTitleIds = normalizeReviewTitleIdList([ + seriesPlayAllDecision.longestCandidateTitleId + ]); + const preselectedTitleIds = normalizeReviewTitleIdList( + (Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) + ? seriesPlayAllDecision.defaultSelectedTitleIds + : [] + ).filter((titleId) => candidatesData.some((row) => Number(row.handBrakeTitleId) === Number(titleId))) + ); + const effectivePreselectedTitleIds = preselectedTitleIds.length > 0 + ? preselectedTitleIds + : (candidatesData[0]?.handBrakeTitleId ? [candidatesData[0].handBrakeTitleId] : []); + enrichedReview = { + ...enrichedReview, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: { + reason: seriesPlayAllDecision.reason, + longestCandidateTitleId: seriesPlayAllDecision.longestCandidateTitleId, + selectedDurationSumSeconds: seriesPlayAllDecision.selectedDurationSumSeconds, + longestCandidateDurationSeconds: seriesPlayAllDecision.longestCandidateDurationSeconds, + durationDeltaSeconds: seriesPlayAllDecision.durationDeltaSeconds, + toleranceSeconds: seriesPlayAllDecision.toleranceSeconds, + longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio + }, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, + selectedHandBrakeTitleIds: effectivePreselectedTitleIds, + selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, + encodeInputPath: null + }; + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + analyzeContext: effectiveAnalyzeContext + }, mediaProfile)), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), + encode_plan_json: JSON.stringify(enrichedReview), + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Grenzfall erkannt: Längster Titel -t ${seriesPlayAllDecision.longestCandidateTitleId || '-'} ` + + `(${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}) liegt innerhalb der ` + + `PlayAll-Toleranz (Summe Episoden=${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'}, ` + + `Delta=${seriesPlayAllDecision.durationDeltaSeconds || 0}s, Toleranz=${seriesPlayAllDecision.toleranceSeconds || 0}s). ` + + 'Bitte manuell entscheiden: PlayAll oder Doppelfolge.' + ); + const dvdWaitingStatusText = 'Serien-Grenzfall: PlayAll oder Doppelfolge?'; + const dvdWaitingContextPatch = { + jobId, + rawPath, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, + selectedHandBrakeTitleIds: effectivePreselectedTitleIds, + selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + }; + if (this.isPrimaryJob(jobId)) { + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: dvdWaitingStatusText, + context: { + ...dvdWaitingContextPatch, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } else { + await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { + contextPatch: dvdWaitingContextPatch + }); + } + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Serien-Grenzfall', + message: `Job #${jobId}: Bitte entscheiden, ob Titel ${seriesPlayAllDecision.longestCandidateTitleId || '-'} PlayAll oder Doppelfolge ist` + }); + return enrichedReview; + } + const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( + dvdTitleScanJson, + seriesTitleIds, + { encodeInputPath: dvdScanInputPath } + ); + const resolvedSelectedTitleIds = normalizeReviewTitleIdList( + selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) + ); + const resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; + if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { + const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); + const normalizedTitles = selectedReviewTitles.map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = selectedSet.has(Number(titleId)); + return { + ...title, + selectedForEncode, + encodeInput: titleId === resolvedPrimaryTitleId + }; + }); + enrichedReview = { + ...enrichedReview, + titles: normalizedTitles, + selectedTitleIds: resolvedSelectedTitleIds, + encodeInputTitleId: resolvedPrimaryTitleId, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds, + encodeInputPath: dvdScanInputPath, + titleSelectionRequired: false, + handBrakeTitleDecisionRequired: false, + handBrakeTitleCandidates: [] + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)}: ${resolvedSelectedTitleIds.length} Episoden-Titel aus HandBrake-Scan übernommen (kein separater Titelauswahl-Schritt).` + ); + } + } else { + const candidatesData = titleCandidates.map((t) => ({ + handBrakeTitleId: t.handBrakeTitleId, + durationSeconds: t.durationSeconds, + durationMinutes: Number((t.durationSeconds / 60).toFixed(1)), + audioTrackCount: t.audioTrackCount, + subtitleTrackCount: t.subtitleTrackCount, + sizeBytes: t.sizeBytes || 0 + })); + enrichedReview = { + ...enrichedReview, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeDvdInputPath: dvdScanInputPath, + encodeInputPath: null + }; + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + analyzeContext: effectiveAnalyzeContext + }, mediaProfile)), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), + encode_plan_json: JSON.stringify(enrichedReview), + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD Titelauswahl erforderlich: ${titleCandidates.length} Kandidaten. Nutzer muss Titel wählen.` + ); + const dvdWaitingStatusText = `DVD Titelauswahl: ${titleCandidates.length} Kandidaten gefunden`; + const dvdWaitingContextPatch = { + jobId, + rawPath, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeDvdInputPath: dvdScanInputPath, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + }; + if (this.isPrimaryJob(jobId)) { + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: dvdWaitingStatusText, + context: { + ...dvdWaitingContextPatch, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } else { + await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { + contextPatch: dvdWaitingContextPatch + }); + } + void this.notifyPushover('metadata_ready', { + title: 'Ripster - DVD Titelauswahl', + message: `Job #${jobId}: ${titleCandidates.length} DVD-Titel gefunden, manuelle Auswahl erforderlich` + }); + return enrichedReview; + } + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + minLengthSeconds > 0 + ? `HandBrake DVD Titel-Scan: Kein Titel ≥ ${minLengthMinutes} min. Ohne Titel-ID fortgefahren.` + : 'HandBrake DVD Titel-Scan: Kein verwertbarer Titel gefunden. Ohne Titel-ID fortgefahren.' + ); + enrichedReview = { ...enrichedReview, encodeInputPath: dvdScanInputPath }; + } + } else { + const error = new Error('HandBrake DVD Titel-Scan: Ausgabe nicht lesbar.'); + error.runInfo = dvdTitleScanRunInfo; + throw error; + } + } + } + + const hasEncodableTitle = Boolean(enrichedReview.encodeInputPath); + const titleSelectionRequired = Boolean(enrichedReview.titleSelectionRequired); + if (!hasEncodableTitle && !titleSelectionRequired && !enrichedReview.handBrakeTitleDecisionRequired) { + enrichedReview.notes = [ + ...(Array.isArray(enrichedReview.notes) ? enrichedReview.notes : []), + 'Kein Titel erfüllt aktuell MIN_LENGTH_MINUTES. Bitte Konfiguration prüfen.' + ]; + } + + const persistedMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + analyzeContext: effectiveAnalyzeContext + }, mediaProfile)), + mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), + encode_plan_json: JSON.stringify(enrichedReview), + encode_input_path: enrichedReview.encodeInputPath || null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Mediainfo-Prüfung abgeschlossen: ${enrichedReview.titles.length} Titel, Input=${enrichedReview.encodeInputPath || (titleSelectionRequired ? 'Titelauswahl erforderlich' : 'kein passender Titel')}` + ); + if (reviewPrefillResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, ` + + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, ` + + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, ` + + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` + ); + } + if (multipartLockResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` + ); + } + + const mediainfoReadyStatusText = titleSelectionRequired + ? 'Mediainfo geprüft - Titelauswahl per Checkbox erforderlich' + : (hasEncodableTitle + ? 'Mediainfo geprüft - Encode manuell starten' + : 'Mediainfo geprüft - kein Titel erfüllt MIN_LENGTH_MINUTES'); + const mediainfoReadyContextPatch = { + jobId, + rawPath, + inputPath: enrichedReview.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + }; + if (this.isPrimaryJob(jobId)) { + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: mediainfoReadyStatusText, + context: { + ...mediainfoReadyContextPatch, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } else { + await this.updateProgress('READY_TO_ENCODE', 0, null, mediainfoReadyStatusText, jobId, { + contextPatch: mediainfoReadyContextPatch + }); + } + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Mediainfo geprüft', + message: `Job #${jobId}: bereit zum manuellen Encode-Start` + }); + + return enrichedReview; + } + + async runEncodeChains(jobId, chainIds, context = {}, phase = 'post', progressTracker = null) { + const ids = Array.isArray(chainIds) ? chainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; + if (ids.length === 0) { + return { configured: 0, succeeded: 0, failed: 0, results: [] }; + } + const results = []; + let succeeded = 0; + let failed = 0; + for (let index = 0; index < ids.length; index += 1) { + const chainId = ids[index]; + const chainLabel = `#${chainId}`; + if (progressTracker?.onStepStart) { + await progressTracker.onStepStart(phase, 'chain', index + 1, ids.length, chainLabel); + } + await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette startet (ID ${chainId})...`); + try { + const chainResult = await scriptChainService.executeChain(chainId, { + ...context, + jobId, + source: phase === 'pre' ? 'pre_encode_chain' : 'post_encode_chain' + }, { + appendLog: (src, msg) => historyService.appendLog(jobId, src, msg) + }); + if (chainResult.aborted || chainResult.failed > 0) { + failed += 1; + await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" fehlgeschlagen.`); + } else { + succeeded += 1; + await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" erfolgreich.`); + } + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete( + phase, + 'chain', + index + 1, + ids.length, + chainResult.chainName || chainLabel, + !(chainResult.aborted || chainResult.failed > 0) + ); + } + results.push({ chainId, ...chainResult }); + } catch (error) { + failed += 1; + results.push({ chainId, success: false, error: error.message }); + await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette ${chainId} Fehler: ${error.message}`); + logger.warn(`encode:${phase}-chain:failed`, { jobId, chainId, error: errorToMeta(error) }); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete(phase, 'chain', index + 1, ids.length, chainLabel, false); + } + } + } + return { configured: ids.length, succeeded, failed, results }; + } + + async runPreEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) { + const scriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []); + const chainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []; + const executionStage = String(context?.pipelineStage || 'ENCODING').trim().toUpperCase() || 'ENCODING'; + if (scriptIds.length === 0 && chainIds.length === 0) { + return { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; + } + + const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false }); + const scriptById = new Map(scripts.map((item) => [Number(item.id), item])); + const results = []; + let succeeded = 0; + let failed = 0; + let skipped = 0; + let aborted = false; + + for (let index = 0; index < scriptIds.length; index += 1) { + const scriptId = scriptIds[index]; + const script = scriptById.get(Number(scriptId)); + const scriptLabel = script?.name || `#${scriptId}`; + if (progressTracker?.onStepStart) { + await progressTracker.onStepStart('pre', 'script', index + 1, scriptIds.length, scriptLabel); + } + if (!script) { + failed += 1; + aborted = true; + results.push({ scriptId, scriptName: null, status: 'ERROR', error: 'missing' }); + await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript #${scriptId} nicht gefunden. Kette abgebrochen.`); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, scriptLabel, false); + } + break; + } + await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`); + const activityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'pre_encode', + scriptId: script.id, + jobId, + currentStep: `Pre-Encode ${index + 1}/${scriptIds.length}` + }); + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { + source: 'pre_encode', + mode: context?.mode || null, + jobId, + jobTitle: context?.jobTitle || null, + inputPath: context?.inputPath || null, + outputPath: context?.outputPath || null, + rawPath: context?.rawPath || null + }); + const runInfo = await this.runCommand({ + jobId, + stage: executionStage, + source: 'PRE_ENCODE_SCRIPT', + cmd: prepared.cmd, + args: prepared.args, + argsForLog: prepared.argsForLog, + onStdoutLine: (line) => { + runtimeActivityService.appendActivityOutput(activityId, { stdout: line }); + }, + onStderrLine: (line) => { + runtimeActivityService.appendActivityOutput(activityId, { stderr: line }); + } + }); + succeeded += 1; + results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo }); + const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; + runtimeActivityService.completeActivity(activityId, { + status: 'success', + success: true, + outcome: 'success', + exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null, + message: 'Pre-Encode Skript erfolgreich', + output: runOutput || null, + stdout: runInfo?.stdoutTail || null, + stderr: runInfo?.stderrTail || null, + stdoutTruncated: Boolean(runInfo?.stdoutTruncated), + stderrTruncated: Boolean(runInfo?.stderrTruncated) + }); + await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript erfolgreich: ${script.name}`); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, true); + } + } catch (error) { + const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null; + const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; + const runStatus = String(runInfo?.status || '').trim().toUpperCase(); + const cancelled = runStatus === 'CANCELLED'; + runtimeActivityService.completeActivity(activityId, { + status: 'error', + success: false, + outcome: cancelled ? 'cancelled' : 'error', + cancelled, + message: error?.message || 'Pre-Encode Skriptfehler', + errorMessage: error?.message || 'Pre-Encode Skriptfehler', + output: runOutput || null, + stdout: runInfo?.stdoutTail || null, + stderr: runInfo?.stderrTail || null, + stdoutTruncated: Boolean(runInfo?.stdoutTruncated), + stderrTruncated: Boolean(runInfo?.stderrTruncated) + }); + failed += 1; + aborted = true; + results.push({ scriptId: script.id, scriptName: script.name, status: 'ERROR', error: error?.message || 'unknown' }); + await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript fehlgeschlagen: ${script.name} (${error?.message || 'unknown'})`); + logger.warn('encode:pre-script:failed', { jobId, scriptId: script.id, error: errorToMeta(error) }); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, false); + } + break; + } finally { + if (prepared?.cleanup) { + await prepared.cleanup(); + } + } + } + + if (!aborted && chainIds.length > 0) { + const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'pre', progressTracker); + if (chainResult.failed > 0) { + aborted = true; + failed += chainResult.failed; + } + succeeded += chainResult.succeeded; + results.push(...chainResult.results); + } + + if (aborted) { + const pendingScripts = scriptIds.slice(results.filter((r) => r.scriptId != null).length); + for (const pendingId of pendingScripts) { + const s = scriptById.get(Number(pendingId)); + skipped += 1; + results.push({ scriptId: Number(pendingId), scriptName: s?.name || null, status: 'SKIPPED_ABORTED' }); + } + throw Object.assign(new Error('Pre-Encode Skripte fehlgeschlagen - Encode wird nicht gestartet.'), { statusCode: 500, preEncodeFailed: true }); + } + + return { + configured: scriptIds.length + chainIds.length, + attempted: scriptIds.length - skipped + chainIds.length, + succeeded, + failed, + skipped, + aborted, + results + }; + } + + async runPostEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) { + const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); + const chainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []; + const executionStage = String(context?.pipelineStage || 'ENCODING').trim().toUpperCase() || 'ENCODING'; + if (scriptIds.length === 0 && chainIds.length === 0) { + return { + configured: 0, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + results: [] + }; + } + + const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false }); + const scriptById = new Map(scripts.map((item) => [Number(item.id), item])); + const results = []; + let succeeded = 0; + let failed = 0; + let skipped = 0; + let aborted = false; + let abortReason = null; + let failedScriptName = null; + let failedScriptId = null; + const titleForPush = context?.jobTitle || `Job #${jobId}`; + + for (let index = 0; index < scriptIds.length; index += 1) { + const scriptId = scriptIds[index]; + const script = scriptById.get(Number(scriptId)); + const scriptLabel = script?.name || `#${scriptId}`; + if (progressTracker?.onStepStart) { + await progressTracker.onStepStart('post', 'script', index + 1, scriptIds.length, scriptLabel); + } + if (!script) { + failed += 1; + aborted = true; + failedScriptId = Number(scriptId); + failedScriptName = `Script #${scriptId}`; + abortReason = `Post-Encode Skript #${scriptId} wurde nicht gefunden (${index + 1}/${scriptIds.length}).`; + await historyService.appendLog(jobId, 'SYSTEM', abortReason); + results.push({ + scriptId, + scriptName: null, + status: 'ERROR', + error: 'missing' + }); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, scriptLabel, false); + } + break; + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}` + ); + const activityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'post_encode', + scriptId: script.id, + jobId, + currentStep: `Post-Encode ${index + 1}/${scriptIds.length}` + }); + + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { + source: 'post_encode', + mode: context?.mode || null, + jobId, + jobTitle: context?.jobTitle || null, + inputPath: context?.inputPath || null, + outputPath: context?.outputPath || null, + rawPath: context?.rawPath || null + }); + const runInfo = await this.runCommand({ + jobId, + stage: executionStage, + source: 'POST_ENCODE_SCRIPT', + cmd: prepared.cmd, + args: prepared.args, + argsForLog: prepared.argsForLog, + onStdoutLine: (line) => { + runtimeActivityService.appendActivityOutput(activityId, { stdout: line }); + }, + onStderrLine: (line) => { + runtimeActivityService.appendActivityOutput(activityId, { stderr: line }); + } + }); + + succeeded += 1; + results.push({ + scriptId: script.id, + scriptName: script.name, + status: 'SUCCESS', + runInfo + }); + const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; + runtimeActivityService.completeActivity(activityId, { + status: 'success', + success: true, + outcome: 'success', + exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null, + message: 'Post-Encode Skript erfolgreich', + output: runOutput || null, + stdout: runInfo?.stdoutTail || null, + stderr: runInfo?.stderrTail || null, + stdoutTruncated: Boolean(runInfo?.stdoutTruncated), + stderrTruncated: Boolean(runInfo?.stderrTruncated) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skript erfolgreich: ${script.name}` + ); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, true); + } + } catch (error) { + const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null; + const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; + const runStatus = String(runInfo?.status || '').trim().toUpperCase(); + const cancelled = runStatus === 'CANCELLED'; + runtimeActivityService.completeActivity(activityId, { + status: 'error', + success: false, + outcome: cancelled ? 'cancelled' : 'error', + cancelled, + message: error?.message || 'Post-Encode Skriptfehler', + errorMessage: error?.message || 'Post-Encode Skriptfehler', + output: runOutput || null, + stdout: runInfo?.stdoutTail || null, + stderr: runInfo?.stderrTail || null, + stdoutTruncated: Boolean(runInfo?.stdoutTruncated), + stderrTruncated: Boolean(runInfo?.stderrTruncated) + }); + failed += 1; + aborted = true; + failedScriptId = Number(script.id); + failedScriptName = script.name; + abortReason = error?.message || 'unknown'; + results.push({ + scriptId: script.id, + scriptName: script.name, + status: 'ERROR', + error: abortReason + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skript fehlgeschlagen: ${script.name} (${abortReason})` + ); + logger.warn('encode:post-script:failed', { + jobId, + scriptId: script.id, + scriptName: script.name, + error: errorToMeta(error) + }); + if (progressTracker?.onStepComplete) { + await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, false); + } + break; + } finally { + if (prepared?.cleanup) { + await prepared.cleanup(); + } + } + } + + if (aborted) { + const executedScriptIds = new Set(results.map((item) => Number(item?.scriptId))); + for (const pendingScriptId of scriptIds) { + const numericId = Number(pendingScriptId); + if (executedScriptIds.has(numericId)) { + continue; + } + const pendingScript = scriptById.get(numericId); + skipped += 1; + results.push({ + scriptId: numericId, + scriptName: pendingScript?.name || null, + status: 'SKIPPED_ABORTED' + }); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skriptkette abgebrochen nach Fehler in ${failedScriptName || `Script #${failedScriptId || 'unknown'}`}.` + ); + void this.notifyPushover('job_error', { + title: 'Ripster - Post-Encode Skriptfehler', + message: `${titleForPush}: ${failedScriptName || `Script #${failedScriptId || 'unknown'}`} fehlgeschlagen (${abortReason || 'unknown'}). Skriptkette abgebrochen.` + }); + } + + if (!aborted && chainIds.length > 0) { + const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'post', progressTracker); + if (chainResult.failed > 0) { + aborted = true; + failed += chainResult.failed; + abortReason = `Post-Encode Kette fehlgeschlagen`; + void this.notifyPushover('job_error', { + title: 'Ripster - Post-Encode Kettenfehler', + message: `${context?.jobTitle || `Job #${jobId}`}: Eine Post-Encode Kette ist fehlgeschlagen.` + }); + } + succeeded += chainResult.succeeded; + results.push(...chainResult.results); + } + + return { + configured: scriptIds.length + chainIds.length, + attempted: scriptIds.length - skipped + chainIds.length, + succeeded, + failed, + skipped, + aborted, + abortReason, + failedScriptId, + failedScriptName, + results + }; + } + + buildPostEncodeScriptsSummaryPlaceholder(encodePlan) { + const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); + const chainIds = normalizeChainIdList(encodePlan?.postEncodeChainIds || []); + return { + configured: scriptIds.length + chainIds.length, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + aborted: false, + abortReason: null, + failedScriptId: null, + failedScriptName: null, + pending: scriptIds.length + chainIds.length > 0, + results: [] + }; + } + + async persistPostEncodeScriptsSummary(jobId, summary) { + try { + const job = await historyService.getJobById(jobId); + if (!job) { + return; + } + const handbrakeInfo = this.safeParseJson(job.handbrake_info_json); + const nextInfo = handbrakeInfo && typeof handbrakeInfo === 'object' + ? handbrakeInfo + : {}; + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify({ + ...nextInfo, + postEncodeScripts: { + ...(summary && typeof summary === 'object' ? summary : {}), + pending: false + } + }) + }); + } catch (error) { + logger.warn('encode:post-script:persist-summary-failed', { jobId, error: errorToMeta(error) }); + } + } + + runPostEncodeScriptsDetached(jobId, encodePlan, context = {}, options = {}) { + const placeholderSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan); + if (placeholderSummary.configured <= 0) { + return { started: false, summary: placeholderSummary }; + } + + const phaseLabel = String(options?.phaseLabel || 'Post-Encode').trim() || 'Post-Encode'; + void historyService.appendLog( + jobId, + 'SYSTEM', + `${phaseLabel} Skripte/Ketten werden unabhängig im Hintergrund ausgeführt.` + ); + + void (async () => { + let finalSummary = { + ...placeholderSummary, + pending: false + }; + try { + finalSummary = { + ...(await this.runPostEncodeScripts(jobId, encodePlan, context, null)), + pending: false + }; + } catch (error) { + logger.warn('encode:post-script:detached-failed', { jobId, error: errorToMeta(error) }); + finalSummary = { + ...placeholderSummary, + attempted: placeholderSummary.configured, + failed: placeholderSummary.configured, + aborted: true, + abortReason: error?.message || 'unknown', + pending: false + }; + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `${phaseLabel} Skripte/Ketten abgeschlossen: ${finalSummary.succeeded} erfolgreich, ` + + `${finalSummary.failed} fehlgeschlagen, ${finalSummary.skipped} übersprungen.` + ); + await this.persistPostEncodeScriptsSummary(jobId, finalSummary); + })(); + + return { + started: true, + summary: placeholderSummary + }; + } + + async startEncodingFromPrepared(jobId, options = {}) { + this.ensureNotBusy('startEncodingFromPrepared', jobId); + logger.info('encode:start-from-prepared', { jobId }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const encodePlanOverride = options?.encodePlanOverride && typeof options.encodePlanOverride === 'object' + ? options.encodePlanOverride + : null; + const encodePlan = encodePlanOverride || this.safeParseJson(job.encode_plan_json); + const seriesBatchEpisodeContext = options?.seriesBatchEpisodeContext && typeof options.seriesBatchEpisodeContext === 'object' + ? options.seriesBatchEpisodeContext + : null; + const isSeriesBatchEpisodeRun = Boolean(seriesBatchEpisodeContext); + const seriesBatchParentJobId = this.resolveSeriesBatchParentJobId({ + ...job, + encodePlan + }); + if (seriesBatchParentJobId && !isSeriesBatchEpisodeRun) { + this.seriesBatchParentByChild.set(Number(jobId), Number(seriesBatchParentJobId)); + } + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + rawPath: job.raw_path + }); + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const encodePlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'encode').catch(() => null); + let encodePluginExecution = null; + const resolvedRawPath = this.resolveCurrentRawPathForSettings(settings, mediaProfile, job.raw_path); + const activeRawPath = resolvedRawPath || String(job.raw_path || '').trim() || null; + if (activeRawPath && normalizeComparablePath(activeRawPath) !== normalizeComparablePath(job.raw_path)) { + await historyService.updateJob(jobId, { raw_path: activeRawPath }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Pfad für Encode-Start aktualisiert: ${job.raw_path} -> ${activeRawPath}` + ); + } + const movieDir = settings.movie_dir; + ensureDir(movieDir); + const mode = encodePlan?.mode || this.snapshot.context?.mode || 'rip'; + let inputPath = isSeriesBatchEpisodeRun + ? (encodePlan?.encodeInputPath || job.encode_input_path || this.snapshot.context?.inputPath || null) + : (job.encode_input_path || encodePlan?.encodeInputPath || this.snapshot.context?.inputPath || null); + let playlistDecision = null; + const resolveInputFromRaw = (rawPathCandidate) => { + if (!rawPathCandidate) { + return null; + } + if (hasBluRayBackupStructure(rawPathCandidate)) { + return rawPathCandidate; + } + if (!playlistDecision) { + playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); + } + return findPreferredRawInput(rawPathCandidate, { + playlistAnalysis: playlistDecision.playlistAnalysis, + selectedPlaylistId: playlistDecision.selectedPlaylist + })?.path || null; + }; + + if (inputPath && !fs.existsSync(inputPath)) { + const recoveredInputPath = resolveInputFromRaw(activeRawPath); + if (recoveredInputPath && fs.existsSync(recoveredInputPath)) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Encode-Input wurde auf aktuellen RAW-Pfad korrigiert: ${inputPath} -> ${recoveredInputPath}` + ); + inputPath = recoveredInputPath; + } + } + + if (!inputPath) { + inputPath = resolveInputFromRaw(activeRawPath); + } + + if (!inputPath) { + const error = new Error('Encode-Start nicht möglich: kein Input-Pfad vorhanden.'); + error.statusCode = 400; + throw error; + } + + if (!fs.existsSync(inputPath)) { + const error = new Error(`Encode-Start nicht möglich: Input-Datei fehlt (${inputPath}).`); + error.statusCode = 400; + throw error; + } + + const outputPathJobView = encodePlanOverride + ? { + ...job, + encode_plan_json: JSON.stringify(encodePlanOverride) + } + : job; + const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, outputPathJobView, jobId); + let preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, outputPathJobView, jobId); + if (this.isMultipartMovieDiscChildHistoryJob(outputPathJobView)) { + const containerJobId = resolveMultipartContainerJobIdFromJob(outputPathJobView); + preferredFinalOutputPath = buildIncompleteMergeOutputPathFromJob( + settings, + outputPathJobView, + preferredFinalOutputPath, + jobId, + { + containerJobId + } + ); + } + ensureDir(path.dirname(incompleteOutputPath)); + const liveJobContext = this.jobProgress.get(Number(jobId))?.context; + const existingSeriesBatchContext = isSeriesBatchEpisodeRun + ? ( + (liveJobContext?.seriesBatch && typeof liveJobContext.seriesBatch === 'object' + ? liveJobContext.seriesBatch + : null) + || (this.snapshot.context?.seriesBatch && typeof this.snapshot.context.seriesBatch === 'object' + ? this.snapshot.context.seriesBatch + : null) + ) + : null; + + await this.setState('ENCODING', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: isSeriesBatchEpisodeRun + ? `Serien-Episode läuft: ${seriesBatchEpisodeContext?.episodeLabel || `Episode ${seriesBatchEpisodeContext?.episodeIndex || '?'}`}` + : (mode === 'reencode' ? 'Re-Encoding mit HandBrake' : 'Encoding mit HandBrake'), + context: { + jobId, + mode, + inputPath, + outputPath: incompleteOutputPath, + reviewConfirmed: true, + mediaProfile, + mediaInfoReview: encodePlan || null, + selectedMetadata: { + title: job.title || job.detected_title || null, + year: job.year || null, + imdbId: job.imdb_id || null, + poster: job.poster_url || null + }, + ...(existingSeriesBatchContext ? { seriesBatch: existingSeriesBatchContext } : {}) + } + }); + + await historyService.updateJob(jobId, { + status: 'ENCODING', + last_state: 'ENCODING', + output_path: incompleteOutputPath, + encode_input_path: inputPath, + ...(activeRawPath ? { raw_path: activeRawPath } : {}) + }); + if (seriesBatchParentJobId) { + await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { + logger.warn('series-batch:parent-progress:update-on-child-start-failed', { + parentJobId: seriesBatchParentJobId, + childJobId: jobId, + error: errorToMeta(error) + }); + }); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `${isSeriesBatchEpisodeRun ? `[Episode ${seriesBatchEpisodeContext?.episodeIndex || '?'}] ` : ''}Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).` + ); + + if (!isSeriesBatchEpisodeRun && mode === 'reencode') { + void this.notifyPushover('reencode_started', { + title: 'Ripster - Re-Encode gestartet', + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` + }); + } else if (!isSeriesBatchEpisodeRun) { + void this.notifyPushover('encoding_started', { + title: 'Ripster - Encoding gestartet', + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` + }); + } + + const preScriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []); + const preChainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []; + const postScriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); + const postChainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []; + const normalizedPreChainIds = Array.isArray(preChainIds) + ? preChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) + : []; + const normalizedPostChainIds = Array.isArray(postChainIds) + ? postChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) + : []; + const preAutomationCount = preScriptIds.length + normalizedPreChainIds.length; + const postAutomationCount = postScriptIds.length + normalizedPostChainIds.length; + const encodeScriptProgressTracker = createEncodeScriptProgressTracker({ + jobId, + preSteps: 0, + postSteps: 0, + updateProgress: this.updateProgress.bind(this) + }); + const preEncodeScriptsSummary = { + configured: preAutomationCount, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + queued: preAutomationCount > 0, + detachedQueue: preAutomationCount > 0, + results: [] + }; + if (preAutomationCount > 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Pre-Encode Automationen laufen als eigene Queue-Einträge (Pre=${preAutomationCount}).` + ); + } + + try { + const trackSelection = extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath); + if (Array.isArray(trackSelection?.subtitleSelectionValidationErrors) && trackSelection.subtitleSelectionValidationErrors.length > 0) { + const error = new Error( + `Subtitle-Auswahl ungültig: ${trackSelection.subtitleSelectionValidationErrors.join(' | ')}` + ); + error.statusCode = 400; + throw error; + } + const primaryPlanTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); + const primaryPlanTitle = Array.isArray(encodePlan?.titles) + ? ( + encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === primaryPlanTitleId) + || encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) + || null + ) + : null; + let handBrakeTitleId = null; + let directoryInput = false; + try { + if (fs.existsSync(inputPath) && fs.statSync(inputPath).isDirectory()) { + directoryInput = true; + } + } catch (_error) { + directoryInput = false; + handBrakeTitleId = null; + } + if (directoryInput) { + const reviewMappedTitleId = normalizeReviewTitleId( + primaryPlanTitle?.handBrakeTitleId + ?? primaryPlanTitle?.titleIndex + ?? encodePlan?.handBrakeTitleId + ?? null + ); + if (reviewMappedTitleId) { + handBrakeTitleId = reviewMappedTitleId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Mapping aus Vorbereitung übernommen: -t ${handBrakeTitleId}` + ); + } + if (!handBrakeTitleId && isSeriesBatchEpisodeRun) { + const seriesEpisodeFallbackTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); + if (seriesEpisodeFallbackTitleId) { + handBrakeTitleId = seriesEpisodeFallbackTitleId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Episode nutzt Titel-ID aus Encode-Plan: -t ${handBrakeTitleId}` + ); + } + } + const selectedPlaylistId = normalizePlaylistId( + encodePlan?.selectedPlaylistId + || (Array.isArray(encodePlan?.titles) + ? (encodePlan.titles.find((title) => Boolean(title?.selectedForEncode))?.playlistId || null) + : null) + || this.snapshot.context?.selectedPlaylist + || null + ); + const selectedEncodeTitle = Array.isArray(encodePlan?.titles) + ? ( + encodePlan.titles.find((title) => + Boolean(title?.selectedForEncode) && normalizePlaylistId(title?.playlistId) === selectedPlaylistId + ) + || encodePlan.titles.find((title) => Boolean(title?.selectedForEncode)) + || null + ) + : null; + const expectedMakemkvTitleIdForResolve = normalizeNonNegativeInteger( + selectedEncodeTitle?.makemkvTitleId + ?? encodePlan?.playlistRecommendation?.makemkvTitleId + ?? this.snapshot.context?.selectedTitleId + ?? null + ); + const expectedDurationSecondsForResolve = Number(selectedEncodeTitle?.durationSeconds || 0) || null; + const expectedSizeBytesForResolve = Number(selectedEncodeTitle?.sizeBytes || 0) || null; + const encodePlanPlaylistAnalysis = encodePlan?.playlistAnalysis && typeof encodePlan.playlistAnalysis === 'object' + ? encodePlan.playlistAnalysis + : null; + const selectedPlaylistAliasesForResolve = selectedPlaylistId + ? normalizePlaylistAliasIds( + [ + ...(Array.isArray(selectedEncodeTitle?.playlistAliases) ? selectedEncodeTitle.playlistAliases : []), + ...getPlaylistAliasesForPlaylist( + encodePlanPlaylistAnalysis + || playlistDecision?.playlistAnalysis + || this.snapshot.context?.playlistAnalysis + || this.safeParseJson(job?.makemkv_info_json)?.analyzeContext?.playlistAnalysis + || null, + selectedPlaylistId + ) + ], + selectedPlaylistId + ) + : []; + if (!handBrakeTitleId && selectedPlaylistId) { + const titleResolveScanLines = []; + const titleResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { + mediaProfile, + settingsMap: settings + }); + logger.info('encoding:title-resolve-scan:command', { + jobId, + cmd: titleResolveScanConfig.cmd, + args: titleResolveScanConfig.args, + sourceArg: titleResolveScanConfig.sourceArg, + selectedPlaylistId + }); + const titleResolveRunInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'HANDBRAKE_SCAN_TITLE_RESOLVE', + cmd: titleResolveScanConfig.cmd, + args: titleResolveScanConfig.args, + collectLines: titleResolveScanLines, + collectStderrLines: false + }); + const titleResolveParsed = parseMediainfoJsonOutput(titleResolveScanLines.join('\n')); + if (!titleResolveParsed) { + const error = new Error('HandBrake Scan-Ausgabe für Titel-Mapping konnte nicht als JSON gelesen werden.'); + error.runInfo = titleResolveRunInfo; + throw error; + } + handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(titleResolveParsed, selectedPlaylistId, { + expectedMakemkvTitleId: expectedMakemkvTitleIdForResolve, + expectedDurationSeconds: expectedDurationSecondsForResolve, + expectedSizeBytes: expectedSizeBytesForResolve, + playlistAliases: selectedPlaylistAliasesForResolve + }); + if (!handBrakeTitleId) { + const knownPlaylists = listAvailableHandBrakePlaylists(titleResolveParsed); + const error = new Error( + `Kein HandBrake-Titel für Playlist ${selectedPlaylistId}.mpls gefunden.` + + ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}` + ); + error.statusCode = 400; + throw error; + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Mapping: ${selectedPlaylistId}.mpls -> -t ${handBrakeTitleId}` + ); + } else if (!handBrakeTitleId) { + const dvdResolveScanLines = []; + const dvdResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { + mediaProfile, + settingsMap: settings + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake Titel-Scan für Verzeichnis ohne Playlist wird gestartet...' + ); + logger.info('encoding:dvd-title-resolve-scan:command', { + jobId, + cmd: dvdResolveScanConfig.cmd, + args: dvdResolveScanConfig.args + }); + const dvdResolveRunInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'HANDBRAKE_SCAN_TITLE_RESOLVE', + cmd: dvdResolveScanConfig.cmd, + args: dvdResolveScanConfig.args, + collectLines: dvdResolveScanLines, + collectStderrLines: false + }); + const dvdResolveParsed = parseMediainfoJsonOutput(dvdResolveScanLines.join('\n')); + if (dvdResolveParsed) { + const dvdTitleInfo = parseHandBrakeSelectedTitleInfo(dvdResolveParsed, { + handBrakeTitleId: null, + playlistId: null + }); + if (dvdTitleInfo?.handBrakeTitleId) { + handBrakeTitleId = dvdTitleInfo.handBrakeTitleId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Scan: längster Titel gefunden -> -t ${handBrakeTitleId}` + ); + } + } else { + logger.warn('encoding:dvd-title-resolve-scan:parse-failed', { + jobId, + runInfo: dvdResolveRunInfo + }); + } + } + } + if (!handBrakeTitleId) { + const primaryTitleMappedHandBrakeId = normalizeReviewTitleId( + primaryPlanTitle?.handBrakeTitleId + ?? primaryPlanTitle?.titleIndex + ?? null + ); + if (primaryTitleMappedHandBrakeId) { + handBrakeTitleId = primaryTitleMappedHandBrakeId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-ID aus Primär-Titel übernommen: -t ${handBrakeTitleId}` + ); + } + } + if (!handBrakeTitleId) { + const fallbackTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId); + if (fallbackTitleId) { + handBrakeTitleId = fallbackTitleId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-ID aus Encode-Plan übernommen: -t ${handBrakeTitleId}` + ); + } + } + const effectiveEncodePlan = { + ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}), + trackSelection: trackSelection || null, + handBrakeTitleId: handBrakeTitleId || null + }; + if (trackSelection) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Track-Override: audio=${trackSelection.audioTrackIds.length > 0 ? trackSelection.audioTrackIds.join(',') : 'none'}, subtitles=${trackSelection.subtitleTrackIds.length > 0 ? trackSelection.subtitleTrackIds.join(',') : 'none'}, subtitle-burned=${trackSelection.subtitleBurnTrackId ?? 'none'}, subtitle-default=${trackSelection.subtitleDefaultTrackId ?? 'none'}, subtitle-forced-index=${Array.isArray(trackSelection.subtitleForcedTrackIndexes) && trackSelection.subtitleForcedTrackIndexes.length > 0 ? trackSelection.subtitleForcedTrackIndexes.join(',') : 'none'}` + ); + } + if (handBrakeTitleId) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Selektion aktiv: -t ${handBrakeTitleId}` + ); + } + const buildSeriesBatchLiveContextPatch = (episodeProgressPercent, episodeEta = null) => { + if (!isSeriesBatchEpisodeRun) { + return null; + } + const liveContext = this.jobProgress.get(Number(jobId))?.context; + const snapshotContext = Number(this.snapshot?.activeJobId) === Number(jobId) + ? this.snapshot?.context + : null; + const currentSeriesBatch = ( + liveContext?.seriesBatch && typeof liveContext.seriesBatch === 'object' + ? liveContext.seriesBatch + : null + ) || ( + snapshotContext?.seriesBatch && typeof snapshotContext.seriesBatch === 'object' + ? snapshotContext.seriesBatch + : null + ); + if (!currentSeriesBatch) { + return null; + } + + const rawChildren = Array.isArray(currentSeriesBatch.children) ? currentSeriesBatch.children : []; + if (rawChildren.length === 0) { + return null; + } + + const normalizedEpisodeIndex = normalizePositiveInteger(seriesBatchEpisodeContext?.episodeIndex); + const normalizedEpisodeTitleId = normalizeReviewTitleId( + seriesBatchEpisodeContext?.titleId + ?? encodePlan?.encodeInputTitleId + ?? null + ); + + let targetChildIndex = rawChildren.findIndex((child) => ( + (normalizedEpisodeIndex && normalizePositiveInteger(child?.episodeIndex) === normalizedEpisodeIndex) + || (normalizedEpisodeTitleId && normalizeReviewTitleId(child?.titleId) === normalizedEpisodeTitleId) + )); + if (targetChildIndex < 0 && normalizedEpisodeIndex && normalizedEpisodeIndex <= rawChildren.length) { + targetChildIndex = normalizedEpisodeIndex - 1; + } + if (targetChildIndex < 0) { + targetChildIndex = rawChildren.findIndex((child) => normalizeSeriesEpisodeStatus(child?.status, 'QUEUED') === 'RUNNING'); + } + if (targetChildIndex < 0) { + targetChildIndex = 0; + } + + const clampedEpisodeProgress = Number.isFinite(Number(episodeProgressPercent)) + ? Math.max(0, Math.min(100, Number(episodeProgressPercent))) + : 0; + const normalizedEpisodeEta = episodeEta !== undefined ? episodeEta : null; + + let finishedCount = 0; + let cancelledCount = 0; + let errorCount = 0; + let runningCount = 0; + const nextChildren = rawChildren.map((child, childIndex) => { + const currentStatus = normalizeSeriesEpisodeStatus(child?.status, 'QUEUED'); + const isTargetChild = childIndex === targetChildIndex; + const nextStatus = ( + isTargetChild + && currentStatus !== 'FINISHED' + && currentStatus !== 'ERROR' + && currentStatus !== 'CANCELLED' + ) + ? 'RUNNING' + : currentStatus; + let nextProgress = Number(child?.progress); + if (nextStatus === 'FINISHED') { + nextProgress = 100; + } else if (isTargetChild && nextStatus === 'RUNNING') { + nextProgress = clampedEpisodeProgress; + } else if (!Number.isFinite(nextProgress)) { + nextProgress = 0; + } + const normalizedProgress = Math.max(0, Math.min(100, Number(nextProgress) || 0)); + + if (nextStatus === 'FINISHED') { + finishedCount += 1; + } else if (nextStatus === 'CANCELLED') { + cancelledCount += 1; + } else if (nextStatus === 'ERROR') { + errorCount += 1; + } else if (nextStatus === 'RUNNING') { + runningCount += 1; + } + + return { + ...child, + status: nextStatus, + progress: Number(normalizedProgress.toFixed(2)), + eta: isTargetChild ? normalizedEpisodeEta : (child?.eta ?? null) + }; + }); + + const totalCountRaw = Number(currentSeriesBatch.totalCount || 0); + const totalCount = Number.isFinite(totalCountRaw) && totalCountRaw > 0 + ? Math.trunc(totalCountRaw) + : nextChildren.length; + + return { + seriesBatch: { + ...currentSeriesBatch, + parentJobId: Number(currentSeriesBatch.parentJobId || jobId), + totalCount, + finishedCount, + cancelledCount, + errorCount, + runningCount, + children: nextChildren + } + }; + }; + const handBrakeProgressParser = (line) => { + const parsed = parseHandBrakeProgress(line); + if (!parsed || parsed.percent === null || parsed.percent === undefined) { + return parsed; + } + const mappedPercent = encodeScriptProgressTracker.hasScriptSteps + ? encodeScriptProgressTracker.mapHandBrakePercent(parsed.percent) + : parsed.percent; + const nextParsed = { + ...parsed, + percent: mappedPercent + }; + const seriesBatchContextPatch = buildSeriesBatchLiveContextPatch(mappedPercent, parsed.eta); + if (seriesBatchContextPatch) { + nextParsed.contextPatch = seriesBatchContextPatch; + } + return nextParsed; + }; + let handbrakeInfo = null; + const encodeCtx = await this.buildPluginContext(encodePlugin.id, { + jobId, + inputPath, + outputPath: incompleteOutputPath, + encodePlan: effectiveEncodePlan, + runCommand: this.runCommand.bind(this), + progressParser: handBrakeProgressParser, + encodeSource: 'HANDBRAKE', + encodeStage: 'ENCODING' + }); + handbrakeInfo = await encodePlugin.encode(job, encodeCtx); + encodePluginExecution = this.mergePluginExecutionState( + encodePluginExecution, + this.sanitizePluginExecutionState(encodeCtx.getPluginExecution()) + ); + logger.info('plugin:encode:used', { + jobId, + pluginId: encodePlugin.id, + mediaProfile + }); + const outputFinalization = finalizeOutputPathForCompletedEncode( + incompleteOutputPath, + preferredFinalOutputPath + ); + const finalizedOutputPath = outputFinalization.outputPath; + const seriesOutputParts = resolveSeriesOutputPathParts(settings, outputPathJobView, jobId); + const outputOwner = String( + seriesOutputParts?.rootOwner + || settings.movie_dir_owner + || '' + ).trim(); + chownRecursive(path.dirname(finalizedOutputPath), outputOwner); + if (outputFinalization.outputPathWithTimestamp) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Finaler Output existierte bereits. Neuer Zielpfad (nummeriert): ${finalizedOutputPath}` + ); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Encode-Output finalisiert: ${finalizedOutputPath}` + ); + historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => { + logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message }); + }); + const postEncodeScriptsSummary = { + ...this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan), + pending: false, + queued: postAutomationCount > 0, + detachedQueue: postAutomationCount > 0 + }; + let finalizedRawPath = activeRawPath || null; + const isMultipartDiscChildRun = this.isMultipartMovieDiscChildHistoryJob(job); + const deferRawFinalizeUntilSeriesBatchDone = ( + isSeriesBatchEpisodeRun + || Boolean(seriesBatchParentJobId) + ) && !isMultipartDiscChildRun; + if (activeRawPath && !deferRawFinalizeUntilSeriesBatchDone) { + const currentRawPath = String(activeRawPath || '').trim(); + const completedRawPath = buildCompletedRawPath(currentRawPath); + if (completedRawPath && completedRawPath !== currentRawPath) { + const currentRawExists = fs.existsSync(currentRawPath); + const completedRawExists = fs.existsSync(completedRawPath); + if (!currentRawExists && completedRawExists) { + finalizedRawPath = completedRawPath; + await historyService.updateRawPathByOldPath(currentRawPath, completedRawPath); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Pfad nach Encode korrigiert (bereits finalisiert): ${currentRawPath} -> ${completedRawPath}` + ); + } else if (!currentRawExists) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nach Encode nicht finalisiert werden (Quellpfad fehlt): ${currentRawPath}` + ); + } else if (completedRawExists) { + logger.warn('encoding:raw-dir-finalize:target-exists', { + jobId, + sourceRawPath: currentRawPath, + targetRawPath: completedRawPath + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nicht finalisiert werden (Ziel existiert bereits): ${completedRawPath}` + ); + } else { + try { + fs.renameSync(currentRawPath, completedRawPath); + await historyService.updateRawPathByOldPath(currentRawPath, completedRawPath); + finalizedRawPath = completedRawPath; + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner nach erfolgreichem Encode finalisiert (Prefix entfernt): ${currentRawPath} -> ${completedRawPath}` + ); + } catch (rawRenameError) { + logger.warn('encoding:raw-dir-finalize:rename-failed', { + jobId, + sourceRawPath: currentRawPath, + targetRawPath: completedRawPath, + error: errorToMeta(rawRenameError) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nach Encode nicht finalisiert werden: ${rawRenameError.message}` + ); + } + } + } + } + + const handbrakeInfoWithPostScripts = this.withPluginExecutionMeta({ + ...handbrakeInfo, + preEncodeScripts: preEncodeScriptsSummary, + postEncodeScripts: postEncodeScriptsSummary + }, encodePluginExecution); + + if (isSeriesBatchEpisodeRun) { + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts), + status: 'ENCODING', + last_state: 'ENCODING', + end_time: null, + raw_path: finalizedRawPath, + rip_successful: 1, + output_path: finalizedOutputPath, + error_message: null + }); + await historyService.generateJobNfo(jobId, { + mode: 'auto', + requireSettingEnabled: true + }).catch((nfoError) => { + logger.warn('job:nfo:auto-generate-failed', { + jobId, + mode: 'series_batch_episode', + error: errorToMeta(nfoError) + }); + }); + return { + outputPath: finalizedOutputPath, + handbrakeInfo: handbrakeInfoWithPostScripts, + trackSelection, + rawPath: finalizedRawPath + }; + } + + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts), + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + raw_path: finalizedRawPath, + rip_successful: 1, + output_path: finalizedOutputPath, + error_message: null + }); + await historyService.generateJobNfo(jobId, { + mode: 'auto', + requireSettingEnabled: true + }).catch((nfoError) => { + logger.warn('job:nfo:auto-generate-failed', { + jobId, + mode: 'encode', + error: errorToMeta(nfoError) + }); + }); + + if (job?.parent_job_id) { + const parentJobId = this.normalizeQueueJobId(job.parent_job_id); + const parentJob = parentJobId + ? await historyService.getJobById(parentJobId).catch(() => null) + : null; + if (parentJob && this.isMultipartMovieContainerHistoryJob(parentJob) && this.isMultipartMovieDiscChildHistoryJob(job)) { + await this.upsertMultipartMergeJobForContainer(parentJobId, { containerJob: parentJob }).catch((mergePrepError) => { + logger.warn('multipart:merge:upsert-on-child-finish-failed', { + parentJobId, + childJobId: jobId, + error: errorToMeta(mergePrepError) + }); + }); + } + await this.syncSeriesContainerStatusFromChildren(parentJobId).catch((parentSyncError) => { + logger.warn('series-container:status-sync-on-child-finish-failed', { + parentJobId, + childJobId: jobId, + error: errorToMeta(parentSyncError) + }); + }); + } + + // Thumbnail aus Cache in persistenten Ordner verschieben + const promotedUrl = thumbnailService.promoteJobThumbnail(jobId); + if (promotedUrl) { + await historyService.updateJob(jobId, { poster_url: promotedUrl }).catch(() => {}); + } + + logger.info('encoding:finished', { jobId, mode, outputPath: finalizedOutputPath }); + const finishedStatusTextBase = mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen'; + const finishedStatusText = postEncodeScriptsSummary.failed > 0 + ? `${finishedStatusTextBase} (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)` + : finishedStatusTextBase; + + // Only switch the pipeline UI to FINISHED if this job is still the active one. + // If the pipeline has moved to another job (e.g. metadata selection for a new disc + // that was inserted while this one was encoding), complete silently so the other + // job's UI state is not disrupted. The DB status is already FINISHED (above). + if (Number(this.snapshot.activeJobId) === Number(jobId)) { + await this.setState('FINISHED', { + activeJobId: jobId, + progress: 100, + eta: null, + statusText: finishedStatusText, + context: { + jobId, + mode, + outputPath: finalizedOutputPath + } + }); + } else { + logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId }); + void this.pumpQueue(); + } + if (postAutomationCount > 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Automationen laufen als eigene Queue-Einträge (Post=${postAutomationCount}).` + ); + } + + if (mode === 'reencode') { + void this.notifyPushover('reencode_finished', { + title: 'Ripster - Re-Encode abgeschlossen', + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` + }); + } else { + void this.notifyPushover('job_finished', { + title: 'Ripster - Job abgeschlossen', + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` + }); + void this.ejectDriveIfEnabled(settings); + } + + if (seriesBatchParentJobId) { + await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { + logger.warn('series-batch:parent-progress:update-on-child-finish-failed', { + parentJobId: seriesBatchParentJobId, + childJobId: jobId, + error: errorToMeta(error) + }); + }); + } + + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { + await this.setState('IDLE', { + finishingJobId: jobId, + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + }, 3000); + } catch (error) { + if (error.runInfo && error.runInfo.source === 'HANDBRAKE') { + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify(error.runInfo) + }); + } + logger.error('encode:start-from-prepared:failed', { jobId, mode, error: errorToMeta(error) }); + if (isSeriesBatchEpisodeRun) { + throw error; + } + await this.failJob(jobId, 'ENCODING', error); + error.jobAlreadyFailed = true; + throw error; + } + } + + async startRipEncode(jobId) { + this.ensureNotBusy('startRipEncode', jobId); + logger.info('ripEncode:start', { jobId }); + + let job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + const preRipPlanBeforeRip = this.safeParseJson(job.encode_plan_json); + const preRipModeBeforeRip = String(preRipPlanBeforeRip?.mode || '').trim().toLowerCase(); + const hasPreRipConfirmedSelection = (preRipModeBeforeRip === 'pre_rip' || Boolean(preRipPlanBeforeRip?.preRip)) + && Number(job.encode_review_confirmed || 0) === 1; + const preRipTrackSelectionPayload = hasPreRipConfirmedSelection + ? extractManualSelectionPayloadFromPlan(preRipPlanBeforeRip) + : null; + const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection + ? normalizeScriptIdList(preRipPlanBeforeRip?.postEncodeScriptIds || []) + : []; + const preRipPreEncodeScriptIds = hasPreRipConfirmedSelection + ? normalizeScriptIdList(preRipPlanBeforeRip?.preEncodeScriptIds || []) + : []; + const preRipPostEncodeChainIds = hasPreRipConfirmedSelection + ? (Array.isArray(preRipPlanBeforeRip?.postEncodeChainIds) ? preRipPlanBeforeRip.postEncodeChainIds : []) + .map(Number).filter((id) => Number.isFinite(id) && id > 0) + : []; + const preRipPreEncodeChainIds = hasPreRipConfirmedSelection + ? (Array.isArray(preRipPlanBeforeRip?.preEncodeChainIds) ? preRipPlanBeforeRip.preEncodeChainIds : []) + .map(Number).filter((id) => Number.isFinite(id) && id > 0) + : []; + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext || {}; + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: preRipPlanBeforeRip, + makemkvInfo: mkInfo, + deviceInfo: this.detectedDisc || this.snapshot.context?.device || null + }); + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); + const selectedTitleId = playlistDecision.selectedTitleId; + const selectedPlaylist = playlistDecision.selectedPlaylist; + const selectedPlaylistFile = toPlaylistFile(selectedPlaylist); + + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const rawBaseDir = settings.raw_dir; + const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' + ? 'backup' + : 'mkv'; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); + const isSeriesDvdRip = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const rawStorage = resolveSeriesAwareRawStorage(settings, mediaProfile, selectedMetadata, analyzeContext); + const effectiveRawBaseDir = String( + rawStorage?.rawBaseDir + || rawBaseDir + || settingsService.DEFAULT_RAW_DIR + || '' + ).trim(); + const effectiveRawOwner = String(rawStorage?.rawOwner || settings.raw_dir_owner || '').trim(); + const disableMinLengthFilter = ripMode === 'mkv' && isSeriesDvdRip; + const effectiveSelectedTitleId = ripMode === 'mkv' ? (selectedTitleId ?? null) : null; + const effectiveSelectedPlaylist = ripMode === 'mkv' ? (selectedPlaylist || null) : null; + const effectiveSelectedPlaylistFile = ripMode === 'mkv' ? selectedPlaylistFile : null; + const selectedPlaylistTitleInfo = ripMode === 'mkv' && Array.isArray(playlistDecision.playlistAnalysis?.titles) + ? (playlistDecision.playlistAnalysis.titles.find((item) => + Number(item?.titleId) === Number(selectedTitleId) + ) || null) + : null; + logger.info('rip:playlist-resolution', { + jobId, + ripMode, + selectedPlaylist: effectiveSelectedPlaylistFile, + selectedTitleId: effectiveSelectedTitleId, + selectedTitleDurationSeconds: Number(selectedPlaylistTitleInfo?.durationSeconds || 0), + selectedTitleDurationLabel: selectedPlaylistTitleInfo?.durationLabel || null + }); + logger.debug('ripEncode:paths', { jobId, rawBaseDir: effectiveRawBaseDir }); + + ensureDir(effectiveRawBaseDir); + + const metadataBase = buildRawMetadataBase({ + title: job.title || job.detected_title || null, + year: job.year || null, + detected_title: job.detected_title || null, + media_type: mediaProfile, + is_multipart_movie: Number(job?.is_multipart_movie || 0) === 1 ? 1 : 0, + job_kind: job?.job_kind || null + }, jobId, { + mediaProfile, + analyzeContext, + selectedMetadata, + isMultipartMovie: Number(job?.is_multipart_movie || 0) === 1 + || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child' + || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container' + }); + const rawDirName = buildRawDirName(metadataBase, jobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); + const rawJobDir = path.join(effectiveRawBaseDir, rawDirName); + ensureDir(rawJobDir); + chownRecursive(rawJobDir, effectiveRawOwner); + logger.info('rip:raw-dir-created', { jobId, rawJobDir }); + + const deviceCandidate = this.detectedDisc || this.snapshot.context?.device || { + path: job.disc_device, + index: 0 + }; + const deviceProfile = normalizeMediaProfile(deviceCandidate?.mediaProfile) + || inferMediaProfileFromDeviceInfo(deviceCandidate) + || mediaProfile; + const device = { + ...deviceCandidate, + mediaProfile: deviceProfile + }; + const devicePath = device.path || null; + + await this.setState('RIPPING', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: ripMode === 'backup' ? 'Backup mit MakeMKV' : 'Ripping mit MakeMKV', + context: { + jobId, + device, + mediaProfile, + ripMode, + playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), + playlistCandidates: playlistDecision.candidatePlaylists, + selectedPlaylist: effectiveSelectedPlaylist, + selectedTitleId: effectiveSelectedTitleId, + preRipSelectionLocked: hasPreRipConfirmedSelection, + selectedMetadata + } + }); + + void this.notifyPushover('rip_started', { + title: ripMode === 'backup' ? 'Ripster - Backup gestartet' : 'Ripster - Rip gestartet', + message: `${job.title || job.detected_title || `Job #${jobId}`} (${device.path || 'disc'})` + }); + + const backupOutputBase = ripMode === 'backup' && mediaProfile === 'dvd' + ? sanitizeFileName(job.title || job.detected_title || `disc-${jobId}`) + : null; + + await historyService.updateJob(jobId, { + status: 'RIPPING', + last_state: 'RIPPING', + raw_path: rawJobDir, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + output_path: null, + error_message: null, + end_time: null + }); + if (job?.parent_job_id) { + await historyService.updateJob(job.parent_job_id, { + status: 'RIPPING', + last_state: 'RIPPING', + error_message: null, + end_time: null + }); + } + job = await historyService.getJobById(jobId); + + let makemkvInfo = null; + try { + await this.ensureMakeMKVRegistration(jobId, 'RIPPING'); + + if (ripMode === 'backup') { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Backup-Modus aktiv: MakeMKV erstellt 1:1 Backup ohne Titel-/Playlist-Einschränkungen.' + ); + } else if (effectiveSelectedPlaylistFile) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Manuelle Playlist-Auswahl aktiv: ${effectiveSelectedPlaylistFile} (Titel ${effectiveSelectedTitleId}).` + ); + if (selectedPlaylistTitleInfo) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Playlist-Auflösung: Titel ${effectiveSelectedTitleId} Dauer ${selectedPlaylistTitleInfo.durationLabel || `${selectedPlaylistTitleInfo.durationSeconds || 0}s`}.` + ); + } + } else if (playlistDecision.playlistDecisionRequired) { + const decisionContext = describePlaylistManualDecision(playlistDecision.playlistAnalysis); + await historyService.appendLog( + jobId, + 'SYSTEM', + `${decisionContext.detailText} Rip läuft ohne Vorauswahl. Finale Titelwahl erfolgt in der Mediainfo-Prüfung per Checkbox.` + ); + } + if (disableMinLengthFilter) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: MakeMKV-Rip läuft ohne Mindestlängen-Filter (--minlength deaktiviert).` + ); + } + if (rawStorage.usingSeriesRawPath) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} RAW-Pfad aktiv: ${effectiveRawBaseDir}` + ); + } + const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); + if (devicePath) { + const isOrphanRawImportRipJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + if (isOrphanRawImportRipJob) { + this._releaseDriveLockForJob(jobId, { reason: 'orphan_raw_rip_bypass' }); + } else { + const existingLock = this._getDriveLockByPath(devicePath); + const existingLockJobId = Number(existingLock?.jobId || existingLock?.owner?.jobId || 0) || null; + const lockedByOtherJob = ( + (diskDetectionService.isDeviceLocked(devicePath) || Boolean(existingLock)) + && existingLockJobId !== Number(jobId) + ); + if (lockedByOtherJob) { + throw this._buildDriveLockedError(devicePath, existingLock); + } + this._acquireDriveLockForJob(devicePath, jobId, { + stage: 'RIPPING', + source: 'MAKEMKV_RIP', + mediaProfile, + reason: 'rip_running' + }); + } + } + let ripCommandSucceeded = false; + try { + // Plugin-Pfad: VideoDiscPlugin.rip() baut eigene ripConfig + ruft ctx.extra.runCommand() + const ripCtx = await this.buildPluginContext(ripPlugin.id, { + jobId, + rawJobDir, + deviceInfo: device, + selectedTitleId: effectiveSelectedTitleId, + disableMinLengthFilter, + backupOutputBase, + sourceArgOverride: null, + makeMkvLineFilter: createMakeMkvForeignDriveLineFilter(devicePath), + runCommand: this.runCommand.bind(this) + }); + makemkvInfo = await ripPlugin.rip(job, ripCtx); + ripCommandSucceeded = true; + } finally { + if (devicePath && !ripCommandSucceeded) { + logger.warn('drive-lock:retained-after-rip-failure', { + devicePath, + jobId + }); + } + } + + // Check for MakeMKV backup failure even when exit code is 0. + // MakeMKV can emit localized failure text while still exiting with 0. + const backupFailureLine = ripMode === 'backup' + ? findMakeMkvBackupFailureMarker(makemkvInfo?.highlights) + : null; + if (backupFailureLine) { + const msgCode = parseMakeMkvMessageCode(backupFailureLine); + throw Object.assign( + new Error(`MakeMKV Backup fehlgeschlagen${msgCode !== null ? ` (MSG:${msgCode})` : ''}: ${backupFailureLine}`), + { runInfo: makemkvInfo } + ); + } + + const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); + // Merge bestehende Analyze-Marker aus der DB mit den Live-Markern aus dem Rip-Lauf, + // damit analyze nicht verloren geht, wenn jobProgress nur die neueste Rip-Phase enthält. + const postRipPluginExecution = this.mergePluginExecutionState( + mkInfoBeforeRip?.pluginExecution, + this.jobProgress.get(Number(jobId))?.context?.pluginExecution + ); + await historyService.updateJob(jobId, { + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...makemkvInfo, + analyzeContext: mkInfoBeforeRip?.analyzeContext || null, + pluginExecution: postRipPluginExecution || null + }, mediaProfile)), + rip_successful: 1 + }); + + // Mark RAW as rip-complete until encode succeeds. + let activeRawJobDir = rawJobDir; + const ripCompleteRawJobDir = buildRipCompleteRawPath(rawJobDir); + if (ripCompleteRawJobDir && ripCompleteRawJobDir !== rawJobDir) { + if (fs.existsSync(ripCompleteRawJobDir)) { + logger.warn('rip:raw-complete:rename-skip', { jobId, rawJobDir, ripCompleteRawJobDir }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nach Rip nicht als Rip_Complete markiert werden (Zielordner existiert): ${ripCompleteRawJobDir}` + ); + } else { + try { + fs.renameSync(rawJobDir, ripCompleteRawJobDir); + activeRawJobDir = ripCompleteRawJobDir; + chownRecursive(activeRawJobDir, effectiveRawOwner); + await historyService.updateRawPathByOldPath(rawJobDir, ripCompleteRawJobDir); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner nach erfolgreichem Rip als Rip_Complete markiert: ${rawJobDir} → ${ripCompleteRawJobDir}` + ); + } catch (renameError) { + logger.warn('rip:raw-complete:rename-failed', { + jobId, + rawJobDir, + ripCompleteRawJobDir, + error: errorToMeta(renameError) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nach Rip nicht als Rip_Complete markiert werden: ${renameError.message}` + ); + } + } + } + + if (devicePath) { + this._releaseDriveLockForJob(jobId, { reason: 'rip_successful' }); + } + + const review = await this.runReviewForRawJob(jobId, activeRawJobDir, { + mode: 'rip', + mediaProfile + }); + logger.info('rip:review-ready', { + jobId, + encodeInputPath: review.encodeInputPath, + selectedTitleCount: Array.isArray(review.selectedTitleIds) + ? review.selectedTitleIds.length + : (Array.isArray(review.titles) + ? review.titles.filter((item) => Boolean(item?.selectedForEncode)).length + : 0) + }); + if (hasPreRipConfirmedSelection && !review?.awaitingPlaylistSelection) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Vorab bestätigte Spurauswahl erkannt. Übernehme Auswahl automatisch und starte Encode.' + ); + await this.confirmEncodeReview(jobId, { + selectedEncodeTitleId: review?.encodeInputTitleId || null, + selectedTrackSelection: preRipTrackSelectionPayload || null, + selectedPostEncodeScriptIds: preRipPostEncodeScriptIds, + selectedPreEncodeScriptIds: preRipPreEncodeScriptIds, + selectedPostEncodeChainIds: preRipPostEncodeChainIds, + selectedPreEncodeChainIds: preRipPreEncodeChainIds + }); + const autoStartResult = await this.startPreparedJob(jobId); + logger.info('rip:auto-encode-started', { + jobId, + stage: autoStartResult?.stage || null + }); + } + } catch (error) { + if (error.runInfo && error.runInfo.source === 'MAKEMKV_RIP') { + const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); + await historyService.updateJob(jobId, { + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...error.runInfo, + analyzeContext: mkInfoBeforeRip?.analyzeContext || null, + pluginExecution: mkInfoBeforeRip?.pluginExecution || null + }, mediaProfile)) + }); + } + if ( + error.runInfo + && [ + 'MEDIAINFO', + 'HANDBRAKE_SCAN', + 'HANDBRAKE_SCAN_PLAYLIST_MAP', + 'HANDBRAKE_SCAN_SELECTED_TITLE', + 'MAKEMKV_ANALYZE_BACKUP' + ].includes(error.runInfo.source) + ) { + await historyService.updateJob(jobId, { + mediainfo_info_json: JSON.stringify({ + failedAt: nowIso(), + runInfo: error.runInfo + }) + }); + } + logger.error('ripEncode:failed', { jobId, stage: this.snapshot.state, error: errorToMeta(error) }); + await this.failJob(jobId, this.snapshot.state, error); + throw error; + } + } + + async retry(jobId, options = {}) { + const immediate = Boolean(options?.immediate); + if (!immediate) { + // Retry starts processing immediately (Rip/Merge) and bypasses the encode queue. + return this.retry(jobId, { ...options, immediate: true }); + } + const resolvedRetryJob = await this.resolveExistingJobForAction(jobId, 'retry'); + jobId = resolvedRetryJob.resolvedJobId; + this.ensureNotBusy('retry', jobId); + logger.info('retry:start', { jobId }); + this.cancelRequestedByJob.delete(Number(jobId)); + + let sourceJob = resolvedRetryJob.job || await historyService.getJobById(jobId); + + if (!sourceJob.title && !sourceJob.detected_title) { + const error = new Error('Retry nicht möglich: keine Metadaten vorhanden.'); + error.statusCode = 400; + throw error; + } + + const sourceStatus = String(sourceJob.status || '').trim().toUpperCase(); + const sourceLastState = String(sourceJob.last_state || '').trim().toUpperCase(); + + const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); + const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); + const sourceJobKind = this.resolveJobKindForJob(sourceJob, { encodePlan: sourceEncodePlan }); + const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { + makemkvInfo: sourceMakemkvInfo, + encodePlan: sourceEncodePlan + }); + const explicitSourceMediaType = String(sourceJob?.media_type || '').trim().toLowerCase(); + const preservedRetryMediaType = explicitSourceMediaType || ( + ['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null + ); + const isCdRetry = mediaProfile === 'cd'; + const isAudiobookRetry = mediaProfile === 'audiobook'; + const isMultipartMergeRetry = sourceJobKind === 'multipart_movie_merge'; + const isVideoDiscRetry = mediaProfile === 'dvd' || mediaProfile === 'bluray'; + const sourceParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id); + + let mergeContainerJobId = null; + if (isMultipartMergeRetry) { + mergeContainerJobId = this.normalizeQueueJobId(sourceEncodePlan?.containerJobId); + if (!mergeContainerJobId && sourceParentJobId) { + const directParentJob = await historyService.getJobById(sourceParentJobId).catch(() => null); + if (directParentJob && this.isMultipartMovieContainerHistoryJob(directParentJob)) { + mergeContainerJobId = sourceParentJobId; + } else if (directParentJob && this.isMultipartMovieMergeHistoryJob(directParentJob)) { + const nestedContainerJobId = this.normalizeQueueJobId(directParentJob?.parent_job_id); + if (nestedContainerJobId) { + const nestedParentJob = await historyService.getJobById(nestedContainerJobId).catch(() => null); + if (nestedParentJob && this.isMultipartMovieContainerHistoryJob(nestedParentJob)) { + mergeContainerJobId = nestedContainerJobId; + } + } + } + } + if (mergeContainerJobId) { + const mergeContainerJob = await historyService.getJobById(mergeContainerJobId).catch(() => null); + if (!mergeContainerJob || !this.isMultipartMovieContainerHistoryJob(mergeContainerJob)) { + mergeContainerJobId = null; + } + } + if (!mergeContainerJobId) { + const error = new Error( + 'Retry für Merge-Job nicht möglich: Multipart-Container konnte nicht aufgelöst werden.' + ); + error.statusCode = 409; + throw error; + } + } + + // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden. + const retryableByStatus = ['ERROR', 'CANCELLED'].includes(sourceStatus) + || ['ERROR', 'CANCELLED'].includes(sourceLastState) + || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)); + const incompleteRipRetryableStates = ['WAITING_FOR_USER_DECISION', 'READY_TO_ENCODE', 'MEDIAINFO_CHECK']; + const retryableByIncompleteVideoRipState = isVideoDiscRetry + && !isMultipartMergeRetry + && ( + incompleteRipRetryableStates.includes(sourceStatus) + || incompleteRipRetryableStates.includes(sourceLastState) + ); + const retryable = retryableByStatus || retryableByIncompleteVideoRipState; + if (!retryable) { + const currentStateLabel = sourceStatus || sourceLastState || '-'; + const error = new Error( + `Retry nicht möglich: Job ${jobId} ist nicht in einem retry-fähigen Status (aktuell ${currentStateLabel}).` + ); + error.statusCode = 409; + throw error; + } + + let cdRetryConfig = null; + if (isCdRetry) { + const normalizeTrackPosition = (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; + const sourceTracks = Array.isArray(sourceMakemkvInfo?.tracks) + ? sourceMakemkvInfo.tracks + : (Array.isArray(sourceEncodePlan?.tracks) ? sourceEncodePlan.tracks : []); + // Keine Trackdaten → Neustart ohne Vorkonfiguration (TOC wird von der CD gelesen) + if (sourceTracks.length === 0) { + cdRetryConfig = { + format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac', + formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object' + ? sourceEncodePlan.formatOptions + : {}, + selectedTracks: [], + tracks: [], + metadata: { + title: sourceMakemkvInfo?.selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD', + artist: sourceMakemkvInfo?.selectedMetadata?.artist || null, + year: sourceMakemkvInfo?.selectedMetadata?.year ?? sourceJob.year ?? null, + mbId: sourceMakemkvInfo?.selectedMetadata?.mbId + || sourceMakemkvInfo?.selectedMetadata?.musicBrainzId + || sourceMakemkvInfo?.selectedMetadata?.musicbrainzId + || sourceMakemkvInfo?.selectedMetadata?.musicbrainz_id + || sourceMakemkvInfo?.selectedMetadata?.music_brainz_id + || sourceMakemkvInfo?.selectedMetadata?.musicbrainz + || sourceMakemkvInfo?.selectedMetadata?.mbid + || null, + coverUrl: sourceMakemkvInfo?.selectedMetadata?.coverUrl || sourceJob.poster_url || null + }, + selectedPreEncodeScriptIds: [], + selectedPostEncodeScriptIds: [], + selectedPreEncodeChainIds: [], + selectedPostEncodeChainIds: [] + }; + } else { + const selectedTracks = normalizeCdTrackPositionList( + Array.isArray(sourceEncodePlan?.selectedTracks) + ? sourceEncodePlan.selectedTracks + : sourceTracks.filter((track) => track?.selected !== false).map((track) => normalizeTrackPosition(track?.position)) + ); + const selectedMetadata = sourceMakemkvInfo?.selectedMetadata && typeof sourceMakemkvInfo.selectedMetadata === 'object' + ? sourceMakemkvInfo.selectedMetadata + : {}; + cdRetryConfig = { + format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac', + formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object' + ? sourceEncodePlan.formatOptions + : {}, + selectedTracks: selectedTracks.length > 0 + ? selectedTracks + : sourceTracks + .map((track) => normalizeTrackPosition(track?.position)) + .filter((value) => Number.isFinite(value) && value > 0), + tracks: sourceTracks, + metadata: { + title: selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD', + artist: selectedMetadata?.artist || null, + year: selectedMetadata?.year ?? sourceJob.year ?? null, + mbId: selectedMetadata?.mbId + || selectedMetadata?.musicBrainzId + || selectedMetadata?.musicbrainzId + || selectedMetadata?.musicbrainz_id + || selectedMetadata?.music_brainz_id + || selectedMetadata?.musicbrainz + || selectedMetadata?.mbid + || null, + coverUrl: selectedMetadata?.coverUrl + || selectedMetadata?.poster + || selectedMetadata?.posterUrl + || sourceJob.poster_url + || null + }, + selectedPreEncodeScriptIds: normalizeScriptIdList(sourceEncodePlan?.preEncodeScriptIds || []), + selectedPostEncodeScriptIds: normalizeScriptIdList(sourceEncodePlan?.postEncodeScriptIds || []), + selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []), + selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || []) + }; + } // end else (sourceTracks.length > 0) + } + + const shouldResetOldRawBeforeRetry = (isVideoDiscRetry || isCdRetry) + && !isAudiobookRetry + && !isMultipartMergeRetry; + if (shouldResetOldRawBeforeRetry) { + const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig( + retrySettings, + mediaProfile + ); + const resolvedOldRawPath = this.resolveCurrentRawPathForSettings( + retrySettings, + mediaProfile, + sourceJob.raw_path + ); + + if (resolvedOldRawPath) { + const oldRawFolderName = path.basename(resolvedOldRawPath); + const oldRawLooksLikeJobFolder = /\s-\sRAW\s-\sjob-\d+\s*$/i.test(stripRawStatePrefix(oldRawFolderName)); + if (!oldRawLooksLikeJobFolder) { + const error = new Error(`Retry nicht möglich: alter RAW-Pfad ist kein Job-RAW-Ordner (${resolvedOldRawPath}).`); + error.statusCode = 400; + throw error; + } + + const rawDeletionRoots = Array.from(new Set( + [ + retryRawBaseDir, + ...retryRawExtraDirs, + path.dirname(String(sourceJob.raw_path || '').trim()) + ] + .map((dirPath) => normalizeComparablePath(dirPath)) + .filter(Boolean) + )); + const oldRawPathAllowed = rawDeletionRoots.some((rootPath) => isPathInsideDirectory(rootPath, resolvedOldRawPath)); + if (!oldRawPathAllowed) { + const error = new Error( + `Retry nicht möglich: alter RAW-Pfad liegt außerhalb der erlaubten RAW-Verzeichnisse (${resolvedOldRawPath}).` + ); + error.statusCode = 400; + throw error; + } + + try { + fs.rmSync(resolvedOldRawPath, { recursive: true, force: true }); + } catch (deleteError) { + const error = new Error(`Retry nicht möglich: alter RAW-Ordner konnte nicht gelöscht werden (${deleteError.message}).`); + error.statusCode = 500; + throw error; + } + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Retry: alter RAW-Ordner wurde entfernt: ${resolvedOldRawPath}` + ); + sourceJob = await historyService.updateJob(jobId, { + raw_path: null, + rip_successful: 0 + }); + } else if (sourceJob.raw_path) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Retry: alter RAW-Pfad ist nicht mehr vorhanden und wird aus dem Job entfernt (${sourceJob.raw_path}).` + ); + sourceJob = await historyService.updateJob(jobId, { + raw_path: null, + rip_successful: 0 + }); + } + } + + const retryInitialStatus = isCdRetry + ? 'CD_READY_TO_RIP' + : (isAudiobookRetry + ? 'READY_TO_START' + : (isMultipartMergeRetry ? 'READY_TO_START' : 'RIPPING')); + const createNewJob = Boolean(options?.createNewJob); + const retryParentJobId = isMultipartMergeRetry + ? mergeContainerJobId + : this.normalizeQueueJobId(jobId); + + let retryEncodePlanJson = (isCdRetry || isAudiobookRetry || isMultipartMergeRetry) + ? (sourceJob.encode_plan_json || null) + : null; + if (isMultipartMergeRetry && sourceEncodePlan && typeof sourceEncodePlan === 'object' && retryParentJobId) { + retryEncodePlanJson = JSON.stringify({ + ...sourceEncodePlan, + mode: 'multipart_merge', + jobKind: 'multipart_movie_merge', + containerJobId: Number(retryParentJobId), + updatedAt: nowIso() + }); + } + + let retryJobId = Number(jobId); + let replacedSourceJob = false; + if (createNewJob) { + const retryJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: retryInitialStatus, + detectedTitle: sourceJob.detected_title || sourceJob.title || null, + jobKind: this.resolveJobKindForJob(sourceJob, { + encodePlan: sourceEncodePlan + }) + }); + retryJobId = Number(retryJob?.id || 0); + if (!Number.isFinite(retryJobId) || retryJobId <= 0) { + throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + replacedSourceJob = true; + } + + let retryRawPath = isAudiobookRetry ? (sourceJob.raw_path || null) : null; + let retryEncodeInputPath = isAudiobookRetry + ? ( + sourceJob.encode_input_path + || sourceEncodePlan?.encodeInputPath + || sourceMakemkvInfo?.rawFilePath + || null + ) + : null; + if (retryRawPath && replacedSourceJob) { + const previousRetryRawPath = retryRawPath; + retryRawPath = await this.alignRawFolderJobId(retryRawPath, retryJobId); + if ( + previousRetryRawPath + && retryRawPath + && normalizeComparablePath(previousRetryRawPath) !== normalizeComparablePath(retryRawPath) + ) { + retryEncodeInputPath = remapPathToRetargetedRawRoot( + retryEncodeInputPath, + previousRetryRawPath, + retryRawPath + ); + } + } + + const retryUpdatePayload = { + media_type: preservedRetryMediaType, + title: sourceJob.title || null, + year: sourceJob.year ?? null, + imdb_id: sourceJob.imdb_id || null, + poster_url: sourceJob.poster_url || null, + omdb_json: null, + selected_from_omdb: 0, + makemkv_info_json: sourceJob.makemkv_info_json || null, + rip_successful: isAudiobookRetry ? 1 : 0, + error_message: null, + end_time: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: retryEncodePlanJson, + encode_input_path: retryEncodeInputPath, + encode_review_confirmed: isAudiobookRetry ? 1 : 0, + output_path: null, + raw_path: retryRawPath, + status: retryInitialStatus, + last_state: retryInitialStatus + }; + if (replacedSourceJob) { + retryUpdatePayload.parent_job_id = retryParentJobId ? Number(retryParentJobId) : null; + } + await historyService.updateJob(retryJobId, retryUpdatePayload); + + // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist + if (replacedSourceJob && thumbnailService.isLocalUrl(sourceJob.poster_url)) { + const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), retryJobId); + if (copiedUrl) { + await historyService.updateJob(retryJobId, { poster_url: copiedUrl }).catch(() => {}); + } + } + + await historyService.appendLog( + retryJobId, + 'USER_ACTION', + `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : (isMultipartMergeRetry ? 'Merge' : 'Disc'))}).${replacedSourceJob ? ' Neuer Job wurde angelegt.' : ' Bestehender Job wird weiterverwendet.'}` + ); + if (preservedRetryMediaType && !explicitSourceMediaType) { + await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {}); + } + if (replacedSourceJob) { + this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); + await historyService.retireJobInFavorOf(jobId, retryJobId, { + reason: isCdRetry + ? 'cd_retry' + : (isAudiobookRetry ? 'audiobook_retry' : (isMultipartMergeRetry ? 'merge_retry' : 'retry')) + }); + } + this.cancelRequestedByJob.delete(retryJobId); + + if (isCdRetry) { + this.startCdRip(retryJobId, cdRetryConfig || {}).catch((error) => { + logger.error('retry:cd:background-failed', { + jobId: retryJobId, + sourceJobId: jobId, + error: errorToMeta(error) + }); + }); + } else if (isAudiobookRetry) { + return { + jobId: retryJobId, + sourceJobId: Number(jobId), + replacedSourceJob, + started: false, + queued: false, + stage: 'READY_TO_START' + }; + } else if (isMultipartMergeRetry) { + this.startMultipartMergeJob(retryJobId).catch((error) => { + logger.error('retry:multipart-merge:background-failed', { + jobId: retryJobId, + sourceJobId: jobId, + error: errorToMeta(error) + }); + }); + } else { + this.startRipEncode(retryJobId).catch((error) => { + logger.error('retry:background-failed', { + jobId: retryJobId, + sourceJobId: jobId, + error: errorToMeta(error) + }); + }); + } + + return { + started: true, + sourceJobId: Number(jobId), + jobId: retryJobId, + replacedSourceJob + }; + } + + async resumeReadyToEncodeJob(jobId) { + const resolvedReadyJob = await this.resolveExistingJobForAction(jobId, 'resume_ready_to_encode'); + jobId = resolvedReadyJob.resolvedJobId; + this.ensureNotBusy('resumeReadyToEncodeJob', jobId); + logger.info('resumeReadyToEncodeJob:requested', { jobId }); + + const job = resolvedReadyJob.job || await historyService.getJobById(jobId); + + const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE'; + if (!isReadyToEncode) { + const error = new Error(`Job ${jobId} ist nicht im Status READY_TO_ENCODE.`); + error.statusCode = 409; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json); + if (!encodePlan || !Array.isArray(encodePlan.titles)) { + const error = new Error('READY_TO_ENCODE Job kann nicht geladen werden: encode_plan fehlt.'); + error.statusCode = 400; + throw error; + } + + const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase(); + const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); + const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed); + const readyMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan }); + const resumeSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); + const resolvedResumeRawPath = this.resolveCurrentRawPathForSettings( + resumeSettings, + readyMediaProfile, + job.raw_path + ); + const activeResumeRawPath = resolvedResumeRawPath || String(job.raw_path || '').trim() || null; + + let inputPath = isPreRipMode + ? null + : (job.encode_input_path || encodePlan?.encodeInputPath || null); + if (!isPreRipMode && activeResumeRawPath) { + const needsInputRefresh = !inputPath + || !fs.existsSync(inputPath) + || !isPathInsideDirectory(activeResumeRawPath, inputPath); + if (needsInputRefresh) { + const selectedPlaylistId = normalizePlaylistId( + encodePlan?.selectedPlaylistId + || encodePlan?.selectedPlaylist + || null + ); + if (hasBluRayBackupStructure(activeResumeRawPath)) { + inputPath = activeResumeRawPath; + } else { + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylistId); + inputPath = findPreferredRawInput(activeResumeRawPath, { + playlistAnalysis: playlistDecision.playlistAnalysis, + selectedPlaylistId: selectedPlaylistId || playlistDecision.selectedPlaylist + })?.path || null; + } + } + } + const hasEncodableTitle = isPreRipMode + ? Boolean(encodePlan?.encodeInputTitleId) + : Boolean(inputPath); + const selectedMetadata = { + title: job.title || job.detected_title || null, + year: job.year || null, + imdbId: job.imdb_id || null, + poster: job.poster_url || null + }; + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: hasEncodableTitle + ? (reviewConfirmed + ? (isPreRipMode + ? 'Spurauswahl geladen - Backup/Rip + Encode startbereit' + : 'Mediainfo geladen - Encode startbereit') + : (isPreRipMode + ? 'Spurauswahl geladen - bitte bestätigen' + : 'Mediainfo geladen - bitte bestätigen')) + : (isPreRipMode + ? 'Spurauswahl geladen - kein passender Titel gewählt' + : 'Mediainfo geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'), + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath: activeResumeRawPath, + inputPath, + hasEncodableTitle, + reviewConfirmed, + mode, + mediaProfile: readyMediaProfile, + sourceJobId: encodePlan?.sourceJobId || null, + selectedMetadata, + mediaInfoReview: encodePlan + } + }); + + await historyService.appendLog( + jobId, + 'USER_ACTION', + 'READY_TO_ENCODE Job nach Neustart in den Ripper geladen.' + ); + + if ( + (activeResumeRawPath && normalizeComparablePath(activeResumeRawPath) !== normalizeComparablePath(job.raw_path)) + || (!isPreRipMode && inputPath && normalizeComparablePath(inputPath) !== normalizeComparablePath(job.encode_input_path)) + ) { + const resumeUpdatePayload = {}; + if (activeResumeRawPath && normalizeComparablePath(activeResumeRawPath) !== normalizeComparablePath(job.raw_path)) { + resumeUpdatePayload.raw_path = activeResumeRawPath; + } + if (!isPreRipMode) { + resumeUpdatePayload.encode_input_path = inputPath; + } + await historyService.updateJob(jobId, resumeUpdatePayload); + } + + return historyService.getJobById(jobId); + } + + async restartEncodeWithLastSettings(jobId, options = {}) { + const immediate = Boolean(options?.immediate); + if (!immediate) { + // Restart-Encode now prepares an editable READY_TO_ENCODE state first. + // No queue slot is needed because encoding is not started automatically here. + return this.restartEncodeWithLastSettings(jobId, { ...options, immediate: true }); + } + + const resolvedRestartEncodeJob = await this.resolveExistingJobForAction(jobId, 'restart_encode'); + jobId = resolvedRestartEncodeJob.resolvedJobId; + this.ensureNotBusy('restartEncodeWithLastSettings', jobId); + const requestedRestartMode = String(options?.restartMode || 'all').trim().toLowerCase() === 'from_abort' + ? 'from_abort' + : 'all'; + logger.info('restartEncodeWithLastSettings:requested', { + jobId, + restartMode: requestedRestartMode + }); + this.cancelRequestedByJob.delete(Number(jobId)); + const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase(); + const AUTO_RECOVERY_TRIGGER_REASONS = new Set([ + 'failed_encode', + 'cancelled_encode', + 'server_restart', + 'confirm_auto_prepare' + ]); + const isAutoRecoveryTrigger = AUTO_RECOVERY_TRIGGER_REASONS.has(triggerReason); + const preserveJobIdOnAutoRecovery = isAutoRecoveryTrigger; + const shouldCreateReplacementJob = !preserveJobIdOnAutoRecovery && Boolean(options?.createNewJob); + + const job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId); + + const currentStatus = String(job.status || '').trim().toUpperCase(); + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK'].includes(currentStatus)) { + const error = new Error(`Encode-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); + error.statusCode = 409; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json); + if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) { + const error = new Error('Encode-Neustart nicht möglich: encode_plan fehlt.'); + error.statusCode = 400; + throw error; + } + const restartMakemkvInfo = this.safeParseJson(job.makemkv_info_json); + const explicitJobMediaType = String(job?.media_type || '').trim().toLowerCase(); + const derivedRestartMediaProfile = this.resolveMediaProfileForJob(job, { + makemkvInfo: restartMakemkvInfo, + encodePlan + }); + const preservedRestartMediaType = explicitJobMediaType || ( + ['converter', 'cd', 'audiobook'].includes(derivedRestartMediaProfile) + ? derivedRestartMediaProfile + : null + ); + + const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase(); + const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); + const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed); + const encodePreviouslySuccessful = String(this.safeParseJson(job.handbrake_info_json)?.status || '').trim().toUpperCase() === 'SUCCESS'; + const allowReviewBypass = !reviewConfirmed + && encodePreviouslySuccessful + && Array.isArray(encodePlan?.titles) + && encodePlan.titles.length > 0; + if (!reviewConfirmed && !allowReviewBypass) { + const error = new Error('Encode-Neustart nicht möglich: Spurauswahl wurde noch nicht bestätigt.'); + error.statusCode = 409; + throw error; + } + + const hasEncodableInput = isPreRipMode + ? Boolean(encodePlan?.encodeInputTitleId) + : Boolean(job.encode_input_path || encodePlan?.encodeInputPath || job.raw_path); + if (!hasEncodableInput) { + const error = new Error('Encode-Neustart nicht möglich: kein verwertbarer Encode-Input vorhanden.'); + error.statusCode = 400; + throw error; + } + + const settings = await settingsService.getSettingsMap(); + const restartDeleteIncompleteOutput = settings?.handbrake_restart_delete_incomplete_output !== undefined + ? Boolean(settings.handbrake_restart_delete_incomplete_output) + : true; + const effectiveRestartDeleteIncompleteOutput = restartDeleteIncompleteOutput && !isAutoRecoveryTrigger; + const handBrakeInfo = this.safeParseJson(job.handbrake_info_json); + const previousOutputPath = String(job.output_path || '').trim() || null; + + const keepBoth = Boolean(options?.keepBoth); + const deleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders : []; + + // Handle explicit folder deletion requested by the user + if (deleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, deleteFolders); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Encode-Neustart: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('restartEncodeWithLastSettings:delete-specific-folders-failed', { + jobId, + error: errorToMeta(error) + }); + } + } else if (previousOutputPath && effectiveRestartDeleteIncompleteOutput && !encodePreviouslySuccessful && !keepBoth) { + try { + const deleteResult = await historyService.deleteJobFiles(jobId, 'movie'); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Encode-Neustart: unvollständigen Output vor Start entfernt (movie files=${deleteResult?.summary?.movie?.filesDeleted ?? 0}, dirs=${deleteResult?.summary?.movie?.dirsRemoved ?? 0}).` + ); + } catch (error) { + logger.warn('restartEncodeWithLastSettings:delete-incomplete-output-failed', { + jobId, + outputPath: previousOutputPath, + error: errorToMeta(error) + }); + } + } + + let effectiveEncodePlan = encodePlan; + let effectiveRestartMode = requestedRestartMode; + let seriesBatchRestartSummary = null; + if (isSeriesBatchParentPlan(encodePlan)) { + const parentSelectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(encodePlan?.selectedTitleIds) + ? encodePlan.selectedTitleIds + : [encodePlan?.encodeInputTitleId] + ); + const orderedEpisodes = buildSeriesBatchEpisodesFromPlan(job, encodePlan, parentSelectedTitleIds) + .map((entry, index) => ({ + episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || (index + 1), + titleId: normalizeReviewTitleId(entry?.titleId) || parentSelectedTitleIds[index] || null, + status: normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'), + outputPath: String(entry?.outputPath || '').trim() || null + })) + .filter((entry) => Boolean(entry.titleId)) + .sort((left, right) => left.episodeIndex - right.episodeIndex); + + let selectedTitleIdsForRestart = parentSelectedTitleIds; + let restartFromEntry = null; + if (requestedRestartMode === 'from_abort' && orderedEpisodes.length > 0) { + restartFromEntry = orderedEpisodes.find((entry) => entry.status !== 'FINISHED') || null; + if (restartFromEntry) { + const startIndex = orderedEpisodes.findIndex((entry) => entry === restartFromEntry); + selectedTitleIdsForRestart = orderedEpisodes + .slice(startIndex >= 0 ? startIndex : 0) + .map((entry) => entry.titleId) + .filter(Boolean); + if (selectedTitleIdsForRestart.length === 0) { + selectedTitleIdsForRestart = parentSelectedTitleIds; + } + } else { + effectiveRestartMode = 'all'; + selectedTitleIdsForRestart = parentSelectedTitleIds; + } + } else { + effectiveRestartMode = 'all'; + } + + if (selectedTitleIdsForRestart.length > 0) { + const selectedTitleSet = new Set(selectedTitleIdsForRestart.map((id) => String(id))); + const selectionResult = applyEncodeTitleSelectionToPlan( + encodePlan, + selectedTitleIdsForRestart[0], + selectedTitleIdsForRestart + ); + let nextSeriesPlan = selectionResult.plan; + const episodeAssignmentsSource = nextSeriesPlan?.episodeAssignments && typeof nextSeriesPlan.episodeAssignments === 'object' + ? nextSeriesPlan.episodeAssignments + : {}; + const filteredEpisodeAssignments = Object.fromEntries( + Object.entries(episodeAssignmentsSource).filter(([titleId]) => selectedTitleSet.has(String(titleId))) + ); + const manualByTitleSource = nextSeriesPlan?.manualTrackSelectionByTitle + && typeof nextSeriesPlan.manualTrackSelectionByTitle === 'object' + ? nextSeriesPlan.manualTrackSelectionByTitle + : {}; + const filteredManualByTitle = Object.fromEntries( + Object.entries(manualByTitleSource).filter(([titleId]) => selectedTitleSet.has(String(titleId))) + ); + const activeRestartTitleId = normalizeReviewTitleId(nextSeriesPlan?.encodeInputTitleId) + || selectedTitleIdsForRestart[0] + || null; + const activeManualSelection = activeRestartTitleId + ? (filteredManualByTitle[String(activeRestartTitleId)] || null) + : null; + nextSeriesPlan = { + ...nextSeriesPlan, + episodeAssignments: filteredEpisodeAssignments, + manualTrackSelectionByTitle: filteredManualByTitle, + manualTrackSelection: activeManualSelection || nextSeriesPlan?.manualTrackSelection || null, + seriesBatchParent: false, + seriesBatchChild: false, + seriesBatchParentJobId: null, + seriesBatchChildJobIds: [], + seriesBatchDispatchedAt: null, + seriesBatchTotalChildren: selectedTitleIdsForRestart.length + }; + effectiveEncodePlan = nextSeriesPlan; + } + + if ( + effectiveRestartMode === 'from_abort' + && restartFromEntry + && effectiveRestartDeleteIncompleteOutput + && !keepBoth + ) { + const restartChildOutputPath = String(restartFromEntry?.outputPath || '').trim(); + const restartChildWasFinished = restartFromEntry.status === 'FINISHED'; + if (restartChildOutputPath && !restartChildWasFinished && fs.existsSync(restartChildOutputPath)) { + try { + fs.rmSync(restartChildOutputPath, { recursive: true, force: true }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Serien-Neustart ab Abbruch: Unvollständigen Episoden-Output entfernt (${restartChildOutputPath}).` + ); + } catch (deleteChildOutputError) { + logger.warn('restartEncodeWithLastSettings:series-child-output-delete-failed', { + jobId, + episodeIndex: restartFromEntry?.episodeIndex || null, + titleId: restartFromEntry?.titleId || null, + outputPath: restartChildOutputPath, + error: errorToMeta(deleteChildOutputError) + }); + } + } + } + + seriesBatchRestartSummary = { + mode: effectiveRestartMode, + selectedTitleIds: normalizeReviewTitleIdList( + Array.isArray(effectiveEncodePlan?.selectedTitleIds) + ? effectiveEncodePlan.selectedTitleIds + : [effectiveEncodePlan?.encodeInputTitleId] + ), + restartFromEpisodeIndex: restartFromEntry ? Number(restartFromEntry?.episodeIndex || 0) : null, + restartFromTitleId: restartFromEntry ? Number(restartFromEntry?.titleId || 0) : null + }; + } + + const restartPlan = { + ...effectiveEncodePlan, + reviewConfirmed: false, + reviewConfirmedAt: null, + prefilledFromPreviousRun: true, + prefilledFromPreviousRunAt: nowIso() + }; + const selectedMetadata = { + title: job.title || job.detected_title || null, + year: job.year || null, + imdbId: job.imdb_id || null, + poster: job.poster_url || null + }; + const readyMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: restartPlan + }); + const restartSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); + const resolvedRestartRawPath = this.resolveCurrentRawPathForSettings( + restartSettings, + readyMediaProfile, + job.raw_path + ); + let activeRestartRawPath = resolvedRestartRawPath || String(job.raw_path || '').trim() || null; + + let inputPath = isPreRipMode + ? null + : (job.encode_input_path || restartPlan.encodeInputPath || null); + if (!isPreRipMode && activeRestartRawPath) { + const needsInputRefresh = !inputPath + || !fs.existsSync(inputPath) + || !isPathInsideDirectory(activeRestartRawPath, inputPath); + if (needsInputRefresh) { + const selectedPlaylistId = normalizePlaylistId( + restartPlan?.selectedPlaylistId + || restartPlan?.selectedPlaylist + || null + ); + if (hasBluRayBackupStructure(activeRestartRawPath)) { + inputPath = activeRestartRawPath; + } else { + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylistId); + inputPath = findPreferredRawInput(activeRestartRawPath, { + playlistAnalysis: playlistDecision.playlistAnalysis, + selectedPlaylistId: selectedPlaylistId || playlistDecision.selectedPlaylist + })?.path || null; + } + } + } + restartPlan.encodeInputPath = inputPath; + const hasEncodableTitle = isPreRipMode + ? Boolean(restartPlan?.encodeInputTitleId) + : Boolean(inputPath); + + let replacementJobId = Number(jobId); + let replacedSourceJob = false; + if (shouldCreateReplacementJob) { + const replacementJob = await historyService.createJob({ + discDevice: job.disc_device || null, + status: 'READY_TO_ENCODE', + detectedTitle: job.detected_title || job.title || null, + jobKind: this.resolveJobKindForJob(job, { + encodePlan: restartPlan + }) + }); + replacementJobId = Number(replacementJob?.id || 0); + if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { + throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + replacedSourceJob = true; + + const previousRestartRawPath = activeRestartRawPath; + activeRestartRawPath = await this.alignRawFolderJobId(activeRestartRawPath, replacementJobId); + if ( + previousRestartRawPath + && activeRestartRawPath + && normalizeComparablePath(previousRestartRawPath) !== normalizeComparablePath(activeRestartRawPath) + ) { + inputPath = remapPathToRetargetedRawRoot(inputPath, previousRestartRawPath, activeRestartRawPath); + restartPlan.encodeInputPath = inputPath; + } + } + + const replacementJobPatch = { + media_type: preservedRestartMediaType, + title: job.title || null, + year: job.year ?? null, + imdb_id: job.imdb_id || null, + poster_url: job.poster_url || null, + omdb_json: null, + selected_from_omdb: 0, + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + end_time: null, + output_path: null, + disc_device: job.disc_device || null, + raw_path: activeRestartRawPath || null, + rip_successful: Number(job.rip_successful || 0), + makemkv_info_json: job.makemkv_info_json || null, + handbrake_info_json: null, + mediainfo_info_json: job.mediainfo_info_json || null, + encode_plan_json: JSON.stringify(restartPlan), + encode_input_path: inputPath, + encode_review_confirmed: 0 + }; + if (replacedSourceJob) { + replacementJobPatch.parent_job_id = Number(jobId); + } else { + replacementJobPatch.start_time = null; + } + await historyService.updateJob(replacementJobId, replacementJobPatch); + + // Keep local poster thumbnails valid for the replacement job id. + if (replacedSourceJob && thumbnailService.isLocalUrl(job.poster_url)) { + const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); + if (copiedUrl) { + await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); + } + } + const restartModeDetails = seriesBatchRestartSummary + ? ( + seriesBatchRestartSummary.mode === 'from_abort' + ? ` Serienmodus: Neu starten ab Abbruch (ab Episode #${seriesBatchRestartSummary.restartFromEpisodeIndex || '-'}${seriesBatchRestartSummary.restartFromTitleId ? ` | Titel ${seriesBatchRestartSummary.restartFromTitleId}` : ''}).` + : ` Serienmodus: Alle neu starten (${seriesBatchRestartSummary.selectedTitleIds.length} Episode(n)).` + ) + : ''; + const loadedSelectionText = ( + previousOutputPath + ? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${effectiveRestartDeleteIncompleteOutput ? 'on' : 'off'}.${restartModeDetails}` + : `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.${restartModeDetails}` + ); + let restartLogMessage; + if (triggerReason === 'cancelled_encode') { + restartLogMessage = `Encode wurde abgebrochen. ${loadedSelectionText}`; + } else if (triggerReason === 'failed_encode') { + restartLogMessage = `Encode ist fehlgeschlagen. ${loadedSelectionText}`; + } else if (triggerReason === 'server_restart') { + restartLogMessage = `Server-Neustart während Encode erkannt. ${loadedSelectionText}`; + } else if (triggerReason === 'confirm_auto_prepare') { + restartLogMessage = `Status war nicht READY_TO_ENCODE. ${loadedSelectionText}`; + } else { + restartLogMessage = `Encode-Neustart angefordert. ${loadedSelectionText}`; + } + await historyService.appendLog(replacementJobId, 'USER_ACTION', restartLogMessage); + if (replacedSourceJob && preservedRestartMediaType && !explicitJobMediaType) { + await historyService.updateJob(jobId, { media_type: preservedRestartMediaType }).catch(() => {}); + } + if (replacedSourceJob) { + await historyService.retireJobInFavorOf(jobId, replacementJobId, { + reason: 'restart_encode' + }); + } + + await this.setState('READY_TO_ENCODE', { + activeJobId: replacementJobId, + progress: 0, + eta: null, + statusText: hasEncodableTitle + ? (isPreRipMode + ? 'Vorherige Spurauswahl geladen - anpassen und Backup/Rip + Encode starten' + : ( + `Vorherige Encode-Auswahl geladen - anpassen und Encoding starten${seriesBatchRestartSummary?.mode === 'from_abort' ? ' (ab Abbruch)' : ''}` + )) + : (isPreRipMode + ? 'Vorherige Spurauswahl geladen - kein passender Titel gewählt' + : 'Vorherige Encode-Auswahl geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'), + context: { + ...(this.snapshot.context || {}), + jobId: replacementJobId, + rawPath: activeRestartRawPath, + inputPath, + hasEncodableTitle, + reviewConfirmed: false, + mode, + mediaProfile: readyMediaProfile, + sourceJobId: Number(jobId), + selectedMetadata, + mediaInfoReview: restartPlan + } + }); + + return { + restarted: true, + started: false, + stage: 'READY_TO_ENCODE', + reviewConfirmed: false, + sourceJobId: Number(jobId), + jobId: replacementJobId, + replacedSourceJob, + restartMode: seriesBatchRestartSummary?.mode || effectiveRestartMode + }; + } + + async restartReviewFromRaw(jobId, options = {}) { + const resolvedRestartReviewJob = await this.resolveExistingJobForAction(jobId, 'restart_review'); + jobId = resolvedRestartReviewJob.resolvedJobId; + this.ensureNotBusy('restartReviewFromRaw', jobId); + logger.info('restartReviewFromRaw:requested', { jobId, options }); + this.cancelRequestedByJob.delete(Number(jobId)); + + const sourceJob = resolvedRestartReviewJob.job || await historyService.getJobById(jobId); + + if (!sourceJob.raw_path) { + const error = new Error('Review-Neustart nicht möglich: raw_path fehlt.'); + error.statusCode = 400; + throw error; + } + + const reviewMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); + const reviewEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); + const reviewMediaProfile = this.resolveMediaProfileForJob(sourceJob, { + makemkvInfo: reviewMakemkvInfo, + encodePlan: reviewEncodePlan, + rawPath: sourceJob.raw_path + }); + const explicitSourceMediaType = String(sourceJob?.media_type || '').trim().toLowerCase(); + const preservedReviewMediaType = explicitSourceMediaType || ( + ['converter', 'cd', 'audiobook'].includes(reviewMediaProfile) ? reviewMediaProfile : null + ); + const reviewSettings = await settingsService.getSettingsMap(); + let resolvedReviewRawPath = this.resolveCurrentRawPathForSettings( + reviewSettings, + reviewMediaProfile, + sourceJob.raw_path + ); + if (!resolvedReviewRawPath) { + const storedRawPath = String(sourceJob.raw_path || '').trim(); + const storedFolderName = path.basename(storedRawPath); + const rawState = resolveRawFolderStateFromPath(storedRawPath); + const strippedFolderName = stripRawStatePrefix(storedFolderName); + const metadataBase = strippedFolderName.replace(/\s-\sRAW\s-\sjob-\d+\s*$/i, '').trim(); + const recoveryRoots = Array.from(new Set([ + path.dirname(storedRawPath), + String(reviewMakemkvInfo?.rawPath || '').trim(), + String(reviewMakemkvInfo?.importContext?.requestedRawPath || '').trim() + ].filter(Boolean))); + + let recoveredRawPath = null; + for (const rootCandidate of recoveryRoots) { + const rootDir = fs.existsSync(rootCandidate) && fs.statSync(rootCandidate).isDirectory() + ? rootCandidate + : path.dirname(rootCandidate); + if (!rootDir || !fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) { + continue; + } + if (metadataBase) { + const byMetadata = findExistingRawDirectory(rootDir, metadataBase); + if (byMetadata) { + recoveredRawPath = byMetadata; + break; + } + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (normalizedJobId) { + const targetedFolder = buildRawDirName(metadataBase, normalizedJobId, { state: rawState }); + const targetedPath = path.join(rootDir, targetedFolder); + if (fs.existsSync(targetedPath) && fs.statSync(targetedPath).isDirectory()) { + recoveredRawPath = targetedPath; + break; + } + } + } + } + + if (recoveredRawPath) { + resolvedReviewRawPath = recoveredRawPath; + await historyService.updateJob(jobId, { raw_path: recoveredRawPath }).catch(() => {}); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Review-Neustart: RAW-Pfad automatisch korrigiert: ${sourceJob.raw_path || '-'} -> ${recoveredRawPath}` + ).catch(() => {}); + } else { + const error = new Error(`Review-Neustart nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + } + + const resolvedReviewInput = hasBluRayBackupStructure(resolvedReviewRawPath) + ? { path: resolvedReviewRawPath } + : findPreferredRawInput(resolvedReviewRawPath); + const hasRawInput = Boolean(resolvedReviewInput?.path); + if (!hasRawInput) { + let hasAnyRawEntries = false; + try { + hasAnyRawEntries = fs.readdirSync(resolvedReviewRawPath).length > 0; + } catch (_error) { + hasAnyRawEntries = false; + } + if (!hasAnyRawEntries) { + const error = new Error('Review-Neustart nicht möglich: keine Mediendateien im RAW-Pfad gefunden. Disc muss zuerst gerippt werden.'); + error.statusCode = 400; + throw error; + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Review-Neustart: keine direkten Mediendateien erkannt, versuche Analyse trotzdem mit RAW-Pfad ${resolvedReviewRawPath}.` + ); + } + + const existingEncodeInputPath = String(sourceJob.encode_input_path || '').trim() || null; + const shouldRealignEncodeInput = Boolean( + resolvedReviewInput?.path + && ( + !existingEncodeInputPath + || !fs.existsSync(existingEncodeInputPath) + || isEncodeInputMismatchedWithRaw(resolvedReviewRawPath, existingEncodeInputPath) + ) + ); + const normalizedReviewInputPath = shouldRealignEncodeInput + ? resolvedReviewInput.path + : existingEncodeInputPath; + + const currentStatus = String(sourceJob.status || '').trim().toUpperCase(); + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(currentStatus)) { + const error = new Error(`Review-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); + error.statusCode = 409; + throw error; + } + + const reviewDeleteFolders = Array.isArray(options?.deleteFolders) + ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + if (reviewDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, reviewDeleteFolders); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Review-Neustart: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('restartReviewFromRaw:delete-specific-folders-failed', { + jobId, + error: errorToMeta(error) + }); + } + } + + const staleQueueIndex = this.findQueueEntryIndexByJobId(Number(jobId)); + let removedQueueActionLabel = null; + if (staleQueueIndex >= 0) { + const [removed] = this.queueEntries.splice(staleQueueIndex, 1); + removedQueueActionLabel = QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'; + await this.emitQueueChanged(); + } + + const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection); + const previousEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); + const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); + const nextMakemkvInfoJson = mkInfo && typeof mkInfo === 'object' + ? JSON.stringify({ + ...mkInfo, + rawPath: resolvedReviewRawPath, + analyzeContext: { + ...(mkInfo?.analyzeContext || {}), + playlistAnalysis: null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + handBrakePlaylistScan: null + }, + postBackupAnalyze: null + }) + : sourceJob.makemkv_info_json; + + const jobUpdatePayload = { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + start_time: nowIso(), + end_time: null, + error_message: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: normalizedReviewInputPath || null, + encode_review_confirmed: 0, + makemkv_info_json: nextMakemkvInfoJson, + raw_path: resolvedReviewRawPath + }; + + const reuseCurrentJob = Object.prototype.hasOwnProperty.call(options || {}, 'reuseCurrentJob') + ? Boolean(options?.reuseCurrentJob) + : !Boolean(options?.createNewJob); + const inheritedSourceJobId = this.normalizeQueueJobId(previousEncodePlan?.sourceJobId) + || this.normalizeQueueJobId(sourceJob.parent_job_id) + || null; + const reviewSourceJobId = reuseCurrentJob + ? inheritedSourceJobId + : Number(jobId); + let targetJobId = Number(jobId); + let replacedSourceJob = false; + let activeReviewRawPath = resolvedReviewRawPath; + let activeReviewInputPath = normalizedReviewInputPath || null; + + if (reuseCurrentJob) { + await resetProcessLogIfLifecycleAllows(targetJobId, sourceJob); + await historyService.updateJob(targetJobId, { + ...jobUpdatePayload, + media_type: preservedReviewMediaType, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: activeReviewInputPath, + encode_review_confirmed: 0 + }); + } else { + const replacementJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: 'MEDIAINFO_CHECK', + detectedTitle: sourceJob.detected_title || sourceJob.title || null, + jobKind: this.resolveJobKindForJob(sourceJob, { + encodePlan: previousEncodePlan + }) + }); + const replacementJobId = Number(replacementJob?.id || 0); + if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { + throw new Error('Review-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + + const previousReviewRawPath = activeReviewRawPath; + activeReviewRawPath = await this.alignRawFolderJobId(activeReviewRawPath, replacementJobId); + if ( + previousReviewRawPath + && activeReviewRawPath + && normalizeComparablePath(previousReviewRawPath) !== normalizeComparablePath(activeReviewRawPath) + ) { + activeReviewInputPath = remapPathToRetargetedRawRoot( + activeReviewInputPath, + previousReviewRawPath, + activeReviewRawPath + ); + } + + await historyService.updateJob(replacementJobId, { + parent_job_id: Number(jobId), + media_type: preservedReviewMediaType, + title: sourceJob.title || null, + year: sourceJob.year ?? null, + imdb_id: sourceJob.imdb_id || null, + poster_url: sourceJob.poster_url || null, + omdb_json: null, + selected_from_omdb: 0, + disc_device: sourceJob.disc_device || null, + rip_successful: Number(sourceJob.rip_successful || 0), + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: activeReviewInputPath, + encode_review_confirmed: 0, + ...jobUpdatePayload, + raw_path: activeReviewRawPath, + encode_input_path: activeReviewInputPath + }); + + // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist + if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { + const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); + if (copiedUrl) { + await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); + } + } + + targetJobId = replacementJobId; + replacedSourceJob = true; + } + + if (removedQueueActionLabel) { + await historyService.appendLog( + targetJobId, + 'USER_ACTION', + `Queue-Eintrag entfernt (Review-Neustart): ${removedQueueActionLabel}` + ); + } + if (shouldRealignEncodeInput) { + await historyService.appendLog( + targetJobId, + 'SYSTEM', + `Review-Neustart: Encode-Input auf aktuellen RAW-Pfad abgeglichen: ${existingEncodeInputPath || '-'} -> ${activeReviewInputPath || '-'}` + ); + } + await historyService.appendLog( + targetJobId, + 'USER_ACTION', + `Review-Neustart aus RAW angefordert.${reuseCurrentJob ? ' Bestehende Job-ID wird weiterverwendet.' : ''}${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''} MakeMKV Full-Analyse wird vollständig neu ausgeführt.` + ); + if (!reuseCurrentJob && preservedReviewMediaType && !explicitSourceMediaType) { + await historyService.updateJob(jobId, { media_type: preservedReviewMediaType }).catch(() => {}); + } + if (!reuseCurrentJob) { + await historyService.retireJobInFavorOf(jobId, targetJobId, { + reason: 'restart_review' + }); + } + + await this.setState('MEDIAINFO_CHECK', { + activeJobId: targetJobId, + progress: 0, + eta: null, + statusText: 'Titel-/Spurprüfung wird neu gestartet...', + context: { + ...(this.snapshot.context || {}), + jobId: targetJobId, + reviewConfirmed: false, + mediaInfoReview: null + } + }); + + this.runReviewForRawJob(targetJobId, activeReviewRawPath, { + mode: options?.mode || 'reencode', + sourceJobId: reviewSourceJobId, + forcePlaylistReselection, + forceFreshAnalyze: true, + previousEncodePlan + }).catch((error) => { + logger.error('restartReviewFromRaw:background-failed', { jobId: targetJobId, sourceJobId: jobId, error: errorToMeta(error) }); + this.failJob(targetJobId, 'MEDIAINFO_CHECK', error).catch((failError) => { + logger.error('restartReviewFromRaw:background-failJob-failed', { + jobId: targetJobId, + error: errorToMeta(failError) + }); + }); + }); + + return { + restarted: true, + started: true, + stage: 'MEDIAINFO_CHECK', + sourceJobId: Number(jobId), + jobId: targetJobId, + replacedSourceJob + }; + } + + async cancelSeriesBatchParentJob(parentJobId, parentJob = null) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + const error = new Error('Ungültige Serien-Batch Job-ID.'); + error.statusCode = 400; + throw error; + } + + const sourceParentJob = parentJob && Number(parentJob?.id) === Number(normalizedParentJobId) + ? parentJob + : await historyService.getJobById(normalizedParentJobId); + if (!sourceParentJob || !this.isSeriesBatchParentQueueAnchor(sourceParentJob)) { + return { + cancelled: false, + seriesBatch: false, + jobId: normalizedParentJobId + }; + } + + const reason = 'Serien-Batch vom Benutzer abgebrochen.'; + const seriesChildJobs = await this.listSeriesBatchChildJobs(normalizedParentJobId); + const seriesChildJobIds = normalizeReviewTitleIdList( + seriesChildJobs.map((childJob) => normalizePositiveInteger(childJob?.id)).filter(Boolean) + ); + const seriesChildJobIdSet = new Set(seriesChildJobIds.map((value) => Number(value))); + const queueEntriesBefore = this.queueEntries.length; + this.queueEntries = this.queueEntries.filter((entry) => { + const action = String(entry?.action || '').trim().toUpperCase(); + const entryType = String(entry?.type || 'job').trim().toLowerCase(); + const entryJobId = Number(entry?.jobId); + const entrySourceJobId = this.normalizeQueueJobId(entry?.sourceJobId); + const isLegacyParentEntry = ( + entryJobId === Number(normalizedParentJobId) + && action === QUEUE_ACTIONS.START_SERIES_EPISODE + ); + const isSeriesChildJobEntry = ( + (!entry?.type || entry.type === 'job') + && Number.isFinite(entryJobId) + && entryJobId > 0 + && seriesChildJobIdSet.has(entryJobId) + ); + const isDetachedSeriesAutomationEntry = ( + (entryType === 'script' || entryType === 'chain') + && entrySourceJobId + && ( + Number(entrySourceJobId) === Number(normalizedParentJobId) + || seriesChildJobIdSet.has(Number(entrySourceJobId)) + ) + ); + return !(isLegacyParentEntry || isSeriesChildJobEntry || isDetachedSeriesAutomationEntry); + }); + const removedQueueEntries = Math.max(0, queueEntriesBefore - this.queueEntries.length); + + const sourcePlan = this.safeParseJson(sourceParentJob.encode_plan_json); + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(sourcePlan?.selectedTitleIds) + ? sourcePlan.selectedTitleIds + : (Array.isArray(sourcePlan?.seriesBatchEpisodes) + ? sourcePlan.seriesBatchEpisodes.map((entry) => entry?.titleId) + : []) + ); + const sourceEpisodes = buildSeriesBatchEpisodesFromPlan( + sourceParentJob, + sourcePlan, + selectedTitleIds + ); + const cancelledEpisodes = sourceEpisodes.map((entry) => { + const status = normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'); + if (status === 'FINISHED' || status === 'ERROR' || status === 'CANCELLED') { + return entry; + } + return { + ...entry, + status: 'CANCELLED', + eta: null, + finishedAt: nowIso(), + error: reason + }; + }); + const nextPlan = { + ...(sourcePlan && typeof sourcePlan === 'object' ? sourcePlan : {}), + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchChildJobIds: [], + seriesBatchEpisodes: cancelledEpisodes + }; + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, cancelledEpisodes); + const previousHandbrakeInfo = this.safeParseJson(sourceParentJob.handbrake_info_json) || {}; + const nextHandbrakeInfo = { + ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), + mode: 'series_batch', + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + episodes: cancelledEpisodes + } + }; + + let cancelledChildRunning = 0; + let cancelledChildQueued = 0; + const softCancelableChildStates = new Set([ + 'READY_TO_START', + 'READY_TO_ENCODE', + 'METADATA_LOOKUP', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION' + ]); + const currentChildJobsById = new Map( + seriesChildJobs.map((childJob) => [Number(childJob?.id), childJob]) + ); + for (const childJobIdRaw of seriesChildJobIds) { + const childJobId = this.normalizeQueueJobId(childJobIdRaw); + if (!childJobId) { + continue; + } + const childJob = currentChildJobsById.get(Number(childJobId)) || null; + const childStatus = String( + childJob?.status + || childJob?.last_state + || '' + ).trim().toUpperCase(); + if (isTerminalStatus(childStatus)) { + continue; + } + const hasActiveHandle = this.activeProcesses.has(Number(childJobId)); + if (hasActiveHandle || RUNNING_STATES.has(childStatus)) { + try { + await this.cancel(childJobId); + cancelledChildRunning += 1; + } catch (cancelChildError) { + logger.warn('series-batch:cancel:child-cancel-failed', { + parentJobId: normalizedParentJobId, + childJobId, + error: errorToMeta(cancelChildError) + }); + } + continue; + } + if (softCancelableChildStates.has(childStatus)) { + await historyService.updateJob(childJobId, { + status: 'CANCELLED', + last_state: childStatus || 'CANCELLED', + end_time: nowIso(), + error_message: reason + }); + await historyService.appendLog( + childJobId, + 'USER_ACTION', + reason + ); + this.jobProgress.delete(Number(childJobId)); + this.cancelRequestedByJob.delete(Number(childJobId)); + this.seriesBatchParentByChild.delete(Number(childJobId)); + cancelledChildQueued += 1; + } + } + + const processHandle = this.activeProcesses.get(normalizedParentJobId) || null; + if (processHandle) { + this.cancelRequestedByJob.add(Number(normalizedParentJobId)); + try { + processHandle.cancel(); + } catch (_error) { + // ignore cancel race errors + } + } + + this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + for (const childJobId of seriesChildJobIdSet) { + this.seriesBatchParentByChild.delete(Number(childJobId)); + } + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: 'CANCELLED', + last_state: 'CANCELLED', + end_time: nowIso(), + error_message: reason + }); + await historyService.appendLog( + normalizedParentJobId, + 'USER_ACTION', + `${reason} Episoden: ${summary.totalCount}, Queue-Einträge entfernt: ${removedQueueEntries}, aktive Child-Abbrüche: ${cancelledChildRunning}, queued Child-Abbrüche: ${cancelledChildQueued}, aktiver Episode-Run am Parent: ${processHandle ? 'ja' : 'nein'}.` + ); + await this.updateProgress('CANCELLED', summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + children: summary.children + } + } + }); + await this.emitQueueChanged(); + return { + cancelled: true, + queuedOnly: false, + seriesBatch: true, + jobId: normalizedParentJobId, + childCount: summary.totalCount, + removedQueueEntries + }; + } + + async cancel(jobId = null) { + const normalizedJobId = this.normalizeQueueJobId(jobId) + || this.normalizeQueueJobId(this.snapshot.activeJobId) + || this.normalizeQueueJobId(this.snapshot.context?.jobId) + || this.normalizeQueueJobId(Array.from(this.activeProcesses.keys())[0]); + + if (!normalizedJobId) { + const error = new Error('Kein laufender Prozess zum Abbrechen.'); + error.statusCode = 409; + throw error; + } + + const targetJob = await historyService.getJobById(normalizedJobId).catch(() => null); + if (targetJob && this.isSeriesBatchParentQueueAnchor(targetJob)) { + return this.cancelSeriesBatchParentJob(normalizedJobId, targetJob); + } + + const processHandle = this.activeProcesses.get(normalizedJobId) || null; + if (!processHandle) { + const queuedIndex = this.findQueueEntryIndexByJobId(normalizedJobId); + if (queuedIndex >= 0) { + const [removed] = this.queueEntries.splice(queuedIndex, 1); + const removedDetachedEntries = this.removeDetachedQueueAutomationEntriesForJob(normalizedJobId); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}` + + (removedDetachedEntries > 0 + ? ` (inkl. ${removedDetachedEntries} verknüpften Skript/Ketten-Eintrag${removedDetachedEntries === 1 ? '' : 'en'}).` + : '') + ); + await this.emitQueueChanged(); + return { + cancelled: true, + queuedOnly: true, + jobId: normalizedJobId + }; + } + } + + const buildForcedCancelError = (message) => { + const reason = String(message || 'Vom Benutzer hart abgebrochen.').trim() || 'Vom Benutzer hart abgebrochen.'; + const endedAt = nowIso(); + const error = new Error(reason); + error.statusCode = 409; + error.runInfo = { + source: 'USER_CANCEL', + stage: this.snapshot.state || null, + cmd: null, + args: [], + startedAt: endedAt, + endedAt, + durationMs: 0, + status: 'CANCELLED', + exitCode: null, + stdoutLines: 0, + stderrLines: 0, + lastProgress: 0, + eta: null, + lastDetail: null, + highlights: [] + }; + return error; + }; + + const forceFinalizeCancelledJob = async (reason, stageHint = null) => { + const rawStage = String(stageHint || this.snapshot.state || '').trim().toUpperCase(); + const effectiveStage = RUNNING_STATES.has(rawStage) + ? rawStage + : ( + RUNNING_STATES.has(String(this.snapshot.state || '').trim().toUpperCase()) + ? String(this.snapshot.state || '').trim().toUpperCase() + : 'ENCODING' + ); + try { + await historyService.appendLog(normalizedJobId, 'USER_ACTION', reason); + } catch (_error) { + // continue with force-cancel even if logging failed + } + try { + await this.failJob(normalizedJobId, effectiveStage, buildForcedCancelError(reason)); + } catch (forceError) { + logger.error('cancel:force-finalize:failed', { + jobId: normalizedJobId, + stage: effectiveStage, + reason, + error: errorToMeta(forceError) + }); + const fallbackJob = await historyService.getJobById(normalizedJobId); + await historyService.updateJob(normalizedJobId, { + status: 'CANCELLED', + last_state: 'CANCELLED', + end_time: nowIso(), + error_message: reason + }); + await this.setState('CANCELLED', { + activeJobId: normalizedJobId, + progress: this.snapshot.progress, + eta: null, + statusText: reason, + context: { + jobId: normalizedJobId, + rawPath: fallbackJob?.raw_path || null, + error: reason, + canRestartReviewFromRaw: Boolean(fallbackJob?.raw_path) + } + }); + } finally { + this.cancelRequestedByJob.delete(normalizedJobId); + this.activeProcesses.delete(normalizedJobId); + this.syncPrimaryActiveProcess(); + } + return { + cancelled: true, + queuedOnly: false, + forced: true, + jobId: normalizedJobId + }; + }; + + const runningJob = await historyService.getJobById(normalizedJobId); + const runningStatus = String( + runningJob?.status + || runningJob?.last_state + || this.snapshot.state + || '' + ).trim().toUpperCase(); + const SOFT_CANCELABLE_STATES = new Set([ + 'READY_TO_START', + 'READY_TO_ENCODE', + 'METADATA_LOOKUP', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'CD_METADATA_SELECTION', + 'CD_READY_TO_RIP' + ]); + + if (SOFT_CANCELABLE_STATES.has(runningStatus)) { + // Review-/Ready-Phasen werden immer soft-cancelled, selbst wenn + // versehentlich ein veralteter Process-Handle vorhanden ist. + const cancelMessage = 'Vom Benutzer abgebrochen.'; + if (processHandle) { + try { + processHandle.cancel(); + } catch (_error) { + // ignore stale cancel race + } + this.activeProcesses.delete(normalizedJobId); + this.syncPrimaryActiveProcess(); + } + + await historyService.updateJob(normalizedJobId, { + status: 'CANCELLED', + // Preserve origin state so ripper/history can distinguish review-cancelled rows. + last_state: runningStatus, + end_time: nowIso(), + error_message: cancelMessage + }); + + // Soft-cancel must always drop any stale drive lock for this job. + this._releaseDriveLockForJob(normalizedJobId, { reason: 'soft_cancel' }); + + const cdDriveForJob = this._getCdDriveByJobId(normalizedJobId); + if (cdDriveForJob?.devicePath) { + if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) { + this._removeCdDrive(cdDriveForJob.devicePath); + } else { + this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null }); + } + } else { + this.jobProgress.delete(normalizedJobId); + } + + this.cancelRequestedByJob.delete(normalizedJobId); + await historyService.appendLog(normalizedJobId, 'USER_ACTION', `Abbruch im Status ${runningStatus}.`); + await this.setState('CANCELLED', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: cancelMessage, + context: { + jobId: normalizedJobId, + rawPath: runningJob?.raw_path || null, + error: cancelMessage, + cancelledFromState: runningStatus, + canRestartReviewFromRaw: Boolean(runningJob?.raw_path) + } + }); + return { cancelled: true, queuedOnly: false, jobId: normalizedJobId }; + } + + if (!processHandle) { + + if (RUNNING_STATES.has(runningStatus)) { + return forceFinalizeCancelledJob( + `Abbruch erzwungen: kein aktiver Prozess-Handle gefunden (Status ${runningStatus}).`, + runningStatus + ); + } + + const error = new Error(`Kein laufender Prozess für Job #${normalizedJobId} zum Abbrechen.`); + error.statusCode = 409; + throw error; + } + + let removedQueuedActionLabel = null; + const staleQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); + if (staleQueueIndex >= 0) { + const [removed] = this.queueEntries.splice(staleQueueIndex, 1); + removedQueuedActionLabel = QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'; + await this.emitQueueChanged(); + try { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `Veralteter Queue-Eintrag beim Abbruch entfernt: ${removedQueuedActionLabel}` + ); + } catch (_error) { + // keep cancel flow even if stale queue entry logging fails + } + } + + logger.warn('cancel:requested', { + state: this.snapshot.state, + activeJobId: this.snapshot.activeJobId, + requestedJobId: normalizedJobId, + pid: processHandle?.child?.pid || null, + removedQueuedAction: removedQueuedActionLabel + }); + this.cancelRequestedByJob.add(normalizedJobId); + processHandle.cancel(); + try { + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Abbruch angefordert (hard-cancel). Status=${runningStatus || '-'}.` + ); + } catch (_error) { + // keep hard-cancel flow even if logging fails + } + + const settleResult = await Promise.race([ + Promise.resolve(processHandle.promise) + .then(() => 'settled') + .catch(() => 'settled'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 2200)) + ]); + const stillActive = this.activeProcesses.has(normalizedJobId); + if (settleResult === 'settled' && !stillActive) { + return { + cancelled: true, + queuedOnly: false, + jobId: normalizedJobId + }; + } + + logger.error('cancel:hard-timeout', { + jobId: normalizedJobId, + runningStatus, + settleResult, + stillActive, + pid: processHandle?.child?.pid || null + }); + try { + processHandle.cancel(); + } catch (_error) { + // ignore second cancel errors + } + const childPid = Number(processHandle?.child?.pid); + if (Number.isFinite(childPid) && childPid > 0) { + try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ } + try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ } + } + try { + processHandle?.child?.kill?.('SIGKILL'); + } catch (_error) { + // noop + } + this.activeProcesses.delete(normalizedJobId); + this.syncPrimaryActiveProcess(); + return forceFinalizeCancelledJob( + `Abbruch erzwungen: Prozess reagierte nicht rechtzeitig auf Kill-Signal (Status ${runningStatus || '-'}).`, + runningStatus + ); + } + + async runCommand({ + jobId, + stage, + source, + cmd, + args, + parser, + collectLines = null, + collectStdoutLines = true, + collectStderrLines = true, + argsForLog = null, + silent = false, + lineFilter = null, + onStdoutLine = null, + onStderrLine = null + }) { + const normalizedJobId = this.normalizeQueueJobId(jobId) || Number(jobId) || jobId; + const loggableArgs = Array.isArray(argsForLog) ? argsForLog : args; + if (this.cancelRequestedByJob.has(Number(normalizedJobId))) { + const cancelError = new Error('Job wurde vom Benutzer abgebrochen.'); + cancelError.statusCode = 409; + const endedAt = nowIso(); + cancelError.runInfo = { + source, + stage, + cmd, + args: loggableArgs, + startedAt: endedAt, + endedAt, + durationMs: 0, + status: 'CANCELLED', + exitCode: null, + stdoutLines: 0, + stderrLines: 0, + stdoutTail: '', + stderrTail: '', + stdoutTruncated: false, + stderrTruncated: false, + lastProgress: 0, + eta: null, + lastDetail: null, + highlights: [] + }; + logger.warn('command:cancelled-before-spawn', { jobId: normalizedJobId, stage, source }); + throw cancelError; + } + + await historyService.appendLog(jobId, 'SYSTEM', `Spawn ${cmd} ${loggableArgs.join(' ')}`); + logger.info('command:spawn', { jobId, stage, source, cmd, args: loggableArgs }); + + const runInfo = { + source, + stage, + cmd, + args: loggableArgs, + startedAt: nowIso(), + endedAt: null, + durationMs: null, + status: 'RUNNING', + exitCode: null, + stdoutLines: 0, + stderrLines: 0, + stdoutTail: '', + stderrTail: '', + stdoutTruncated: false, + stderrTruncated: false, + lastProgress: 0, + eta: null, + lastDetail: null, + highlights: [] + }; + + const applyLine = (line, isStderr) => { + const text = truncateLine(line, 400); + if (isStderr) { + runInfo.stderrLines += 1; + } else { + runInfo.stdoutLines += 1; + } + + const detail = extractProgressDetail(source, text); + if (detail) { + runInfo.lastDetail = detail; + } + + if (runInfo.highlights.length < 120 && shouldKeepHighlight(text)) { + runInfo.highlights.push(text); + } + + if (parser && !silent) { + const progress = parser(text); + if (progress && progress.percent !== null) { + runInfo.lastProgress = progress.percent; + runInfo.eta = progress.eta || runInfo.eta; + const statusText = composeStatusText(stage, progress.percent, runInfo.lastDetail); + const contextPatch = progress.contextPatch && typeof progress.contextPatch === 'object' + && !Array.isArray(progress.contextPatch) + ? progress.contextPatch + : null; + void this.updateProgress( + stage, + progress.percent, + progress.eta, + statusText, + normalizedJobId, + contextPatch ? { contextPatch } : undefined + ); + } else if (detail) { + const jobEntry = this.jobProgress.get(Number(normalizedJobId)); + const currentProgress = jobEntry?.progress ?? Number(this.snapshot.progress || 0); + const currentEta = jobEntry?.eta ?? runInfo.eta ?? null; + const statusText = composeStatusText(stage, currentProgress, runInfo.lastDetail); + void this.updateProgress(stage, currentProgress, currentEta, statusText, normalizedJobId); + } + } + }; + + const processHandle = spawnTrackedProcess({ + cmd, + args, + context: { jobId, stage, source }, + onStdoutLine: (line) => { + const keepLine = typeof lineFilter === 'function' ? lineFilter(line, 'stdout') !== false : true; + if (!keepLine) { + return; + } + if (collectLines && collectStdoutLines) { + collectLines.push(line); + } + void historyService.appendProcessLog(jobId, source, line); + const nextStdout = appendTailText(runInfo.stdoutTail, line); + runInfo.stdoutTail = nextStdout.value; + runInfo.stdoutTruncated = runInfo.stdoutTruncated || nextStdout.truncated; + if (typeof onStdoutLine === 'function') { + try { + onStdoutLine(line); + } catch (_error) { + // ignore observer failures for live runtime mirroring + } + } + applyLine(line, false); + }, + onStderrLine: (line) => { + const keepLine = typeof lineFilter === 'function' ? lineFilter(line, 'stderr') !== false : true; + if (!keepLine) { + return; + } + if (collectLines && collectStderrLines) { + collectLines.push(line); + } + void historyService.appendProcessLog(jobId, `${source}_ERR`, line); + const nextStderr = appendTailText(runInfo.stderrTail, line); + runInfo.stderrTail = nextStderr.value; + runInfo.stderrTruncated = runInfo.stderrTruncated || nextStderr.truncated; + if (typeof onStderrLine === 'function') { + try { + onStderrLine(line); + } catch (_error) { + // ignore observer failures for live runtime mirroring + } + } + applyLine(line, true); + } + }); + const normalizedProcessKey = Number(normalizedJobId); + const previousProcessHandle = this.activeProcesses.get(normalizedProcessKey) || null; + this.activeProcesses.set(normalizedProcessKey, processHandle); + this.syncPrimaryActiveProcess(); + + try { + const procResult = await processHandle.promise; + runInfo.status = 'SUCCESS'; + runInfo.exitCode = procResult.code; + runInfo.endedAt = nowIso(); + runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime(); + await historyService.appendLog(jobId, 'SYSTEM', `${source} abgeschlossen.`); + logger.info('command:completed', { jobId, stage, source }); + return runInfo; + } catch (error) { + if (this.cancelRequestedByJob.has(Number(normalizedJobId))) { + const cancelError = new Error('Job wurde vom Benutzer abgebrochen.'); + cancelError.statusCode = 409; + runInfo.status = 'CANCELLED'; + runInfo.exitCode = null; + runInfo.endedAt = nowIso(); + runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime(); + cancelError.runInfo = runInfo; + logger.warn('command:cancelled', { jobId, stage, source }); + throw cancelError; + } + runInfo.status = 'ERROR'; + runInfo.exitCode = error.code ?? null; + runInfo.endedAt = nowIso(); + runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime(); + runInfo.errorMessage = error.message; + error.runInfo = runInfo; + logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) }); + throw error; + } finally { + const activeHandleForJob = this.activeProcesses.get(normalizedProcessKey) || null; + if (activeHandleForJob === processHandle) { + if (previousProcessHandle && previousProcessHandle !== processHandle) { + this.activeProcesses.set(normalizedProcessKey, previousProcessHandle); + } else { + this.activeProcesses.delete(normalizedProcessKey); + } + } + // Preserve cancellation marker for outer/orchestrating handles (e.g. CD shared handle). + if (!(previousProcessHandle && previousProcessHandle !== processHandle)) { + this.cancelRequestedByJob.delete(normalizedProcessKey); + } + this.syncPrimaryActiveProcess(); + await historyService.closeProcessLog(jobId); + await this.emitQueueChanged(); + void this.pumpQueue(); + } + } + + async failJob(jobId, stage, error) { + const message = error?.message || String(error); + const isCancelled = /abgebrochen|cancelled/i.test(message) + || String(error?.runInfo?.status || '').trim().toUpperCase() === 'CANCELLED'; + const normalizedStage = String(stage || '').trim().toUpperCase(); + const job = await historyService.getJobById(jobId); + const title = job?.title || job?.detected_title || `Job #${jobId}`; + const finalState = isCancelled ? 'CANCELLED' : 'ERROR'; + const removedDetachedQueueEntries = this.removeDetachedQueueAutomationEntriesForJob(jobId); + if (removedDetachedQueueEntries > 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Queue-Cleanup: ${removedDetachedQueueEntries} verknüpfte Skript/Ketten-Eintrag${removedDetachedQueueEntries === 1 ? '' : 'e'} entfernt.` + ); + await this.emitQueueChanged(); + } + logger[isCancelled ? 'warn' : 'error']('job:failed', { jobId, stage, error: errorToMeta(error) }); + const makemkvInfo = this.safeParseJson(job?.makemkv_info_json); + const encodePlan = this.safeParseJson(job?.encode_plan_json); + const resolvedMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo, + mediaProfile: normalizedStage.startsWith('CD_') ? 'cd' : null + }); + const isCdFailure = resolvedMediaProfile === 'cd' + || normalizedStage.startsWith('CD_') + || String(job?.status || '').trim().toUpperCase().startsWith('CD_') + || String(job?.last_state || '').trim().toUpperCase().startsWith('CD_') + || (Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0); + const mode = String(encodePlan?.mode || '').trim().toLowerCase(); + const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); + const hasEncodableInput = isPreRipMode + ? Boolean(encodePlan?.encodeInputTitleId) + : Boolean(job?.encode_input_path || encodePlan?.encodeInputPath || job?.raw_path); + const hasConfirmedPlan = Boolean( + encodePlan + && Array.isArray(encodePlan?.titles) + && encodePlan.titles.length > 0 + && (Number(job?.encode_review_confirmed || 0) === 1 || Boolean(encodePlan?.reviewConfirmed)) + && hasEncodableInput + ); + let hasRawPath = false; + try { + hasRawPath = Boolean( + job?.raw_path + && fs.existsSync(job.raw_path) + && (hasBluRayBackupStructure(job.raw_path) || findPreferredRawInput(job.raw_path)) + ); + } catch (_error) { + hasRawPath = false; + } + + const isOrphanRawImportJob = String(makemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + const shouldAutoRecoverEncode = normalizedStage === 'ENCODING' + && hasConfirmedPlan + && resolvedMediaProfile !== 'audiobook' + && !isOrphanRawImportJob; + if (shouldAutoRecoverEncode) { + try { + const recoveryReasonLabel = isCancelled ? 'Abbruch' : 'Fehler'; + await historyService.appendLog( + jobId, + 'SYSTEM', + `${recoveryReasonLabel} in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.` + ); + await this.restartEncodeWithLastSettings(jobId, { + immediate: true, + triggerReason: isCancelled ? 'cancelled_encode' : 'failed_encode' + }); + this.cancelRequestedByJob.delete(Number(jobId)); + return; + } catch (recoveryError) { + logger.error('job:encoding:auto-recover-failed', { + jobId, + stage, + error: errorToMeta(recoveryError) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Auto-Recovery nach Encode-Abbruch fehlgeschlagen: ${recoveryError?.message || 'unknown'}` + ); + } + } + + const normalizedJobStatus = String(job?.status || '').trim().toUpperCase(); + const normalizedJobLastState = String(job?.last_state || '').trim().toUpperCase(); + const ripStageForLockRecovery = ( + normalizedStage === 'CD_RIPPING' + || normalizedJobStatus === 'CD_RIPPING' + || normalizedJobLastState === 'CD_RIPPING' + ) + ? 'CD_RIPPING' + : ( + normalizedStage === 'RIPPING' + || normalizedJobStatus === 'RIPPING' + || normalizedJobLastState === 'RIPPING' + ) + ? 'RIPPING' + : null; + const shouldPreserveRipStageForLock = Boolean( + ripStageForLockRecovery + && Number(job?.rip_successful || 0) !== 1 + && (finalState === 'ERROR' || finalState === 'CANCELLED') + ); + const persistedLastState = shouldPreserveRipStageForLock + ? ripStageForLockRecovery + : finalState; + + await historyService.updateJob(jobId, { + status: finalState, + last_state: persistedLastState, + end_time: nowIso(), + error_message: message + }); + if (job?.parent_job_id) { + await this.syncSeriesContainerStatusFromChildren(job.parent_job_id).catch((parentSyncError) => { + logger.warn('series-container:status-sync-on-child-fail-failed', { + parentJobId: job.parent_job_id, + childJobId: jobId, + error: errorToMeta(parentSyncError) + }); + }); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}` + ); + if (finalState === 'CANCELLED') { + const incompleteOutputDir = resolveIncompleteDirectoryFromOutputPath(job?.output_path, jobId); + if (incompleteOutputDir) { + try { + const existedBeforeCleanup = fs.existsSync(incompleteOutputDir); + fs.rmSync(incompleteOutputDir, { recursive: true, force: true }); + if (existedBeforeCleanup) { + logger.info('job:cancelled:incomplete-output-cleanup', { + jobId, + stage: normalizedStage || null, + incompleteOutputDir + }); + } + } catch (cleanupError) { + logger.warn('job:cancelled:incomplete-output-cleanup-failed', { + jobId, + stage: normalizedStage || null, + incompleteOutputDir, + error: errorToMeta(cleanupError) + }); + } + } + } + const jobProgressContext = this.jobProgress.get(Number(jobId))?.context; + const cdSelectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + const fallbackCdArtist = Array.isArray(makemkvInfo?.tracks) + ? ( + makemkvInfo.tracks + .map((track) => String(track?.artist || '').trim()) + .find(Boolean) || null + ) + : null; + const resolvedCdMbId = String( + cdSelectedMetadata?.mbId + || cdSelectedMetadata?.musicBrainzId + || cdSelectedMetadata?.musicbrainzId + || cdSelectedMetadata?.musicbrainz_id + || cdSelectedMetadata?.music_brainz_id + || cdSelectedMetadata?.musicbrainz + || cdSelectedMetadata?.mbid + || '' + ).trim() || null; + const resolvedCdCoverUrl = String( + cdSelectedMetadata?.coverUrl + || cdSelectedMetadata?.poster + || cdSelectedMetadata?.posterUrl + || job?.poster_url + || '' + ).trim() || null; + const resolvedSelectedMetadata = isCdFailure + ? { + title: cdSelectedMetadata?.title || job?.title || job?.detected_title || null, + artist: cdSelectedMetadata?.artist || fallbackCdArtist || null, + year: cdSelectedMetadata?.year ?? job?.year ?? null, + mbId: resolvedCdMbId, + coverUrl: resolvedCdCoverUrl, + imdbId: job?.imdb_id || null, + poster: job?.poster_url || resolvedCdCoverUrl || null + } + : { + title: job?.title || job?.detected_title || null, + year: job?.year || null, + imdbId: job?.imdb_id || null, + poster: job?.poster_url || null + }; + const resolvedTracks = isCdFailure + ? ( + Array.isArray(jobProgressContext?.tracks) && jobProgressContext.tracks.length > 0 + ? jobProgressContext.tracks + : (Array.isArray(makemkvInfo?.tracks) ? makemkvInfo.tracks : []) + ) + : []; + const resolvedCdRipConfig = isCdFailure + ? ( + jobProgressContext?.cdRipConfig && typeof jobProgressContext.cdRipConfig === 'object' + ? jobProgressContext.cdRipConfig + : (encodePlan && typeof encodePlan === 'object' ? encodePlan : null) + ) + : null; + + const failContext = { + ...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}), + jobId, + stage, + error: message, + rawPath: job?.raw_path || null, + outputPath: job?.output_path || null, + mediaProfile: isCdFailure ? 'cd' : resolvedMediaProfile, + inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null, + selectedMetadata: resolvedSelectedMetadata, + ...(isCdFailure ? { + tracks: resolvedTracks, + cdRipConfig: resolvedCdRipConfig, + cdLive: jobProgressContext?.cdLive || null, + devicePath: String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null, + cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null + } : {}), + canRestartEncodeFromLastSettings: hasConfirmedPlan, + canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath + }; + + if (isCdFailure) { + const resolvedCdDrivePath = ( + String(job?.disc_device || '').trim() + || String(jobProgressContext?.devicePath || '').trim() + || String(jobProgressContext?.virtualDrivePath || '').trim() + || String(this._getCdDriveByJobId(jobId)?.devicePath || '').trim() + || null + ); + const normalizedFailStage = String(stage || '').trim().toUpperCase(); + const keepCdDriveLockedOnCancel = Boolean( + isCancelled + && Number(job?.rip_successful || 0) !== 1 + && ( + this._getDriveLockByJobId(jobId) + || normalizedFailStage === 'CD_RIPPING' + || String(job?.last_state || '').trim().toUpperCase() === 'CD_RIPPING' + ) + ); + + if (resolvedCdDrivePath) { + this._setCdDriveState(resolvedCdDrivePath, { + state: finalState, + jobId, + progress: this.cdDrives.get(resolvedCdDrivePath)?.progress ?? 0, + eta: null, + statusText: message, + context: failContext + }); + if (isCancelled) { + if (keepCdDriveLockedOnCancel) { + logger.warn('cd:drive:retain-lock-after-cancel', { + jobId, + devicePath: resolvedCdDrivePath, + stage: normalizedFailStage + }); + } else { + this._releaseDriveLockForJob(jobId, { reason: 'cancelled_without_pending_rip' }); + if (String(resolvedCdDrivePath).startsWith('__virtual__')) { + this._removeCdDrive(resolvedCdDrivePath); + } else { + this._releaseCdDrive(resolvedCdDrivePath); + } + } + } + } else { + // No drive mapping available (e.g. orphan/skipRip import path). + // Ensure stale live progress does not keep the job "running" in the ripper. + this.jobProgress.delete(Number(jobId)); + } + } else { + await this.setState(finalState, { + activeJobId: jobId, + progress: this.snapshot.progress, + eta: null, + statusText: message, + context: { + ...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}), + jobId, + stage, + error: message, + rawPath: job?.raw_path || null, + outputPath: job?.output_path || null, + mediaProfile: resolvedMediaProfile, + inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null, + selectedMetadata: resolvedSelectedMetadata, + canRestartEncodeFromLastSettings: hasConfirmedPlan, + canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath + }}); + } + + this.cancelRequestedByJob.delete(Number(jobId)); + + const shouldRemoveCancelledOrphanImportJob = Boolean( + isCancelled + && normalizedStage === 'ENCODING' + && isOrphanRawImportJob + && !job?.parent_job_id + ); + if (shouldRemoveCancelledOrphanImportJob) { + try { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Abgebrochener Orphan-RAW-Encode wird aus der Historie entfernt. RAW bleibt erhalten und ist wieder unter /database verfügbar.' + ); + await historyService.deleteJob(jobId, 'none', { + includeRelated: false, + preserveRawForImportJobs: true + }); + await this.onJobsDeleted([jobId], { suppressQueueRefresh: false }); + if (Number(this.snapshot.activeJobId || 0) === Number(jobId)) { + await this.setState('IDLE', { + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + } catch (cleanupError) { + logger.warn('job:cancelled:orphan-import-cleanup-failed', { + jobId, + stage: normalizedStage, + error: errorToMeta(cleanupError) + }); + } + return; + } + + const seriesBatchParentJobId = this.resolveSeriesBatchParentJobId({ + ...job, + encodePlan + }); + if (seriesBatchParentJobId) { + await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((seriesError) => { + logger.warn('series-batch:parent-progress:update-on-child-fail-failed', { + parentJobId: seriesBatchParentJobId, + childJobId: jobId, + error: errorToMeta(seriesError) + }); + }); + } + + void this.notifyPushover(isCancelled ? 'job_cancelled' : 'job_error', { + title: isCancelled ? 'Ripster - Job abgebrochen' : 'Ripster - Job Fehler', + message: `${title} (${stage}): ${message}` + }); + } + + async uploadAudiobookFile(file, options = {}) { + const tempFilePath = String(file?.path || '').trim(); + const originalName = String(file?.originalname || file?.originalName || '').trim() + || path.basename(tempFilePath || 'upload.aax'); + const detectedTitle = path.basename(originalName, path.extname(originalName)) || 'Audiobook'; + const requestedFormat = String(options?.format || '').trim().toLowerCase() || null; + const startImmediately = options?.startImmediately === undefined + ? false + : !['0', 'false', 'no', 'off'].includes(String(options.startImmediately).trim().toLowerCase()); + + if (!tempFilePath || !fs.existsSync(tempFilePath)) { + const error = new Error('Upload-Datei fehlt.'); + error.statusCode = 400; + throw error; + } + if (!audiobookService.isSupportedInputFile(originalName)) { + const error = new Error('Nur AAX-Dateien werden für Audiobooks unterstützt.'); + error.statusCode = 400; + throw error; + } + + const settings = await settingsService.getEffectiveSettingsMap('audiobook'); + const rawBaseDir = String( + settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR || settingsService.DEFAULT_RAW_DIR || '' + ).trim(); + const rawTemplate = String( + settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE; + const outputTemplate = String( + settings?.output_template || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE; + const outputFormat = audiobookService.normalizeOutputFormat( + requestedFormat || 'm4b' + ); + const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat); + const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + const ffmpegCommand = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg'; + + const job = await historyService.createJob({ + discDevice: null, + status: 'ANALYZING', + detectedTitle, + mediaType: 'audiobook', + jobKind: 'audiobook' + }); + + let stagedRawDir = null; + let stagedRawFilePath = null; + + try { + await resetProcessLogIfLifecycleAllows(job.id, job); + await historyService.appendLog(job.id, 'SYSTEM', `AAX-Upload empfangen: ${originalName}`); + logger.info('audiobook:upload:received', { + jobId: job.id, + originalName, + tempFilePath, + rawBaseDir, + outputFormat, + startImmediately + }); + + const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, tempFilePath); + const captured = await this.runCapturedCommand(probeConfig.cmd, probeConfig.args); + const probe = audiobookService.parseProbeOutput(captured.stdout); + if (!probe) { + const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden.'); + error.statusCode = 500; + throw error; + } + + const metadata = audiobookService.buildMetadataFromProbe(probe, originalName); + const storagePaths = audiobookService.buildRawStoragePaths( + metadata, + job.id, + rawBaseDir, + rawTemplate, + originalName + ); + logger.info('audiobook:upload:staging', { + jobId: job.id, + tempFilePath, + rawDir: storagePaths.rawDir, + rawFilePath: storagePaths.rawFilePath, + rawTemplate, + outputTemplate + }); + + ensureDir(storagePaths.rawDir); + moveFileWithFallback(tempFilePath, storagePaths.rawFilePath); + stagedRawDir = storagePaths.rawDir; + stagedRawFilePath = storagePaths.rawFilePath; + chownRecursive(storagePaths.rawDir, settings?.raw_dir_owner); + logger.info('audiobook:upload:staged', { + jobId: job.id, + stagedRawDir, + stagedRawFilePath + }); + + // Activation Bytes: Cache prüfen und Checksum am Job speichern + let aaxChecksum = null; + let aaxNeedsActivationBytes = false; + try { + const abResult = await activationBytesService.resolveActivationBytes(stagedRawFilePath); + aaxChecksum = abResult.checksum; + await historyService.updateJob(job.id, { aax_checksum: aaxChecksum }); + if (abResult.activationBytes) { + await historyService.appendLog(job.id, 'SYSTEM', `Activation Bytes im Cache gefunden: checksum=${abResult.checksum}`); + logger.info('audiobook:upload:activation-bytes', { jobId: job.id, checksum: abResult.checksum, source: 'cache' }); + } else { + aaxNeedsActivationBytes = true; + logger.info('audiobook:upload:activation-bytes-needed', { jobId: job.id, checksum: abResult.checksum }); + } + } catch (abError) { + logger.warn('audiobook:upload:activation-bytes-failed', { jobId: job.id, error: errorToMeta(abError) }); + } + + let detectedAsin = null; + let audnexChapters = []; + let audnexBook = null; + try { + detectedAsin = await audnexService.extractAsinFromAaxFile(storagePaths.rawFilePath); + if (detectedAsin) { + await historyService.appendLog(job.id, 'SYSTEM', `ASIN erkannt: ${detectedAsin}`); + const [chapters, book] = await Promise.allSettled([ + audnexService.fetchChaptersByAsin(detectedAsin, 'de'), + audnexService.fetchBookByAsin(detectedAsin, 'de') + ]); + if (chapters.status === 'fulfilled') { + audnexChapters = chapters.value; + if (audnexChapters.length > 0) { + await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Kapitel geladen: ${audnexChapters.length}`); + } else { + await historyService.appendLog(job.id, 'SYSTEM', `Keine Audnex-Kapitel fuer ASIN ${detectedAsin} gefunden.`); + } + } else { + logger.warn('audiobook:upload:audnex-chapters-failed', { jobId: job.id, asin: detectedAsin, error: errorToMeta(chapters.reason) }); + await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Kapitel konnten nicht geladen werden: ${chapters.reason?.message || 'unknown'}`).catch(() => {}); + } + if (book.status === 'fulfilled' && book.value) { + audnexBook = book.value; + await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Buchmetadaten geladen: ${audnexBook.narrator ? `Sprecher: ${audnexBook.narrator}` : 'kein Sprecher'}`); + } else if (book.status === 'rejected') { + logger.warn('audiobook:upload:audnex-book-failed', { jobId: job.id, asin: detectedAsin, error: errorToMeta(book.reason) }); + } + } else { + await historyService.appendLog(job.id, 'SYSTEM', 'Keine ASIN in der AAX-Datei gefunden, verwende eingebettete Kapitel.'); + } + } catch (audnexError) { + logger.warn('audiobook:upload:audnex-failed', { + jobId: job.id, + stagedRawFilePath: storagePaths.rawFilePath, + asin: detectedAsin, + error: errorToMeta(audnexError) + }); + await historyService.appendLog( + job.id, + 'SYSTEM', + `Audnex-Daten konnten nicht geladen werden: ${audnexError?.message || 'unknown'}` + ).catch(() => {}); + } + + let posterUrl = null; + if (metadata?.hasEmbeddedCover && metadata?.cover) { + const coverTempPath = path.join(storagePaths.rawDir, `.job-${job.id}-cover.jpg`); + try { + const coverCommand = audiobookService.buildCoverExtractionCommand( + ffmpegCommand, + storagePaths.rawFilePath, + coverTempPath, + metadata.cover + ); + await this.runCapturedCommand(coverCommand.cmd, coverCommand.args); + posterUrl = thumbnailService.storeLocalThumbnail(job.id, coverTempPath); + if (posterUrl) { + await historyService.appendLog(job.id, 'SYSTEM', 'Eingebettetes AAX-Cover erkannt und gespeichert.'); + } + } catch (coverError) { + logger.warn('audiobook:upload:cover-extract-failed', { + jobId: job.id, + stagedRawFilePath: storagePaths.rawFilePath, + error: errorToMeta(coverError) + }); + } finally { + try { + fs.rmSync(coverTempPath, { force: true }); + } catch (_error) { + // best effort cleanup + } + } + } + + const resolvedMetadata = { + ...metadata, + // Audnex book metadata takes precedence over embedded tags where available + ...(audnexBook?.narrator ? { narrator: audnexBook.narrator } : {}), + ...(audnexBook?.author ? { author: audnexBook.author, artist: audnexBook.author } : {}), + ...(audnexBook?.title ? { title: audnexBook.title, album: audnexBook.title } : {}), + ...(audnexBook?.description ? { description: audnexBook.description } : {}), + ...(audnexBook?.series ? { series: audnexBook.series } : {}), + ...(audnexBook?.part ? { part: audnexBook.part } : {}), + ...(audnexBook?.year ? { year: audnexBook.year } : {}), + asin: detectedAsin || null, + chapterSource: audnexChapters.length > 0 ? 'audnex' : 'probe', + chapters: audnexChapters.length > 0 + ? audiobookService.normalizeChapterList(audnexChapters, { + durationMs: metadata.durationMs, + fallbackTitle: metadata.title, + createFallback: false + }) + : metadata.chapters, + poster: posterUrl || null + }; + + const makemkvInfo = this.withAnalyzeContextMediaProfile({ + status: 'SUCCESS', + source: 'aax_upload', + importedAt: nowIso(), + mediaProfile: 'audiobook', + rawFileName: storagePaths.rawFileName, + rawFilePath: storagePaths.rawFilePath, + chapters: resolvedMetadata.chapters, + detectedMetadata: resolvedMetadata, + selectedMetadata: resolvedMetadata, + probeSummary: { + durationMs: resolvedMetadata.durationMs, + tagKeys: Object.keys(resolvedMetadata.tags || {}), + asin: detectedAsin || null, + chapterSource: resolvedMetadata.chapterSource || 'probe' + } + }, 'audiobook'); + + const encodePlan = { + mediaProfile: 'audiobook', + jobKind: 'audiobook', + mode: 'audiobook', + sourceType: 'upload', + uploadedAt: nowIso(), + format: outputFormat, + formatOptions, + rawTemplate, + outputTemplate, + encodeInputPath: storagePaths.rawFilePath, + metadata: resolvedMetadata, + reviewConfirmed: true + }; + + await historyService.updateJob(job.id, { + media_type: 'audiobook', + job_kind: 'audiobook', + status: 'READY_TO_START', + last_state: 'READY_TO_START', + title: resolvedMetadata.title || detectedTitle, + detected_title: resolvedMetadata.title || detectedTitle, + year: resolvedMetadata.year ?? null, + raw_path: storagePaths.rawDir, + rip_successful: 1, + makemkv_info_json: JSON.stringify(makemkvInfo), + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: JSON.stringify(encodePlan), + encode_input_path: storagePaths.rawFilePath, + encode_review_confirmed: 1, + output_path: null, + poster_url: posterUrl || null, + error_message: null, + end_time: null + }); + + await historyService.appendLog( + job.id, + 'SYSTEM', + `Audiobook analysiert: ${resolvedMetadata.title || detectedTitle} | Autor: ${resolvedMetadata.author || '-'} | Format: ${outputFormat.toUpperCase()}` + ); + + if (!startImmediately) { + return { + jobId: job.id, + started: false, + queued: false, + stage: 'READY_TO_START', + ...(aaxNeedsActivationBytes ? { needsActivationBytes: true, checksum: aaxChecksum } : {}) + }; + } + + const startResult = await this.startPreparedJob(job.id); + return { + jobId: job.id, + ...(startResult && typeof startResult === 'object' ? startResult : {}) + }; + } catch (error) { + logger.error('audiobook:upload:failed', { + jobId: job.id, + originalName, + tempFilePath, + stagedRawDir, + stagedRawFilePath, + error: errorToMeta(error) + }); + const updatePayload = { + media_type: 'audiobook', + job_kind: 'audiobook', + status: 'ERROR', + last_state: 'ERROR', + end_time: nowIso(), + error_message: error?.message || 'Audiobook-Upload fehlgeschlagen.' + }; + if (stagedRawDir) { + updatePayload.raw_path = stagedRawDir; + } + if (stagedRawFilePath) { + updatePayload.encode_input_path = stagedRawFilePath; + } + await historyService.updateJob(job.id, updatePayload).catch(() => {}); + await historyService.appendLog( + job.id, + 'SYSTEM', + `Audiobook-Upload fehlgeschlagen: ${error?.message || 'unknown'}` + ).catch(() => {}); + throw error; + } finally { + if (tempFilePath && fs.existsSync(tempFilePath)) { + try { + fs.rmSync(tempFilePath, { force: true }); + } catch (_error) { + // best effort cleanup + } + } + } + } + + async startAudiobookWithConfig(jobId, config = {}) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + const error = new Error('Ungültige Job-ID für Audiobook-Start.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(normalizedJobId); + if (!job) { + const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json); + const makemkvInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo, + mediaProfile: 'audiobook' + }); + if (mediaProfile !== 'audiobook') { + const error = new Error(`Job ${normalizedJobId} ist kein Audiobook-Job.`); + error.statusCode = 400; + throw error; + } + + const format = audiobookService.normalizeOutputFormat( + config?.format || encodePlan?.format || 'm4b' + ); + const formatOptions = audiobookService.normalizeFormatOptions( + format, + config?.formatOptions || encodePlan?.formatOptions || {} + ); + const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan); + const chapters = audiobookService.normalizeChapterList( + Array.isArray(config?.chapters) ? config.chapters : metadata.chapters, + { + durationMs: metadata.durationMs, + fallbackTitle: metadata.title, + createFallback: false + } + ); + const selectedPreEncodeScriptIds = normalizeScriptIdList(config?.selectedPreEncodeScriptIds || []); + const selectedPostEncodeScriptIds = normalizeScriptIdList(config?.selectedPostEncodeScriptIds || []); + const selectedPreEncodeChainIds = normalizeChainIdList(config?.selectedPreEncodeChainIds || []); + const selectedPostEncodeChainIds = normalizeChainIdList(config?.selectedPostEncodeChainIds || []); + const [ + selectedPreEncodeScripts, + selectedPostEncodeScripts, + selectedPreEncodeChains, + selectedPostEncodeChains + ] = await Promise.all([ + scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }), + scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { strict: true }), + scriptChainService.getChainsByIds(selectedPreEncodeChainIds), + scriptChainService.getChainsByIds(selectedPostEncodeChainIds) + ]); + + const ensureResolvedChains = (requestedIds, resolvedChains, fieldName) => { + const resolved = Array.isArray(resolvedChains) ? resolvedChains : []; + const resolvedSet = new Set( + resolved + .map((chain) => Number(chain?.id)) + .filter((id) => Number.isFinite(id) && id > 0) + .map((id) => Math.trunc(id)) + ); + const missing = requestedIds.filter((id) => !resolvedSet.has(Number(id))); + if (missing.length === 0) { + return; + } + const error = new Error(`Skriptkette(n) nicht gefunden: ${missing.join(', ')}`); + error.statusCode = 400; + error.details = [{ field: fieldName, message: `Nicht gefunden: ${missing.join(', ')}` }]; + throw error; + }; + ensureResolvedChains(selectedPreEncodeChainIds, selectedPreEncodeChains, 'selectedPreEncodeChainIds'); + ensureResolvedChains(selectedPostEncodeChainIds, selectedPostEncodeChains, 'selectedPostEncodeChainIds'); + + const toScriptDescriptor = (script) => { + const id = Number(script?.id); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + if (!normalizedId) { + return null; + } + const name = String(script?.name || '').trim() || `Skript #${normalizedId}`; + return { id: normalizedId, name }; + }; + const toChainDescriptor = (chain) => { + const id = Number(chain?.id); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + if (!normalizedId) { + return null; + } + const name = String(chain?.name || '').trim() || `Kette #${normalizedId}`; + return { id: normalizedId, name }; + }; + const resolvedMetadata = { + ...metadata, + chapters + }; + const nextMakemkvInfo = { + ...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}), + chapters, + selectedMetadata: { + ...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}), + ...resolvedMetadata, + poster: metadata.poster || job.poster_url || null + } + }; + + const nextEncodePlan = { + ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}), + mediaProfile: 'audiobook', + jobKind: 'audiobook', + mode: 'audiobook', + format, + formatOptions, + metadata: resolvedMetadata, + preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)), + postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), + preEncodeScripts: selectedPreEncodeScripts.map(toScriptDescriptor).filter(Boolean), + postEncodeScripts: selectedPostEncodeScripts.map(toScriptDescriptor).filter(Boolean), + preEncodeChainIds: selectedPreEncodeChainIds, + postEncodeChainIds: selectedPostEncodeChainIds, + preEncodeChains: selectedPreEncodeChains.map(toChainDescriptor).filter(Boolean), + postEncodeChains: selectedPostEncodeChains.map(toChainDescriptor).filter(Boolean), + reviewConfirmed: true + }; + + await historyService.updateJob(normalizedJobId, { + media_type: 'audiobook', + job_kind: 'audiobook', + status: 'READY_TO_START', + last_state: 'READY_TO_START', + title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook', + year: resolvedMetadata.year ?? job.year ?? null, + makemkv_info_json: JSON.stringify(nextMakemkvInfo), + encode_plan_json: JSON.stringify(nextEncodePlan), + encode_review_confirmed: 1, + error_message: null, + handbrake_info_json: null, + end_time: null + }); + + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()} | Kapitel: ${chapters.length || 0} | ` + + `Pre-Skripte: ${selectedPreEncodeScriptIds.length} | Post-Skripte: ${selectedPostEncodeScriptIds.length} | ` + + `Pre-Ketten: ${selectedPreEncodeChainIds.length} | Post-Ketten: ${selectedPostEncodeChainIds.length}` + ); + + const startResult = await this.startPreparedJob(normalizedJobId); + return { + jobId: normalizedJobId, + ...(startResult && typeof startResult === 'object' ? startResult : {}) + }; + } + + async startAudiobookEncode(jobId, options = {}) { + const immediate = Boolean(options?.immediate); + if (!immediate) { + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + jobId, + () => this.startAudiobookEncode(jobId, { ...options, immediate: true }), + { preloadedJob: options?.preloadedJob || null } + ); + } + + this.ensureNotBusy('startAudiobookEncode', jobId); + logger.info('audiobook:encode:start', { jobId }); + this.cancelRequestedByJob.delete(Number(jobId)); + + const job = options?.preloadedJob || await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json); + const makemkvInfo = this.safeParseJson(job.makemkv_info_json); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo, + mediaProfile: 'audiobook' + }); + if (mediaProfile !== 'audiobook') { + const error = new Error(`Job ${jobId} ist kein Audiobook-Job.`); + error.statusCode = 400; + throw error; + } + + const settings = await settingsService.getEffectiveSettingsMap('audiobook'); + const resolvedRawPath = this.resolveCurrentRawPathForSettings( + settings, + 'audiobook', + job.raw_path + ) || String(job.raw_path || '').trim() || null; + + let inputPath = String( + job.encode_input_path + || encodePlan?.encodeInputPath + || makemkvInfo?.rawFilePath + || '' + ).trim(); + + if ((!inputPath || !fs.existsSync(inputPath)) && resolvedRawPath) { + inputPath = findPreferredRawInput(resolvedRawPath)?.path || ''; + } + + if (!inputPath) { + const error = new Error('Audiobook-Encode nicht möglich: keine Input-Datei gefunden.'); + error.statusCode = 400; + throw error; + } + if (!fs.existsSync(inputPath)) { + const error = new Error(`Audiobook-Encode nicht möglich: Input-Datei fehlt (${inputPath}).`); + error.statusCode = 400; + throw error; + } + + const { + metadata, + outputFormat, + preferredFinalOutputPath, + incompleteOutputPath, + preferredChapterPlan, + incompleteChapterPlan + } = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId); + const formatOptions = audiobookService.normalizeFormatOptions( + outputFormat, + encodePlan?.formatOptions || {} + ); + const isSplitOutput = outputFormat !== 'm4b'; + const activeChapters = isSplitOutput + ? (Array.isArray(incompleteChapterPlan?.chapters) ? incompleteChapterPlan.chapters : []) + : (Array.isArray(metadata.chapters) ? metadata.chapters : []); + + if (isSplitOutput) { + try { + fs.rmSync(incompleteOutputPath, { recursive: true, force: true }); + } catch (_error) { + // best effort cleanup + } + ensureDir(incompleteOutputPath); + } else { + ensureDir(path.dirname(incompleteOutputPath)); + } + + await resetProcessLogIfLifecycleAllows(jobId, job); + await this.setState('ENCODING', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: `Audiobook-Encoding (${outputFormat.toUpperCase()})`, + context: { + jobId, + mode: 'audiobook', + mediaProfile: 'audiobook', + inputPath, + outputPath: incompleteOutputPath, + format: outputFormat, + formatOptions, + chapters: activeChapters, + selectedMetadata: { + title: metadata.title || job.title || job.detected_title || null, + year: metadata.year ?? job.year ?? null, + author: metadata.author || null, + narrator: metadata.narrator || null, + description: metadata.description || null, + series: metadata.series || null, + part: metadata.part || null, + chapters: activeChapters, + durationMs: metadata.durationMs || 0, + poster: metadata.poster || job.poster_url || null + }, + audiobookConfig: { + format: outputFormat, + formatOptions + }, + canRestartEncodeFromLastSettings: false, + canRestartReviewFromRaw: false + } + }); + + await historyService.updateJob(jobId, { + media_type: 'audiobook', + job_kind: 'audiobook', + status: 'ENCODING', + last_state: 'ENCODING', + start_time: nowIso(), + end_time: null, + error_message: null, + raw_path: resolvedRawPath || job.raw_path || null, + output_path: incompleteOutputPath, + encode_input_path: inputPath + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + isSplitOutput + ? `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()} | Kapitel-Dateien: ${activeChapters.length || 0}` + : `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}` + ); + + void this.notifyPushover('encoding_started', { + title: 'Ripster - Audiobook-Encoding gestartet', + message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` + }); + + // Activation Bytes für AAX-Dateien aus Cache lesen (vor dem Background-Start, + // damit ein Fehler hier den HTTP-Request direkt abbricht). + let encodeActivationBytes = null; + if (path.extname(inputPath).toLowerCase() === '.aax') { + try { + const abResult = await activationBytesService.resolveActivationBytes(inputPath); + encodeActivationBytes = abResult.activationBytes || null; + if (!encodeActivationBytes) { + throw new Error('Activation Bytes nicht im Cache – bitte zuerst über den Upload-Dialog eintragen'); + } + logger.info('audiobook:encode:activation-bytes', { jobId, checksum: abResult.checksum }); + } catch (abError) { + logger.error('audiobook:encode:activation-bytes-failed', { jobId, error: errorToMeta(abError) }); + throw abError; + } + } + + // Den eigentlichen ffmpeg-Encode im Hintergrund ausführen, damit der HTTP-Request + // sofort nach dem Setzen des ENCODING-Status zurückkehrt (kein Blockieren bis FINISHED). + void (async () => { + let temporaryChapterMetadataPath = null; + try { + let ffmpegRunInfo = null; + if (isSplitOutput) { + const outputFiles = Array.isArray(incompleteChapterPlan?.outputFiles) + ? incompleteChapterPlan.outputFiles + : []; + if (outputFiles.length === 0) { + throw new Error('Keine Audiobook-Kapitel für den Encode verfügbar.'); + } + + const chapterRunInfos = []; + for (let index = 0; index < outputFiles.length; index += 1) { + const entry = outputFiles[index]; + const chapter = entry?.chapter || {}; + const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`; + const startPercent = Number(((index / outputFiles.length) * 100).toFixed(2)); + const endPercent = Number((((index + 1) / outputFiles.length) * 100).toFixed(2)); + + ensureDir(path.dirname(entry.outputPath)); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle} -> ${path.basename(entry.outputPath)}` + ); + await this.updateProgress( + 'ENCODING', + startPercent, + null, + `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`, + jobId, + { + contextPatch: { + outputPath: incompleteOutputPath, + completedChapterCount: index, + currentChapter: { + index: index + 1, + total: outputFiles.length, + title: chapterTitle + } + } + } + ); + + const ffmpegConfig = audiobookService.buildChapterEncodeCommand( + settings?.ffmpeg_command || 'ffmpeg', + inputPath, + entry.outputPath, + outputFormat, + formatOptions, + metadata, + chapter, + outputFiles.length, + { activationBytes: encodeActivationBytes } + ); + const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0); + const scaledParser = baseParser + ? (line) => { + const progress = baseParser(line); + if (!progress || progress.percent == null) { + return null; + } + const scaledPercent = startPercent + ((endPercent - startPercent) * (progress.percent / 100)); + return { + percent: Number(scaledPercent.toFixed(2)), + eta: null + }; + } + : null; + + logger.info('audiobook:encode:chapter-command', { + jobId, + chapterIndex: index + 1, + cmd: ffmpegConfig.cmd, + args: ffmpegConfig.args + }); + const chapterRunInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'FFMPEG', + cmd: ffmpegConfig.cmd, + args: ffmpegConfig.args, + parser: scaledParser + }); + + chapterRunInfos.push({ + ...chapterRunInfo, + chapterIndex: index + 1, + chapterTitle, + outputPath: entry.outputPath + }); + + await this.updateProgress( + 'ENCODING', + endPercent, + null, + `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length} abgeschlossen`, + jobId, + { + contextPatch: { + completedChapterCount: index + 1, + currentChapter: { + index: index + 1, + total: outputFiles.length, + title: chapterTitle + } + } + } + ); + } + + ffmpegRunInfo = { + source: 'FFMPEG', + stage: 'ENCODING', + cmd: String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg', + args: [''], + startedAt: chapterRunInfos[0]?.startedAt || nowIso(), + endedAt: chapterRunInfos[chapterRunInfos.length - 1]?.endedAt || nowIso(), + durationMs: chapterRunInfos.reduce((sum, item) => sum + Number(item?.durationMs || 0), 0), + status: 'SUCCESS', + exitCode: 0, + stdoutLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stdoutLines || 0), 0), + stderrLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stderrLines || 0), 0), + lastProgress: 100, + eta: null, + lastDetail: `${chapterRunInfos.length} Kapitel abgeschlossen`, + highlights: chapterRunInfos.flatMap((item) => (Array.isArray(item?.highlights) ? item.highlights : [])).slice(0, 120), + steps: chapterRunInfos + }; + } else { + temporaryChapterMetadataPath = path.join(path.dirname(inputPath), `.job-${jobId}-chapters.ffmeta`); + fs.writeFileSync( + temporaryChapterMetadataPath, + audiobookService.buildChapterMetadataContent(activeChapters, metadata), + 'utf8' + ); + + const ffmpegConfig = audiobookService.buildEncodeCommand( + settings?.ffmpeg_command || 'ffmpeg', + inputPath, + incompleteOutputPath, + outputFormat, + formatOptions, + { + chapterMetadataPath: temporaryChapterMetadataPath, + metadata, + activationBytes: encodeActivationBytes + } + ); + logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args }); + ffmpegRunInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'FFMPEG', + cmd: ffmpegConfig.cmd, + args: ffmpegConfig.args, + parser: audiobookService.buildProgressParser(metadata.durationMs) + }); + } + + const outputFinalization = finalizeOutputPathForCompletedEncode( + incompleteOutputPath, + preferredFinalOutputPath + ); + const finalizedOutputPath = outputFinalization.outputPath; + let ownershipTarget = path.dirname(finalizedOutputPath); + try { + const finalizedStat = fs.statSync(finalizedOutputPath); + if (finalizedStat.isDirectory()) { + ownershipTarget = finalizedOutputPath; + } + } catch (_error) { + ownershipTarget = path.dirname(finalizedOutputPath); + } + chownRecursive(ownershipTarget, settings?.movie_dir_owner); + + if (outputFinalization.outputPathWithTimestamp) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Finaler Audiobook-Output existierte bereits. Zielpfad nummeriert: ${finalizedOutputPath}` + ); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Audiobook-Output finalisiert: ${finalizedOutputPath}` + ); + historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => { + logger.warn('audiobook:record-output-folder-failed', { jobId, error: e?.message }); + }); + + const finalizedOutputFiles = isSplitOutput + ? (Array.isArray(preferredChapterPlan?.outputFiles) + ? preferredChapterPlan.outputFiles.map((entry) => { + const relativePath = path.relative(preferredFinalOutputPath, entry.outputPath); + return path.join(finalizedOutputPath, relativePath); + }) + : []) + : null; + const ffmpegInfo = { + ...ffmpegRunInfo, + mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode', + format: outputFormat, + formatOptions, + metadata: { + ...metadata, + chapters: activeChapters + }, + inputPath, + outputPath: finalizedOutputPath, + chapterCount: activeChapters.length, + outputFiles: finalizedOutputFiles + }; + + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify(ffmpegInfo), + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + rip_successful: 1, + raw_path: resolvedRawPath || job.raw_path || null, + output_path: finalizedOutputPath, + error_message: null + }); + await historyService.generateJobNfo(jobId, { + mode: 'auto', + requireSettingEnabled: true + }).catch((nfoError) => { + logger.warn('job:nfo:auto-generate-failed', { + jobId, + mode: 'audiobook', + error: errorToMeta(nfoError) + }); + }); + + // Only switch the pipeline UI to FINISHED if this job is still the active one. + if (Number(this.snapshot.activeJobId) === Number(jobId)) { + await this.setState('FINISHED', { + activeJobId: jobId, + progress: 100, + eta: null, + statusText: 'Audiobook abgeschlossen', + context: { + jobId, + mode: 'audiobook', + mediaProfile: 'audiobook', + outputPath: finalizedOutputPath + } + }); + } else { + logger.info('audiobook:finished:background', { jobId, activeJobId: this.snapshot.activeJobId }); + void this.pumpQueue(); + } + + void this.notifyPushover('job_finished', { + title: 'Ripster - Audiobook abgeschlossen', + message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` + }); + + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { + await this.setState('IDLE', { + finishingJobId: jobId, + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + }, 3000); + + return { + started: true, + stage: 'ENCODING', + outputPath: finalizedOutputPath + }; + } catch (error) { + if (temporaryChapterMetadataPath) { + try { + fs.rmSync(temporaryChapterMetadataPath, { force: true }); + } catch (_error) { + // best effort cleanup + } + } + if (error.runInfo && error.runInfo.source === 'FFMPEG') { + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify({ + ...error.runInfo, + mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode', + format: outputFormat, + formatOptions, + inputPath + }) + }).catch(() => {}); + } + logger.error('audiobook:encode:failed', { jobId, error: errorToMeta(error) }); + await this.failJob(jobId, 'ENCODING', error); + } finally { + if (temporaryChapterMetadataPath) { + try { + fs.rmSync(temporaryChapterMetadataPath, { force: true }); + } catch (_error) { + // best effort cleanup + } + } + } + })(); + + return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath }; + } + + // ── Converter Pipeline ─────────────────────────────────────────────────────── + + async startConverterEncode(jobId, options = {}) { + const immediate = Boolean(options?.immediate); + if (!immediate) { + const queuedJob = options?.preloadedJob || await historyService.getJobById(jobId); + const queuedPlan = this.safeParseJson(queuedJob?.encode_plan_json) || {}; + const queuedConverterMediaType = String(queuedPlan?.converterMediaType || '').trim().toLowerCase(); + const queuePoolType = queuedConverterMediaType === 'audio' ? 'audio' : 'film'; + return this.enqueueOrStartAction( + QUEUE_ACTIONS.START_PREPARED, + jobId, + () => this.startConverterEncode(jobId, { ...options, immediate: true }), + { poolType: queuePoolType, preloadedJob: queuedJob } + ); + } + + this.ensureNotBusy('startConverterEncode', jobId); + logger.info('converter:encode:start', { jobId }); + this.cancelRequestedByJob.delete(Number(jobId)); + + const job = options?.preloadedJob || await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; + const inputPath = encodePlan.inputPath || job.raw_path; + + if (!inputPath || !fs.existsSync(inputPath)) { + const error = new Error(`Converter-Eingabedatei nicht gefunden: ${inputPath}`); + error.statusCode = 404; + throw error; + } + + let outputPath = encodePlan.outputPath || null; + let outputDir = encodePlan.outputDir || null; + let outputFormat = String(encodePlan.outputFormat || '').trim().toLowerCase() || null; + + if (!outputPath && !outputDir) { + const converterSettings = await settingsService.getSettingsMap(); + const converterMediaType = String(encodePlan.converterMediaType || 'video').trim().toLowerCase(); + outputFormat = outputFormat || (converterMediaType === 'audio' ? 'flac' : 'mkv'); + + const parseTemplateSegments = (template) => String(template || '') + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((segment) => String(segment || '').trim()) + .filter(Boolean); + const renderTemplateSegment = (segment, values, fallback = 'unknown') => { + const rendered = renderTemplate(segment, values); + return sanitizeFileName(rendered) || fallback; + }; + + if (converterMediaType === 'video' || converterMediaType === 'iso') { + const movieDir = String( + converterSettings?.converter_movie_dir || require('../config').defaultConverterMovieDir || '' + ).trim(); + const baseName = sanitizeFileName(path.basename(inputPath, path.extname(inputPath))) || 'output'; + const title = String( + encodePlan?.metadata?.title || job.title || job.detected_title || baseName + ).trim() || baseName; + const safeTitle = sanitizeFileName(title) || baseName; + const yearValue = Number.isFinite(Number(encodePlan?.metadata?.year)) + ? Math.trunc(Number(encodePlan.metadata.year)) + : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : new Date().getFullYear()); + const videoTemplateRaw = String( + converterSettings?.converter_output_template_video || '{title}' + ).trim() || '{title}'; + const videoSegmentsRaw = parseTemplateSegments(videoTemplateRaw); + const videoSegments = (videoSegmentsRaw.length > 0 ? videoSegmentsRaw : ['{title}']) + .map((segment) => renderTemplateSegment(segment, { + title: safeTitle, + year: yearValue + }, safeTitle)); + const videoBaseName = videoSegments[videoSegments.length - 1] || safeTitle; + const videoFolders = videoSegments.slice(0, -1); + outputPath = path.join(movieDir, ...(videoFolders.length > 0 ? videoFolders : []), `${videoBaseName}.${outputFormat}`); + } + + encodePlan.outputFormat = outputFormat; + encodePlan.outputPath = outputPath || null; + encodePlan.outputDir = outputDir || null; + await historyService.updateJob(jobId, { + encode_plan_json: JSON.stringify(encodePlan), + output_path: outputPath || outputDir || null + }); + } + + await resetProcessLogIfLifecycleAllows(jobId, job); + await this.setState('ANALYZING', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'Converter: Analyse läuft …', + context: { + jobId, + mode: 'converter', + mediaProfile: 'converter', + inputPath, + converterMediaType: encodePlan.converterMediaType || 'video' + } + }); + + await historyService.updateJob(jobId, { + status: 'ANALYZING', + last_state: 'ANALYZING', + start_time: nowIso(), + end_time: null, + error_message: null + }); + + void (async () => { + try { + const { ConverterPlugin } = require('../plugins/ConverterPlugin'); + const converterPlugin = new ConverterPlugin(); + const appendConverterProcessLine = (source, line, stream = 'stderr') => { + const normalizedSource = String(source || '').trim().toUpperCase() || 'PROCESS'; + const rawLine = String(line || '').trim(); + if (!rawLine) { + return; + } + const streamPrefix = stream === 'stdout' ? '[OUT] ' : ''; + void historyService.appendLog(jobId, normalizedSource, `${streamPrefix}${rawLine}`); + }; + + const analyzeCtx = await this.buildPluginContext('converter', { + jobId, + filePath: inputPath, + encodePlan, + emitProgress: (pct, statusText) => { + void this.updateProgress('ANALYZING', pct, null, statusText, jobId); + }, + appendLogLine: appendConverterProcessLine + }); + await converterPlugin.analyze(inputPath, job, analyzeCtx); + + if (this.cancelRequestedByJob.has(Number(jobId))) { + const cancelErr = new Error('Job wurde vom Benutzer abgebrochen.'); + cancelErr.runInfo = { status: 'CANCELLED' }; + throw cancelErr; + } + + await this.setState('ENCODING', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'Converter: Encoding läuft …', + context: { + jobId, + mode: 'converter', + mediaProfile: 'converter', + inputPath, + outputPath: outputPath || outputDir, + converterMediaType: encodePlan.converterMediaType || 'video' + } + }); + + await historyService.updateJob(jobId, { + status: 'ENCODING', + last_state: 'ENCODING' + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Converter-Encoding gestartet: ${path.basename(inputPath)} → ${encodePlan.converterMediaType || 'video'} / ${encodePlan.outputFormat || '?'}` + ); + + void this.notifyPushover('encoding_started', { + title: 'Ripster - Converter gestartet', + message: `${job.title || job.detected_title || `Job #${jobId}`} → ${outputPath || outputDir || '?'}` + }); + + const encodeCtx = await this.buildPluginContext('converter', { + jobId, + inputPath, + outputPath, + outputDir, + encodePlan, + isCancelled: () => this.cancelRequestedByJob.has(Number(jobId)), + onProcessHandle: (handle) => { + if (handle) { + this.activeProcesses.set(Number(jobId), handle); + } + }, + emitProgress: (pct, statusText, eta) => { + void this.updateProgress('ENCODING', pct, eta ?? null, statusText, jobId); + }, + appendLogLine: appendConverterProcessLine + }); + + await converterPlugin.encode(job, encodeCtx); + this.activeProcesses.delete(Number(jobId)); + + const finalOutputPath = outputPath || outputDir || null; + + // Eigentümer der Ausgabedateien setzen + if (finalOutputPath) { + const converterSettings = await settingsService.getSettingsMap(); + const converterMediaType = encodePlan.converterMediaType || 'video'; + const outputOwner = converterMediaType === 'audio' + ? String(converterSettings?.converter_audio_dir_owner || converterSettings?.converter_movie_dir_owner || '').trim() + : String(converterSettings?.converter_movie_dir_owner || '').trim(); + if (outputOwner) { + chownRecursive(finalOutputPath, outputOwner); + } + } + + await historyService.updateJob(jobId, { + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + rip_successful: 1, + output_path: finalOutputPath, + error_message: null + }); + await historyService.generateJobNfo(jobId, { + mode: 'auto', + requireSettingEnabled: true + }).catch((nfoError) => { + logger.warn('job:nfo:auto-generate-failed', { + jobId, + mode: 'converter', + error: errorToMeta(nfoError) + }); + }); + + // Datei-Zuweisungen im Explorer freigeben + try { + await require('./converterScanService').clearAssignmentsForJob(jobId); + } catch (_clearErr) { + // nicht kritisch + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Converter-Encoding abgeschlossen: ${finalOutputPath}` + ); + + if (Number(this.snapshot.activeJobId) === Number(jobId)) { + await this.setState('FINISHED', { + activeJobId: jobId, + progress: 100, + eta: null, + statusText: 'Converter abgeschlossen', + context: { + jobId, + mode: 'converter', + mediaProfile: 'converter', + outputPath: finalOutputPath + } + }); + } else { + void this.pumpQueue(); + } + + void this.notifyPushover('job_finished', { + title: 'Ripster - Converter abgeschlossen', + message: `${job.title || job.detected_title || `Job #${jobId}`} → ${finalOutputPath}` + }); + + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { + await this.setState('IDLE', { + finishingJobId: jobId, + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + }, 3000); + } catch (error) { + this.activeProcesses.delete(Number(jobId)); + logger.error('converter:encode:failed', { jobId, error: errorToMeta(error) }); + // Datei-Zuweisungen im Explorer freigeben (auch bei Abbruch/Fehler) + try { + await require('./converterScanService').clearAssignmentsForJob(jobId); + } catch (_clearErr) { + // nicht kritisch + } + await this.failJob(jobId, 'ENCODING', error); + } + })(); + + return { started: true, stage: 'ANALYZING', outputPath: outputPath || outputDir }; + } + + // ── CD Pipeline ───────────────────────────────────────────────────────────── + + async analyzeCd(device, options = {}) { + const devicePath = String(device?.path || '').trim(); + const detectedTitle = String( + device?.discLabel || device?.label || 'Audio CD' + ).trim(); + + logger.info('cd:analyze:start', { devicePath, detectedTitle }); + + const job = await historyService.createJob({ + discDevice: devicePath, + status: 'CD_METADATA_SELECTION', + detectedTitle, + jobKind: 'cd' + }); + + try { + const settings = await settingsService.getSettingsMap(); + let effectiveDetectedTitle = detectedTitle; + let cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; + + // Read TOC + this._setCdDriveState(devicePath, { + state: 'CD_ANALYZING', + jobId: job.id, + device, + progress: 0, + eta: null, + statusText: 'CD wird analysiert …', + context: { jobId: job.id, device, mediaProfile: 'cd' } + }); + + let tracks = null; + let pluginExecution = null; + const analyzePlugin = options?.plugin && typeof options.plugin === 'object' + ? options.plugin + : null; + if (analyzePlugin) { + const pluginContext = await this.buildPluginContext(analyzePlugin.id, { + discInfo: device, + jobId: job.id + }); + try { + const pluginResult = await analyzePlugin.analyze(devicePath, job, pluginContext); + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + if (Array.isArray(pluginResult?.tracks) && pluginResult.tracks.length > 0) { + tracks = pluginResult.tracks; + } + const pluginCmd = String(pluginResult?.cdparanoiaCmd || '').trim(); + if (pluginCmd) { + cdparanoiaCmd = pluginCmd; + } + const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); + if (pluginDetectedTitle) { + effectiveDetectedTitle = pluginDetectedTitle; + } + logger.info('plugin:analyze:used', { + jobId: job.id, + pluginId: analyzePlugin.id, + mediaProfile: 'cd' + }); + } catch (error) { + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + logger.warn('plugin:analyze:cd:fallback-legacy', { + jobId: job.id, + pluginId: analyzePlugin.id, + error: errorToMeta(error) + }); + } + } + + if (!Array.isArray(tracks) || tracks.length === 0) { + tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd); + } + logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length }); + if (!tracks.length) { + const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).'); + error.statusCode = 400; + throw error; + } + + const cdInfo = { + phase: 'PREPARE', + mediaProfile: 'cd', + preparedAt: nowIso(), + cdparanoiaCmd, + tracks, + detectedTitle: effectiveDetectedTitle + }; + const persistedCdInfo = this.withPluginExecutionMeta(cdInfo, pluginExecution); + + await historyService.updateJob(job.id, { + status: 'CD_METADATA_SELECTION', + last_state: 'CD_METADATA_SELECTION', + detected_title: effectiveDetectedTitle, + makemkv_info_json: JSON.stringify(persistedCdInfo) + }); + await historyService.appendLog( + job.id, + 'SYSTEM', + `CD analysiert: ${tracks.length} Track(s) gefunden.` + ); + + const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null; + const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; + this._setCdDriveState(devicePath, { + state: 'CD_METADATA_SELECTION', + jobId: job.id, + device, + progress: 0, + eta: null, + statusText: 'CD-Metadaten auswählen', + context: { + jobId: job.id, + device, + mediaProfile: 'cd', + devicePath, + cdparanoiaCmd, + cdparanoiaCommandPreview, + detectedTitle: effectiveDetectedTitle, + tracks, + ...(pluginExecution ? { pluginExecution } : {}) + } + }); + + return { jobId: job.id, detectedTitle: effectiveDetectedTitle, tracks }; + } catch (error) { + logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) }); + await this.failJob(job.id, 'CD_ANALYZING', error); + throw error; + } + } + + async searchMusicBrainz(query, options = {}) { + const expectedTrackCountRaw = Number(options?.trackCount); + const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0 + ? Math.trunc(expectedTrackCountRaw) + : null; + logger.info('musicbrainz:search', { query, expectedTrackCount }); + const results = await musicBrainzService.searchByTitle(query, { + trackCount: expectedTrackCount + }); + logger.info('musicbrainz:search:done', { query, expectedTrackCount, count: results.length }); + return results; + } + + async getMusicBrainzReleaseById(mbId) { + const id = String(mbId || '').trim(); + if (!id) { + const error = new Error('mbId fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('musicbrainz:get-by-id', { mbId: id }); + const release = await musicBrainzService.getReleaseById(id); + if (!release) { + const error = new Error(`MusicBrainz Release ${id} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + logger.info('musicbrainz:get-by-id:done', { mbId: id, trackCount: Array.isArray(release.tracks) ? release.tracks.length : 0 }); + return release; + } + + async selectCdMetadata(payload) { + const { + jobId, + title, + artist, + year, + mbId, + coverUrl, + tracks: selectedTracks + } = payload || {}; + + if (!jobId) { + const error = new Error('jobId fehlt.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + logger.info('cd:select-metadata', { jobId, title, artist, year, mbId }); + + const cdInfo = this.safeParseJson(job.makemkv_info_json) || {}; + const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; + const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: cdInfo, encodePlan }); + if (mediaProfile !== 'cd') { + const error = new Error(`Job ${jobId} ist kein Audio-CD-Job.`); + error.statusCode = 400; + throw error; + } + + // Merge track metadata from selection into existing TOC tracks + const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; + const mergedTracks = tocTracks.map((t) => { + const selected = Array.isArray(selectedTracks) + ? selectedTracks.find((st) => Number(st.position) === Number(t.position)) + : null; + const resolvedTitle = normalizeCdTrackText(selected?.title) || t.title || `Track ${t.position}`; + const resolvedArtist = normalizeCdTrackText(selected?.artist) || t.artist || artist || null; + return { + ...t, + title: resolvedTitle, + artist: resolvedArtist, + selected: selected ? Boolean(selected.selected) : true + }; + }); + + let effectiveCoverUrl = String(coverUrl || '').trim() || null; + let shouldQueueCoverCache = false; + if (effectiveCoverUrl && !thumbnailService.isLocalUrl(effectiveCoverUrl)) { + try { + const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, effectiveCoverUrl, { + source: 'Coverart', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + effectiveCoverUrl = cacheResult.localUrl; + } else { + shouldQueueCoverCache = true; + } + } catch (_error) { + shouldQueueCoverCache = true; + } + } + + const updatedCdInfo = { + ...cdInfo, + tracks: mergedTracks, + selectedMetadata: { title, artist, year, mbId, coverUrl: effectiveCoverUrl } + }; + + await historyService.updateJob(jobId, { + title: title || null, + year: year ? Number(year) : null, + poster_url: effectiveCoverUrl || null, + media_type: 'cd', + job_kind: 'cd', + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', + makemkv_info_json: JSON.stringify(updatedCdInfo) + }); + + // Fallback: async retry when sync cache was not successful. + if (shouldQueueCoverCache && effectiveCoverUrl && !thumbnailService.isLocalUrl(effectiveCoverUrl)) { + historyService.queuePosterCache(jobId, effectiveCoverUrl, { + source: 'Coverart', + logFailures: true + }); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).` + ); + + const resolvedDevicePath = String(job?.disc_device || '').trim() || null; + const resolvedRawWavDir = String(job?.raw_path || '').trim() || null; + const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; + const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null; + const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; + const existingDrive = resolvedDevicePath ? this.cdDrives.get(resolvedDevicePath) : null; + if (resolvedDevicePath) { + const isVirtualDevicePath = resolvedDevicePath.startsWith('__virtual__'); + this._setCdDriveState(resolvedDevicePath, { + state: 'CD_READY_TO_RIP', + jobId, + progress: 0, + eta: null, + statusText: isVirtualDevicePath ? 'CD bereit zum Encodieren' : 'CD bereit zum Rippen', + context: { + ...(existingDrive?.context || {}), + jobId, + mediaProfile: 'cd', + tracks: mergedTracks, + selectedMetadata: { title, artist, year, mbId, coverUrl: effectiveCoverUrl }, + devicePath: resolvedDevicePath, + cdparanoiaCmd: resolvedCdparanoiaCmd, + cdparanoiaCommandPreview, + ...(isVirtualDevicePath + ? { + virtualDrivePath: resolvedDevicePath, + skipRip: true, + rawWavDir: resolvedRawWavDir + } + : {}) + } + }); + } else { + // No real device — create/update virtual drive (orphan CD job) + const virtualDrivePath = `__virtual__${jobId}`; + const existingVirtualDrive = this.cdDrives.get(virtualDrivePath); + this._setCdDriveState(virtualDrivePath, { + state: 'CD_READY_TO_RIP', + jobId, + progress: 0, + eta: null, + statusText: 'CD bereit zum Encodieren', + context: { + ...(existingVirtualDrive?.context || {}), + jobId, + mediaProfile: 'cd', + tracks: mergedTracks, + selectedMetadata: { title, artist, year, mbId, coverUrl: effectiveCoverUrl }, + devicePath: null, + virtualDrivePath, + skipRip: true, + rawWavDir: resolvedRawWavDir, + cdparanoiaCmd: resolvedCdparanoiaCmd + } + }); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten bestätigt. Rip/Encode kann jetzt manuell über den Start-Button ausgelöst werden.' + ); + + return historyService.getJobById(jobId); + } + + async renameJobFolders(jobId) { + const job = await historyService.getJobById(jobId); + if (!job) { + return { renamed: [] }; + } + + const renamed = []; + const mediaProfile = this.resolveMediaProfileForJob(job); + const isCd = mediaProfile === 'cd'; + const isAudiobook = mediaProfile === 'audiobook'; + const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); + const mkInfo = this.safeParseJson(job.makemkv_info_json) || {}; + const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; + const selectedMetadata = resolveSelectedMetadataForJob(job, mkInfo?.analyzeContext || null, null); + + // Rename raw folder + const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null; + if (currentRawPath && fs.existsSync(currentRawPath)) { + let newRawPath; + if (isAudiobook) { + const audiobookMeta = buildAudiobookMetadataForJob(job, mkInfo, encodePlan); + const rawTemplate = String( + settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE + ).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE; + const currentInputPath = findPreferredRawInput(currentRawPath)?.path + || String(job.encode_input_path || mkInfo?.rawFilePath || '').trim() + || 'input.aax'; + const nextRaw = audiobookService.buildRawStoragePaths( + audiobookMeta, + jobId, + settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR, + rawTemplate, + path.basename(currentInputPath) + ); + newRawPath = nextRaw.rawDir; + } else { + const rawStorage = resolveSeriesAwareRawStorage( + settings || {}, + mediaProfile, + selectedMetadata, + mkInfo?.analyzeContext || null + ); + const rawBaseDir = String(rawStorage?.rawBaseDir || '').trim() || path.dirname(currentRawPath); + const newMetadataBase = buildRawMetadataBase({ + title: job.title || job.detected_title || null, + year: job.year || null, + detected_title: job.detected_title || null, + media_type: mediaProfile, + is_multipart_movie: Number(job?.is_multipart_movie || 0) === 1 ? 1 : 0, + job_kind: job?.job_kind || null + }, jobId, { + mediaProfile, + analyzeContext: mkInfo?.analyzeContext || null, + selectedMetadata, + isMultipartMovie: Number(job?.is_multipart_movie || 0) === 1 + || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child' + || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container' + }); + const currentState = resolveRawFolderStateFromPath(currentRawPath); + const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState }); + newRawPath = path.join(rawBaseDir, newRawDirName); + } + + if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(newRawPath) && !fs.existsSync(newRawPath)) { + try { + fs.mkdirSync(path.dirname(newRawPath), { recursive: true }); + fs.renameSync(currentRawPath, newRawPath); + const updatePayload = { raw_path: newRawPath }; + if (isAudiobook) { + const previousInputPath = String(job.encode_input_path || mkInfo?.rawFilePath || '').trim(); + if (previousInputPath && previousInputPath.startsWith(`${currentRawPath}${path.sep}`)) { + updatePayload.encode_input_path = path.join(newRawPath, path.basename(previousInputPath)); + } + } + await historyService.updateJob(jobId, updatePayload); + renamed.push({ type: 'raw', from: currentRawPath, to: newRawPath }); + logger.info('rename-job-folders:raw', { jobId, from: currentRawPath, to: newRawPath }); + } catch (err) { + logger.warn('rename-job-folders:raw-failed', { jobId, error: err.message }); + } + } + } + + // Rename output file (film) or output directory (CD) + const currentOutputPath = job.output_path ? path.resolve(job.output_path) : null; + if (currentOutputPath && fs.existsSync(currentOutputPath)) { + try { + if (isCd) { + const cdInfo = this.safeParseJson(job.makemkv_info_json) || {}; + const selectedMeta = cdInfo.selectedMetadata && typeof cdInfo.selectedMetadata === 'object' + ? cdInfo.selectedMetadata + : {}; + const cdMeta = { + artist: String(selectedMeta.artist || '').trim() || String(job.title || '').trim() || null, + album: String(job.title || selectedMeta.title || '').trim() || null, + year: job.year || selectedMeta.year || null + }; + const cdOutputBaseDir = String(settings.movie_dir || '').trim(); + const cdOutputTemplate = String(settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim(); + if (cdOutputBaseDir) { + const newCdOutputDir = cdRipService.buildOutputDir(cdMeta, cdOutputBaseDir, cdOutputTemplate); + if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newCdOutputDir) && !fs.existsSync(newCdOutputDir)) { + fs.mkdirSync(path.dirname(newCdOutputDir), { recursive: true }); + fs.renameSync(currentOutputPath, newCdOutputDir); + await historyService.updateJob(jobId, { output_path: newCdOutputDir }); + renamed.push({ type: 'output', from: currentOutputPath, to: newCdOutputDir }); + logger.info('rename-job-folders:cd-output', { jobId, from: currentOutputPath, to: newCdOutputDir }); + } + } + } else { + let newOutputPath = isAudiobook + ? buildAudiobookOutputConfig(settings, job, mkInfo, encodePlan, jobId).preferredFinalOutputPath + : buildFinalOutputPathFromJob(settings, job, jobId); + if (!isAudiobook && this.isMultipartMovieDiscChildHistoryJob(job)) { + const containerJobId = resolveMultipartContainerJobIdFromJob(job); + newOutputPath = buildIncompleteMergeOutputPathFromJob( + settings, + job, + newOutputPath, + jobId, + { + containerJobId + } + ); + } + if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newOutputPath) && !fs.existsSync(newOutputPath)) { + fs.mkdirSync(path.dirname(newOutputPath), { recursive: true }); + moveFileWithFallback(currentOutputPath, newOutputPath); + try { + const oldParentDir = path.dirname(currentOutputPath); + if (fs.readdirSync(oldParentDir).length === 0) { + fs.rmdirSync(oldParentDir); + } + } catch (_ignoreErr) {} + await historyService.updateJob(jobId, { output_path: newOutputPath }); + renamed.push({ type: 'output', from: currentOutputPath, to: newOutputPath }); + logger.info(isAudiobook ? 'rename-job-folders:audiobook-output' : 'rename-job-folders:film-output', { + jobId, + from: currentOutputPath, + to: newOutputPath + }); + } + } + } catch (err) { + logger.warn('rename-job-folders:output-failed', { jobId, isCd, error: err.message }); + } + } + + return { renamed }; + } + + async startCdRip(jobId, ripConfig) { + const resolvedCdRipJob = await this.resolveExistingJobForAction(jobId, 'start_cd_rip'); + jobId = resolvedCdRipJob.resolvedJobId; + this.ensureNotBusy('startCdRip', jobId); + this.cancelRequestedByJob.delete(Number(jobId)); + + const sourceJob = resolvedCdRipJob.job || await historyService.getJobById(jobId); + + let activeJobId = Number(jobId); + let activeJob = sourceJob; + const cdSettings = await settingsService.getEffectiveSettingsMap('cd'); + const sourceResolvedRawPath = this.resolveCurrentRawPathForSettings( + cdSettings, + 'cd', + sourceJob.raw_path + ); + const sourceHasSuccessfulRawRip = Number(sourceJob?.rip_successful || 0) === 1 + && Boolean(sourceResolvedRawPath); + const sourceStatus = String(sourceJob.status || sourceJob.last_state || '').trim().toUpperCase(); + const shouldReplaceSourceJob = Boolean(ripConfig?.createNewJob) + && (sourceStatus === 'CANCELLED' || sourceStatus === 'ERROR'); + if (shouldReplaceSourceJob) { + const replacementJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: 'CD_READY_TO_RIP', + detectedTitle: sourceJob.detected_title || sourceJob.title || null, + jobKind: this.resolveJobKindForJob(sourceJob, { + encodePlan: this.safeParseJson(sourceJob.encode_plan_json) + }) || 'cd' + }); + const replacementJobId = Number(replacementJob?.id || 0); + if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { + throw new Error('CD-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + + let replacementRawPath = sourceHasSuccessfulRawRip ? sourceResolvedRawPath : null; + if (replacementRawPath) { + replacementRawPath = await this.alignRawFolderJobId(replacementRawPath, replacementJobId); + } + + await historyService.updateJob(replacementJobId, { + parent_job_id: Number(jobId), + title: sourceJob.title || null, + year: sourceJob.year ?? null, + imdb_id: sourceJob.imdb_id || null, + poster_url: sourceJob.poster_url || null, + omdb_json: null, + selected_from_omdb: 0, + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', + error_message: null, + end_time: null, + output_path: null, + disc_device: sourceJob.disc_device || null, + raw_path: replacementRawPath, + rip_successful: sourceHasSuccessfulRawRip ? 1 : 0, + makemkv_info_json: sourceJob.makemkv_info_json || null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: sourceHasSuccessfulRawRip ? (sourceJob.encode_plan_json || null) : null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist + if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { + const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); + if (copiedUrl) { + await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); + } + } + + await historyService.appendLog( + replacementJobId, + 'USER_ACTION', + `CD-Rip Neustart aus Job #${jobId}. Alter Job wurde durch neuen Job ersetzt.` + ); + this._releaseDriveLockForJob(jobId, { reason: 'cd_restart_replaced' }); + await historyService.retireJobInFavorOf(jobId, replacementJobId, { + reason: 'cd_restart_rip' + }); + + activeJobId = replacementJobId; + activeJob = await historyService.getJobById(replacementJobId); + this.cancelRequestedByJob.delete(replacementJobId); + if (!activeJob) { + throw new Error(`CD-Neustart fehlgeschlagen: neuer Job #${replacementJobId} konnte nicht geladen werden.`); + } + } + + const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {}; + const devicePath = String(activeJob.disc_device || '').trim(); + const resolvedExistingRawPath = this.resolveCurrentRawPathForSettings( + cdSettings, + 'cd', + activeJob.raw_path + ); + const hasSuccessfulRawRip = Number(activeJob?.rip_successful || 0) === 1 + && Boolean(resolvedExistingRawPath); + const skipRip = Boolean(ripConfig?.skipRip || cdInfo?.skipRip || hasSuccessfulRawRip); + const skipEncode = Boolean(ripConfig?.skipEncode); + const resolvedCdRipPlugin = await this.resolveAnalyzePlugin({ mediaProfile: 'cd' }, 'rip').catch(() => null); + const cdRipPlugin = resolvedCdRipPlugin?.id === 'cd' ? resolvedCdRipPlugin : null; + + if (!devicePath && !skipRip) { + const error = new Error('Kein CD-Laufwerk bekannt.'); + error.statusCode = 400; + throw error; + } + + // For skipRip (orphan CD): look up virtual drive path or derive it + const effectiveDevicePath = devicePath || ( + this._getCdDriveByJobId(activeJobId)?.devicePath || `__virtual__${activeJobId}` + ); + + const format = String(ripConfig?.format || 'flac').trim().toLowerCase(); + const formatOptions = ripConfig?.formatOptions || {}; + const normalizeTrackPosition = (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; + const selectedTrackPositions = Array.isArray(ripConfig?.selectedTracks) + ? ripConfig.selectedTracks + .map(normalizeTrackPosition) + .filter((value) => Number.isFinite(value) && value > 0) + : []; + const normalizeOptionalYear = (value) => { + if (value === null || value === undefined || String(value).trim() === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; + + const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; + const incomingTracks = Array.isArray(ripConfig?.tracks) ? ripConfig.tracks : []; + const incomingByPosition = new Map(); + for (const incoming of incomingTracks) { + const position = normalizeTrackPosition(incoming?.position); + if (!position) { + continue; + } + incomingByPosition.set(position, incoming); + } + const selectedMeta = cdInfo.selectedMetadata || {}; + const incomingMeta = ripConfig?.metadata && typeof ripConfig.metadata === 'object' + ? ripConfig.metadata + : {}; + const effectiveSelectedMeta = { + ...selectedMeta, + title: normalizeCdTrackText(incomingMeta?.title) + || normalizeCdTrackText(selectedMeta?.title) + || normalizeCdTrackText(activeJob?.title) + || normalizeCdTrackText(cdInfo?.detectedTitle) + || 'Audio CD', + artist: normalizeCdTrackText(incomingMeta?.artist) + || normalizeCdTrackText(selectedMeta?.artist) + || null, + year: normalizeOptionalYear(incomingMeta?.year) + ?? normalizeOptionalYear(selectedMeta?.year) + ?? normalizeOptionalYear(activeJob?.year) + ?? null + }; + const mergedTracks = tocTracks.map((track) => { + const position = normalizeTrackPosition(track?.position); + if (!position) { + return null; + } + const incoming = incomingByPosition.get(position) || null; + const fallbackTitle = normalizeCdTrackText(track?.title) || `Track ${position}`; + const fallbackArtist = normalizeCdTrackText(track?.artist) || normalizeCdTrackText(effectiveSelectedMeta?.artist) || ''; + const title = normalizeCdTrackText(incoming?.title) || fallbackTitle; + const artist = normalizeCdTrackText(incoming?.artist) || fallbackArtist || null; + const selected = incoming + ? Boolean(incoming?.selected) + : (track?.selected !== false); + return { + ...track, + position, + title, + artist, + selected + }; + }).filter(Boolean); + + const effectiveSelectedTrackPositions = selectedTrackPositions.length > 0 + ? selectedTrackPositions + : mergedTracks.filter((track) => track?.selected !== false).map((track) => track.position); + const selectedPreEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPreEncodeScriptIds || []); + const selectedPostEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPostEncodeScriptIds || []); + const selectedPreEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPreEncodeChainIds || []); + const selectedPostEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPostEncodeChainIds || []); + const activeEncodePlan = this.safeParseJson(activeJob.encode_plan_json) || {}; + const plannedOutputConflict = activeEncodePlan?.outputConflict && typeof activeEncodePlan.outputConflict === 'object' + ? activeEncodePlan.outputConflict + : {}; + const incomingOutputConflict = ripConfig?.outputConflict && typeof ripConfig.outputConflict === 'object' + ? ripConfig.outputConflict + : {}; + const outputConflictStrategy = String( + incomingOutputConflict.strategy + || plannedOutputConflict.strategy + || '' + ).trim().toLowerCase(); + const keepBothOutput = outputConflictStrategy === 'keep_both' + || Boolean(incomingOutputConflict.keepBoth || plannedOutputConflict.keepBoth || ripConfig?.keepBoth); + const explicitDeleteFolders = Array.isArray(ripConfig?.deleteFolders) + ? ripConfig.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + if (explicitDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(activeJobId, explicitDeleteFolders); + await historyService.appendLog( + activeJobId, + 'USER_ACTION', + `CD-Start: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('startCdRip:delete-specific-folders-failed', { + jobId: activeJobId, + error: errorToMeta(error) + }); + } + } + + const [ + selectedPreEncodeScripts, + selectedPostEncodeScripts, + selectedPreEncodeChains, + selectedPostEncodeChains + ] = await Promise.all([ + scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }), + scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { strict: true }), + scriptChainService.getChainsByIds(selectedPreEncodeChainIds), + scriptChainService.getChainsByIds(selectedPostEncodeChainIds) + ]); + + const ensureResolvedChains = (requestedIds, resolvedChains, fieldName) => { + const resolved = Array.isArray(resolvedChains) ? resolvedChains : []; + const resolvedSet = new Set( + resolved + .map((chain) => Number(chain?.id)) + .filter((id) => Number.isFinite(id) && id > 0) + .map((id) => Math.trunc(id)) + ); + const missing = requestedIds.filter((id) => !resolvedSet.has(Number(id))); + if (missing.length === 0) { + return; + } + const error = new Error(`Skriptkette(n) nicht gefunden: ${missing.join(', ')}`); + error.statusCode = 400; + error.details = [{ field: fieldName, message: `Nicht gefunden: ${missing.join(', ')}` }]; + throw error; + }; + ensureResolvedChains(selectedPreEncodeChainIds, selectedPreEncodeChains, 'selectedPreEncodeChainIds'); + ensureResolvedChains(selectedPostEncodeChainIds, selectedPostEncodeChains, 'selectedPostEncodeChainIds'); + + const toScriptDescriptor = (script) => { + const id = Number(script?.id); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + if (!normalizedId) { + return null; + } + const name = String(script?.name || '').trim() || `Skript #${normalizedId}`; + return { id: normalizedId, name }; + }; + const toChainDescriptor = (chain) => { + const id = Number(chain?.id); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + if (!normalizedId) { + return null; + } + const name = String(chain?.name || '').trim() || `Kette #${normalizedId}`; + return { id: normalizedId, name }; + }; + + const settings = cdSettings; + const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; + const cdOutputTemplate = String( + settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE + ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; + const cdRawBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR; + const cdOutputBaseDir = String(settings.movie_dir || '').trim() || cdRawBaseDir; + const cdRawOwner = String(settings.raw_dir_owner || '').trim(); + const cdOutputOwner = String(settings.movie_dir_owner || settings.raw_dir_owner || '').trim(); + + // For skipRip: use existing WAV dir instead of creating a new raw directory + let rawWavDir; + let rawJobDir; + let cdMetadataBase = null; + if (skipRip) { + const existingRawPath = this.resolveCurrentRawPathForSettings(settings, 'cd', activeJob.raw_path); + if (!existingRawPath) { + const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht erreichbar (${activeJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + rawWavDir = existingRawPath; + rawJobDir = existingRawPath; + } else { + cdMetadataBase = buildRawMetadataBase({ + title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, + artist: effectiveSelectedMeta?.artist || null, + year: effectiveSelectedMeta?.year || null + }, activeJobId, { + mediaProfile: 'cd', + selectedMetadata: effectiveSelectedMeta, + artist: effectiveSelectedMeta?.artist || null, + album: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, + year: effectiveSelectedMeta?.year || null + }); + const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); + rawJobDir = path.join(cdRawBaseDir, rawDirName); + rawWavDir = rawJobDir; + ensureDir(cdRawBaseDir); + ensureDir(rawJobDir); + chownRecursive(rawJobDir, cdRawOwner); + } + + let outputDir = null; + if (!skipEncode) { + const baseOutputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate); + const baseOutputStillExists = (() => { + try { + return fs.existsSync(baseOutputDir); + } catch (_error) { + return false; + } + })(); + // Conflict strategy: + // - keepBoth => always number (_X) + // - replace + remaining sibling folders => continue numbering (_X) + // - replace + all removed => reuse base folder without numbering + const needsNumberedOutput = keepBothOutput || baseOutputStillExists; + outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir; + ensureDir(outputDir); + chownRecursive(outputDir, cdOutputOwner); + } + const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1; + const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`); + const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${effectiveDevicePath || ''} ${previewTrackPos} ${previewWavPath}`; + const cdLiveTrackRows = buildCdLiveTrackRows( + effectiveSelectedTrackPositions, + mergedTracks, + effectiveSelectedMeta?.artist + ); + const initialCdLive = buildCdLiveProgressSnapshot({ + trackRows: cdLiveTrackRows, + phase: skipRip ? 'encode' : 'rip', + trackIndex: cdLiveTrackRows.length > 0 ? 1 : 0, + trackTotal: cdLiveTrackRows.length, + trackPosition: cdLiveTrackRows[0]?.position || null, + ripCompletedCount: skipRip ? cdLiveTrackRows.length : 0, + encodeCompletedCount: 0 + }); + const startedAt = nowIso(); + const cdEncodePlan = { + format, + formatOptions, + selectedTracks: effectiveSelectedTrackPositions, + tracks: mergedTracks, + directReencodeReady: skipRip || !skipEncode, + directReencodeReadyAt: skipRip || !skipEncode ? startedAt : null, + outputTemplate: cdOutputTemplate, + outputConflict: { + strategy: keepBothOutput ? 'keep_both' : 'replace', + keepBoth: keepBothOutput + }, + preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)), + postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), + preEncodeScripts: selectedPreEncodeScripts.map(toScriptDescriptor).filter(Boolean), + postEncodeScripts: selectedPostEncodeScripts.map(toScriptDescriptor).filter(Boolean), + preEncodeChainIds: selectedPreEncodeChainIds, + postEncodeChainIds: selectedPostEncodeChainIds, + preEncodeChains: selectedPreEncodeChains.map(toChainDescriptor).filter(Boolean), + postEncodeChains: selectedPostEncodeChains.map(toChainDescriptor).filter(Boolean) + }; + + const updatedCdInfo = { + ...cdInfo, + tracks: mergedTracks, + selectedMetadata: effectiveSelectedMeta + }; + const jobUpdate = { + title: effectiveSelectedMeta?.title || null, + year: normalizeOptionalYear(effectiveSelectedMeta?.year), + status: 'CD_RIPPING', + last_state: 'CD_RIPPING', + start_time: startedAt, + end_time: null, + error_message: null, + output_path: outputDir, + handbrake_info_json: null, + mediainfo_info_json: null, + makemkv_info_json: JSON.stringify(updatedCdInfo), + encode_plan_json: JSON.stringify(cdEncodePlan) + }; + // Only overwrite raw_path for normal rips; skipRip keeps the existing WAV directory + if (!skipRip) { + jobUpdate.raw_path = rawJobDir; + jobUpdate.rip_successful = 0; + } + await historyService.updateJob(activeJobId, jobUpdate); + + const existingCdDrive = this.cdDrives.get(effectiveDevicePath); + this._setCdDriveState(effectiveDevicePath, { + state: 'CD_RIPPING', + jobId: activeJobId, + progress: 0, + eta: null, + statusText: skipRip ? 'Tracks werden encodiert …' : (skipEncode ? 'CD wird RAW gerippt …' : 'CD wird gerippt …'), + context: { + ...(existingCdDrive?.context || {}), + jobId: activeJobId, + mediaProfile: 'cd', + tracks: mergedTracks, + selectedMetadata: effectiveSelectedMeta, + devicePath: devicePath || null, + virtualDrivePath: skipRip ? effectiveDevicePath : undefined, + skipRip: skipRip || undefined, + cdparanoiaCmd, + rawWavDir, + outputPath: outputDir, + outputTemplate: cdOutputTemplate, + cdRipConfig: cdEncodePlan, + cdLive: initialCdLive, + cdparanoiaCommandPreview + } + }); + + const isOrphanRawImportCdJob = String(cdInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + if (!skipRip && !isOrphanRawImportCdJob && !String(effectiveDevicePath || '').startsWith('__virtual__')) { + this._acquireDriveLockForJob(effectiveDevicePath, activeJobId, { + stage: 'CD_RIPPING', + source: 'CDPARANOIA_RIP', + mediaProfile: 'cd', + reason: 'rip_running' + }); + } else { + this._releaseDriveLockForJob(activeJobId, { reason: isOrphanRawImportCdJob ? 'orphan_raw_rip_bypass' : 'encode_from_raw' }); + } + + logger.info('cd:rip:start', { + jobId: activeJobId, + devicePath: effectiveDevicePath, + skipRip, + skipEncode, + pluginId: cdRipPlugin?.id || null, + format, + trackCount: effectiveSelectedTrackPositions.length + }); + await historyService.appendLog( + activeJobId, + 'SYSTEM', + skipRip + ? `CD-Encode aus RAW gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + : ( + skipEncode + ? `CD-RAW-Rip gestartet: Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + : `CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + ) + ); + if ( + selectedPreEncodeScripts.length > 0 + || selectedPreEncodeChains.length > 0 + || selectedPostEncodeScripts.length > 0 + || selectedPostEncodeChains.length > 0 + ) { + await historyService.appendLog( + activeJobId, + 'SYSTEM', + `CD Skript-Auswahl: Pre-Skripte=${selectedPreEncodeScripts.length}, Pre-Ketten=${selectedPreEncodeChains.length}, ` + + `Post-Skripte=${selectedPostEncodeScripts.length}, Post-Ketten=${selectedPostEncodeChains.length}.` + ); + } + + // Run asynchronously so the HTTP response returns immediately + this._runCdRip({ + jobId: activeJobId, + devicePath: effectiveDevicePath, + cdparanoiaCmd, + rawWavDir, + rawBaseDir: skipRip ? null : cdRawBaseDir, + cdMetadataBase, + outputDir, + format, + formatOptions, + outputTemplate: cdOutputTemplate, + rawOwner: cdRawOwner, + outputOwner: cdOutputOwner, + selectedTrackPositions: effectiveSelectedTrackPositions, + tocTracks: mergedTracks, + selectedMeta: effectiveSelectedMeta, + encodePlan: cdEncodePlan, + ripPlugin: cdRipPlugin, + skipRip, + skipEncode + }).catch((error) => { + logger.error('cd:rip:unhandled', { jobId: activeJobId, error: errorToMeta(error) }); + }); + + return { + jobId: activeJobId, + sourceJobId: shouldReplaceSourceJob ? Number(jobId) : null, + replacedSourceJob: shouldReplaceSourceJob, + started: true + }; + } + + async _runCdRip({ + jobId, + devicePath, + cdparanoiaCmd, + rawWavDir, + rawBaseDir, + cdMetadataBase, + outputDir, + format, + formatOptions, + outputTemplate, + rawOwner, + outputOwner, + selectedTrackPositions, + tocTracks, + selectedMeta, + encodePlan = null, + ripPlugin = null, + skipRip = false, + skipEncode = false + }) { + const processKey = Number(jobId); + let currentProcessHandle = null; + let lifecycleResolve = null; + let lifecycleSettled = false; + const lifecyclePromise = new Promise((resolve) => { + lifecycleResolve = resolve; + }); + const settleLifecycle = () => { + if (lifecycleSettled) { + return; + } + lifecycleSettled = true; + lifecycleResolve({ settled: true }); + }; + const sharedHandle = { + child: null, + promise: lifecyclePromise, + cancel: () => { + try { + currentProcessHandle?.cancel?.(); + } catch (_error) { + // ignore cancel race errors + } + } + }; + let currentTrackPosition = null; + let buildLiveContext = () => null; + this.activeProcesses.set(processKey, sharedHandle); + this.syncPrimaryActiveProcess(); + + try { + const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []); + const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []); + const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []); + const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []); + const preAutomationCount = preScriptIds.length + preChainIds.length; + const postAutomationCount = postScriptIds.length + postChainIds.length; + const preEncodeScriptsSummary = { + configured: preAutomationCount, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + queued: preAutomationCount > 0, + detachedQueue: preAutomationCount > 0, + results: [] + }; + let postEncodeScriptsSummary = { + configured: postAutomationCount, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + queued: postAutomationCount > 0, + detachedQueue: postAutomationCount > 0, + results: [] + }; + const selectedTrackOrder = normalizeCdTrackPositionList(selectedTrackPositions); + const liveTrackRows = buildCdLiveTrackRows(selectedTrackOrder, tocTracks, selectedMeta?.artist); + const effectiveTrackTotal = liveTrackRows.length; + let ripCompletedCount = 0; + let encodeCompletedCount = 0; + let currentPhase = 'rip'; + let currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0; + currentTrackPosition = liveTrackRows[0]?.position || null; + buildLiveContext = (failedTrackPosition = null) => buildCdLiveProgressSnapshot({ + trackRows: liveTrackRows, + phase: currentPhase, + trackIndex: currentTrackIndex, + trackTotal: effectiveTrackTotal, + trackPosition: currentTrackPosition, + ripCompletedCount, + encodeCompletedCount, + failedTrackPosition + }); + if (preAutomationCount > 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Pre-Rip Automationen laufen als eigene Queue-Einträge (Pre=${preAutomationCount}).` + ); + } + let encodeStateApplied = false; + let lastRipProgressPercent = 0; + let lastEncodeProgressPercent = 0; + const bindProcessHandle = (handle) => { + currentProcessHandle = handle && typeof handle === 'object' ? handle : null; + sharedHandle.child = currentProcessHandle?.child || null; + this.syncPrimaryActiveProcess(); + if (this.cancelRequestedByJob.has(processKey)) { + try { + currentProcessHandle?.cancel?.(); + } catch (_error) { + // ignore cancel race errors + } + } + }; + const handleRipProgress = async ({ phase, percent, trackIndex, trackTotal, trackPosition, trackEvent }) => { + const normalizedPhase = phase === 'encode' ? 'encode' : 'rip'; + const stage = normalizedPhase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING'; + const normalizedTrackTotal = normalizePositiveInteger(trackTotal) || effectiveTrackTotal; + const normalizedTrackIndex = normalizePositiveInteger(trackIndex) + || currentTrackIndex + || (normalizedTrackTotal > 0 ? 1 : 0); + const normalizedTrackPosition = normalizePositiveInteger(trackPosition) || currentTrackPosition || null; + const normalizedTrackEvent = String(trackEvent || '').trim().toLowerCase(); + let clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0)); + if (normalizedPhase === 'rip') { + if (clampedPercent < lastRipProgressPercent) { + clampedPercent = lastRipProgressPercent; + } + lastRipProgressPercent = clampedPercent; + } else { + if (clampedPercent < lastEncodeProgressPercent) { + clampedPercent = lastEncodeProgressPercent; + } + lastEncodeProgressPercent = clampedPercent; + } + clampedPercent = Number(clampedPercent.toFixed(2)); + + if (normalizedPhase === 'rip') { + currentPhase = 'rip'; + currentTrackIndex = normalizedTrackIndex; + currentTrackPosition = normalizedTrackPosition; + if (normalizedTrackEvent === 'complete') { + ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackIndex); + if (ripCompletedCount >= normalizedTrackTotal) { + currentTrackPosition = null; + } + } else { + ripCompletedCount = Math.max(ripCompletedCount, Math.max(0, normalizedTrackIndex - 1)); + } + } else { + currentPhase = 'encode'; + ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackTotal); + currentTrackIndex = normalizedTrackIndex; + currentTrackPosition = normalizedTrackPosition; + if (normalizedTrackEvent === 'complete') { + encodeCompletedCount = Math.max(encodeCompletedCount, normalizedTrackIndex); + if (encodeCompletedCount >= normalizedTrackTotal) { + currentTrackPosition = null; + } + } else { + encodeCompletedCount = Math.max(encodeCompletedCount, Math.max(0, normalizedTrackIndex - 1)); + } + } + + if (!skipEncode && normalizedPhase === 'encode' && !encodeStateApplied) { + encodeStateApplied = true; + await historyService.updateJob(jobId, { + status: 'CD_ENCODING', + last_state: 'CD_ENCODING' + }); + } + + const detail = Number.isFinite(Number(trackIndex)) && Number.isFinite(Number(trackTotal)) && Number(trackTotal) > 0 + ? ` (${Math.trunc(Number(trackIndex))}/${Math.trunc(Number(trackTotal))})` + : ''; + const statusText = normalizedPhase === 'rip' + ? `${skipEncode ? 'CD wird RAW gerippt' : 'CD wird gerippt'} …${detail}` + : `Tracks werden encodiert …${detail}`; + + await this.updateProgress(stage, clampedPercent, null, statusText, processKey, { + contextPatch: { + cdLive: buildLiveContext(null) + } + }); + // Keep cdDrives entry in sync (no broadcast — PIPELINE_PROGRESS covers real-time updates) + const currentCdDriveEntry = this.cdDrives.get(devicePath); + if (currentCdDriveEntry) { + this.cdDrives.set(devicePath, { + ...currentCdDriveEntry, + state: stage, + progress: clampedPercent, + statusText, + context: { ...(currentCdDriveEntry.context || {}), cdLive: buildLiveContext(null) } + }); + } + }; + + let cdRipResult = null; + if (ripPlugin?.id === 'cd') { + const pluginJob = await historyService.getJobById(jobId); + const pluginCtx = await this.buildPluginContext(ripPlugin.id, { + jobId, + emitProgress: (progressPercent, statusText) => { + const fallbackPercentRaw = Math.max(0, Math.min(100, Number(progressPercent) || 0)); + const fallbackStage = currentPhase === 'encode' ? 'CD_ENCODING' : 'CD_RIPPING'; + const fallbackPercent = currentPhase === 'encode' + ? Math.max(lastEncodeProgressPercent, fallbackPercentRaw) + : Math.max(lastRipProgressPercent, fallbackPercentRaw); + if (currentPhase === 'encode') { + lastEncodeProgressPercent = fallbackPercent; + } else { + lastRipProgressPercent = fallbackPercent; + } + void this.updateProgress(fallbackStage, fallbackPercent, null, statusText || null, processKey, { + contextPatch: { cdLive: buildLiveContext(null) } + }); + }, + ripConfig: { + format, + formatOptions, + selectedTracks: selectedTrackOrder, + tracks: tocTracks, + metadata: selectedMeta, + skipRip, + skipEncode + }, + rawWavDir, + outputDir, + outputTemplate, + isCancelled: () => this.cancelRequestedByJob.has(processKey), + onProcessHandle: bindProcessHandle, + onProgress: (event) => { + handleRipProgress(event).catch((progressError) => { + logger.debug('cd:rip:plugin-progress-update-failed', { + jobId, + error: errorToMeta(progressError) + }); + }); + }, + onLog: async (_level, msg) => { + await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); + }, + context: { jobId: processKey } + }); + cdRipResult = await ripPlugin.rip( + pluginJob || { id: jobId, disc_device: devicePath || null, makemkv_info_json: null }, + pluginCtx + ); + logger.info('plugin:cd:rip:used', { + jobId, + pluginId: ripPlugin.id, + skipRip, + skipEncode, + trackCount: selectedTrackOrder.length + }); + } else { + cdRipResult = await cdRipService.ripAndEncode({ + jobId, + devicePath, + cdparanoiaCmd, + rawWavDir, + outputDir, + format, + formatOptions, + outputTemplate, + selectedTracks: selectedTrackOrder, + tracks: tocTracks, + meta: selectedMeta, + skipRip, + skipEncode, + onProcessHandle: bindProcessHandle, + isCancelled: () => this.cancelRequestedByJob.has(processKey), + onProgress: handleRipProgress, + onLog: async (_level, msg) => { + await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); + }, + context: { jobId: processKey } + }); + } + const { encodeResults: cdEncodeResults = [] } = cdRipResult || {}; + settleLifecycle(); + postEncodeScriptsSummary = { + ...this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan), + pending: false, + queued: postAutomationCount > 0, + detachedQueue: postAutomationCount > 0 + }; + + // RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen + let activeRawDir = rawWavDir; + if (!skipRip && rawBaseDir && cdMetadataBase) { + try { + const targetRawState = skipEncode ? RAW_FOLDER_STATES.RIP_COMPLETE : RAW_FOLDER_STATES.COMPLETE; + const completedRawDirName = buildRawDirName(cdMetadataBase, jobId, { state: targetRawState }); + const completedRawDir = path.join(rawBaseDir, completedRawDirName); + if (activeRawDir !== completedRawDir && fs.existsSync(activeRawDir) && !fs.existsSync(completedRawDir)) { + fs.renameSync(activeRawDir, completedRawDir); + activeRawDir = completedRawDir; + } + } catch (_renameError) { + // ignore – raw dir bleibt unter bestehendem Namen zugänglich + } + } + const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso(); + const persistedEncodePlan = { + ...normalizedEncodePlan, + directReencodeReady: true, + directReencodeReadyAt + }; + + if (skipEncode) { + await historyService.updateJob(jobId, { + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', + end_time: null, + rip_successful: 1, + raw_path: activeRawDir, + output_path: null, + encode_input_path: null, + encode_review_confirmed: 0, + encode_plan_json: JSON.stringify(persistedEncodePlan), + handbrake_info_json: JSON.stringify({ + mode: 'cd_rip_raw', + tracks: cdEncodeResults, + preEncodeScripts: preEncodeScriptsSummary, + postEncodeScripts: postEncodeScriptsSummary + }) + }); + + const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId); + if (cdPromotedUrl) { + await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); + } + + chownRecursive(activeRawDir, rawOwner || outputOwner); + await historyService.appendLog( + jobId, + 'SYSTEM', + `CD-RAW-Rip abgeschlossen. Laufwerk freigegeben. Encode-Auswahl kann jetzt gestartet werden (RAW: ${activeRawDir}).` + ); + + this._releaseDriveLockForJob(jobId, { reason: 'cd_raw_rip_successful' }); + + if (String(devicePath || '').startsWith('__virtual__')) { + this._removeCdDrive(devicePath); + } else { + this._releaseCdDrive(devicePath); + } + + const readyVirtualDrivePath = `__virtual__${jobId}`; + currentPhase = 'encode'; + ripCompletedCount = effectiveTrackTotal; + encodeCompletedCount = 0; + currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0; + currentTrackPosition = liveTrackRows[0]?.position || null; + const readyCdLive = buildLiveContext(null); + + this._setCdDriveState(readyVirtualDrivePath, { + state: 'CD_READY_TO_RIP', + jobId, + progress: 0, + eta: null, + statusText: 'RAW-Rip abgeschlossen - Auswahl und Encode bereit', + context: { + jobId, + mediaProfile: 'cd', + devicePath: null, + virtualDrivePath: readyVirtualDrivePath, + skipRip: true, + cdparanoiaCmd, + rawWavDir: activeRawDir, + outputPath: null, + outputTemplate, + tracks: tocTracks, + selectedMetadata: selectedMeta, + cdRipConfig: persistedEncodePlan, + cdLive: readyCdLive + } + }); + this.jobProgress.delete(processKey); + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - CD RAW bereit', + message: `Job #${jobId}: RAW-Rip abgeschlossen, Auswahl/Encode bereit` + }); + if (!String(devicePath || '').startsWith('__virtual__')) { + void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); + } + } else { + await historyService.updateJob(jobId, { + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + rip_successful: 1, + raw_path: activeRawDir, + output_path: outputDir, + handbrake_info_json: JSON.stringify({ + mode: 'cd_rip', + tracks: cdEncodeResults, + preEncodeScripts: preEncodeScriptsSummary, + postEncodeScripts: postEncodeScriptsSummary + }), + encode_plan_json: JSON.stringify(persistedEncodePlan) + }); + + const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId); + if (cdPromotedUrl) { + await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); + } + + chownRecursive(activeRawDir, rawOwner || outputOwner); + if (outputDir) { + chownRecursive(outputDir, outputOwner); + await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); + historyService.addJobOutputFolder(jobId, outputDir).catch((e) => { + logger.warn('cd:record-output-folder-failed', { jobId, error: e?.message }); + }); + } else { + await historyService.appendLog(jobId, 'SYSTEM', 'CD-Rip abgeschlossen.'); + } + + this._releaseDriveLockForJob(jobId, { reason: 'cd_rip_successful' }); + const finishedStatusText = postEncodeScriptsSummary.failed > 0 + ? `CD-Rip abgeschlossen (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)` + : 'CD-Rip abgeschlossen'; + currentPhase = 'encode'; + ripCompletedCount = effectiveTrackTotal; + encodeCompletedCount = effectiveTrackTotal; + currentTrackIndex = effectiveTrackTotal; + currentTrackPosition = null; + const finishedCdLive = buildLiveContext(null); + + this._setCdDriveState(devicePath, { + state: 'FINISHED', + jobId, + progress: 100, + eta: null, + statusText: finishedStatusText, + context: { + jobId, + mediaProfile: 'cd', + tracks: tocTracks, + outputDir, + outputPath: outputDir, + cdRipConfig: persistedEncodePlan, + cdLive: finishedCdLive, + selectedMetadata: selectedMeta + } + }); + // Virtual drives (orphan CD jobs without a real device) are removed after encode; real drives reset to DISC_DETECTED + if (String(devicePath || '').startsWith('__virtual__')) { + this._removeCdDrive(devicePath); + } else { + this._releaseCdDrive(devicePath); + } + this.jobProgress.delete(processKey); + + void this.notifyPushover('job_finished', { + title: 'Ripster - CD Rip erfolgreich', + message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}` + }); + if (!String(devicePath || '').startsWith('__virtual__')) { + void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); + } + } + if (postAutomationCount > 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Rip Automationen laufen als eigene Queue-Einträge (Post=${postAutomationCount}).` + ); + } + } catch (error) { + settleLifecycle(); + const failedCdLive = buildLiveContext(currentTrackPosition || null); + const currentDriveEntry = this.cdDrives.get(devicePath); + const currentDriveState = currentDriveEntry?.state || 'CD_RIPPING'; + const failStage = currentDriveState === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING'; + await this.updateProgress(failStage, currentDriveEntry?.progress ?? 0, null, currentDriveEntry?.statusText ?? null, processKey, { + contextPatch: { cdLive: failedCdLive } + }); + logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) }); + await this.failJob(jobId, failStage, error); + } finally { + this.activeProcesses.delete(processKey); + this.syncPrimaryActiveProcess(); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CONVERTER + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Zentraler Intake für dateibasierte Jobs. + * Bestehende Endpunkte bleiben erhalten und delegieren intern auf diese Methode. + * + * @param {object} payload + * @param {'audiobook_upload'|'audiobook'|'converter_entry'|'converter'} payload.kind + * @param {object} [payload.file] + * @param {string} [payload.relPath] + * @param {object} [payload.options] + * @returns {Promise} + */ + async createFileJob(payload = {}) { + const inferredKind = String(payload?.kind || '').trim().toLowerCase() + || (payload?.file ? 'audiobook_upload' : '') + || (payload?.relPath ? 'converter_entry' : ''); + const kind = inferredKind; + + if (kind === 'audiobook_upload' || kind === 'audiobook') { + if (!payload?.file) { + const error = new Error('Upload-Datei fehlt.'); + error.statusCode = 400; + throw error; + } + return this.uploadAudiobookFile(payload.file, payload?.options || {}); + } + + if (kind === 'converter_entry' || kind === 'converter') { + const relPath = String(payload?.relPath || '').trim(); + if (!relPath) { + const error = new Error('relPath fehlt.'); + error.statusCode = 400; + throw error; + } + return this.createConverterJobFromEntry(relPath, payload?.options || {}); + } + + const error = new Error(`Unbekannter File-Job-Typ: ${kind || 'n/a'}`); + error.statusCode = 400; + throw error; + } + + /** + * Erstellt einen Converter-Job aus einem Scan-Eintrag (File-Explorer-Auswahl). + * + * @param {string} relPath - Relativer Pfad innerhalb von converter_raw_dir + * @param {object} options + * @param {string} options.converterMediaType - 'video'|'audio'|'iso' + * @param {boolean} options.isFolder - Ordner-Job? + * @returns {Promise} Job-Objekt + */ + async createConverterJobFromEntry(relPath, options = {}) { + const converterScanService = require('./converterScanService'); + const rawDir = await converterScanService.getRawDir(); + + if (!rawDir) { + const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); + error.statusCode = 400; + throw error; + } + + const fullPath = path.join(rawDir, relPath); + if (!fs.existsSync(fullPath)) { + const error = new Error(`Eingabedatei nicht gefunden: ${fullPath}`); + error.statusCode = 404; + throw error; + } + + const stat = fs.statSync(fullPath); + const isDirectory = stat.isDirectory(); + const baseName = path.basename(relPath, path.extname(relPath)); + const detectedTitle = baseName || 'Converter Job'; + + let converterMediaType = options.converterMediaType + || converterScanService.detectMediaType(path.basename(relPath)); + + // Für Ordner ohne erkannte Dateiendung: Medientyp anhand der enthaltenen Dateien bestimmen + if (isDirectory && !converterMediaType) { + try { + const dirFiles = fs.readdirSync(fullPath, { withFileTypes: true }); + let audioCount = 0; + let videoCount = 0; + for (const f of dirFiles) { + if (!f.isFile()) continue; + const mt = converterScanService.detectMediaType(f.name); + if (mt === 'audio') audioCount++; + else if (mt === 'video' || mt === 'iso') videoCount++; + } + // Mehrheit gewinnt; bei Gleichstand gilt audio (häufigster Fall: Musikalbum) + if (audioCount > 0 && audioCount >= videoCount) converterMediaType = 'audio'; + else if (videoCount > 0) converterMediaType = 'video'; + } catch (_err) { + // Ordner nicht lesbar — bleibt null + } + } + + const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso'; + const initialStatus = 'READY_TO_START'; + const normalizedConverterMediaType = ['audio', 'video', 'iso'].includes( + String(converterMediaType || '').trim().toLowerCase() + ) + ? String(converterMediaType || '').trim().toLowerCase() + : 'video'; + const job = await historyService.createJob({ + discDevice: null, + status: initialStatus, + detectedTitle, + mediaType: 'converter', + jobKind: resolveConverterJobKind(normalizedConverterMediaType) + }); + + await historyService.updateJob(job.id, { + media_type: 'converter', + job_kind: resolveConverterJobKind(normalizedConverterMediaType), + raw_path: fullPath, + encode_plan_json: JSON.stringify({ + mediaProfile: 'converter', + jobKind: resolveConverterJobKind(normalizedConverterMediaType), + converterMediaType: normalizedConverterMediaType, + inputPath: fullPath, + isFolder: isDirectory, + audioFiles: isDirectory && normalizedConverterMediaType === 'audio' + ? require('./converterScanService').detectFormat + ? null // wird beim Start gefüllt + : null + : null + }), + last_state: initialStatus + }); + + // Scan-Eintrag als Job markieren + await converterScanService.markEntryAsJob(relPath, job.id); + + await historyService.appendLog( + job.id, + 'SYSTEM', + `Converter-Job erstellt aus: ${relPath} | Typ: ${normalizedConverterMediaType || '-'}` + ); + if (isVideoLikeConverterJob) { + await historyService.appendLog( + job.id, + 'SYSTEM', + 'Metadaten sind optional. Review kann direkt gestartet werden; TMDb-Zuordnung ist jederzeit nachträglich möglich.' + ); + } + + logger.info('converter:job:created-from-entry', { + jobId: job.id, relPath, converterMediaType: normalizedConverterMediaType, fullPath + }); + + return historyService.getJobById(job.id); + } + + /** + * Lädt Converter-Dateien hoch und legt Unterordner in converter_raw_dir an. + * Erstellt KEINE Jobs – das geschieht separat via createConverterJobsFromSelection. + * + * @param {Array<{path, originalname, size}>} files - Multer-Dateiobjekte + * @returns {Promise<{folders: Array<{folderRelPath, fileCount}>}>} + */ + async uploadConverterFiles(files, options = {}) { + const converterScanService = require('./converterScanService'); + const rawDir = await converterScanService.getRawDir(); + + if (!rawDir) { + const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); + error.statusCode = 400; + throw error; + } + + ensureDir(rawDir); + + const normalizedFiles = Array.isArray(files) ? files : [files]; + if (normalizedFiles.length === 0) { + const error = new Error('Keine Dateien zum Upload.'); + error.statusCode = 400; + throw error; + } + + const settings = await settingsService.getSettingsMap(); + const allowedExtensions = new Set(parseConverterScanExtensions(settings?.converter_scan_extensions)); + const invalidFiles = []; + for (const file of normalizedFiles) { + const tempPath = String(file?.path || '').trim(); + const originalName = String(file?.originalname || file?.originalName || '').trim() + || path.basename(tempPath || 'upload'); + const extension = getFileExtensionWithoutDot(originalName); + if (!extension || !allowedExtensions.has(extension)) { + invalidFiles.push(originalName); + } + } + if (invalidFiles.length > 0) { + cleanupTempUploads(normalizedFiles); + const allowedList = [...allowedExtensions].join(', '); + const preview = invalidFiles.slice(0, 6).join(', '); + const suffix = invalidFiles.length > 6 ? ` (+${invalidFiles.length - 6} weitere)` : ''; + const error = new Error( + `Upload abgelehnt: Nicht erlaubte Dateiendung in ${invalidFiles.length} Datei(en): ${preview}${suffix}. Erlaubt: ${allowedList}` + ); + error.statusCode = 400; + throw error; + } + + const folderName = options?.folderName ? String(options.folderName).trim() : null; + + if (folderName) { + // ── Ordner-Upload (wie Klangkiste target_path): alle Dateien in EINEN Ordner ── + const safeFolderName = sanitizeFileName(folderName) || `upload_${Date.now()}`; + const targetDir = ensureUniqueOutputPath(path.join(rawDir, safeFolderName)); + const uniqueFolderName = path.basename(targetDir); + ensureDir(targetDir); + + for (const file of normalizedFiles) { + const tempPath = String(file?.path || '').trim(); + if (!tempPath || !fs.existsSync(tempPath)) continue; + + const rawName = String(file?.originalname || '').trim(); + const safeFileName = sanitizeFileNameWithExtension(rawName) || rawName || path.basename(tempPath); + const targetFile = path.join(targetDir, safeFileName); + + // Traversal-Schutz + if (!targetFile.startsWith(targetDir + path.sep)) { + logger.warn('converter:upload:traversal-blocked', { rawName }); + continue; + } + + moveFileWithFallback(tempPath, targetFile); + } + + logger.info('converter:upload:folder-placed', { + folderName, targetDir, fileCount: normalizedFiles.length + }); + + return { folders: [{ folderRelPath: uniqueFolderName, fileCount: normalizedFiles.length }] }; + } + + // ── Einzeldatei-Upload ───────────────────────────────────────────────── + const createdFolders = []; + for (const file of normalizedFiles) { + const tempPath = String(file?.path || '').trim(); + const originalName = String(file?.originalname || file?.originalName || '').trim() + || path.basename(tempPath || 'upload'); + + if (!tempPath || !fs.existsSync(tempPath)) { + logger.warn('converter:upload:temp-missing', { originalName, tempPath }); + continue; + } + + const baseNameClean = sanitizeFileName( + path.basename(originalName, path.extname(originalName)) + ) || `upload_${Date.now()}`; + const targetDir = ensureUniqueOutputPath(path.join(rawDir, baseNameClean)); + const subfolder = path.basename(targetDir); + ensureDir(targetDir); + + const safeFileName = sanitizeFileNameWithExtension(originalName) || originalName; + const targetFile = path.join(targetDir, safeFileName); + moveFileWithFallback(tempPath, targetFile); + + logger.info('converter:upload:file-placed', { originalName, targetFile }); + + createdFolders.push({ folderRelPath: subfolder, fileCount: 1 }); + } + + return { folders: createdFolders }; + } + + /** + * Erstellt Converter-Jobs aus ausgewählten Dateipfaden (relativ zu converter_raw_dir). + * Video-Dateien bekommen je einen eigenen Job, Audio-Dateien je nach audioMode. + * Ordner werden vom Frontend bereits zu Einzel-Dateien expandiert. + * + * @param {string[]} relPaths - Dateipfade relativ zum rawDir + * @param {'individual'|'shared'} audioMode - Modus für Audio-Dateien + * @returns {Promise} Erstellte Jobs + */ + async createConverterJobsFromSelection(relPaths, audioMode = 'individual') { + const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']); + const converterScanService = require('./converterScanService'); + const rawDir = await converterScanService.getRawDir(); + + if (!rawDir) { + const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); + error.statusCode = 400; + throw error; + } + + const audioRelPaths = []; + const nonAudioRelPaths = []; // Videos, ISOs, Ordner, sonstige + + for (const relPath of relPaths) { + const ext = path.extname(String(relPath || '')).toLowerCase(); + if (AUDIO_EXTS.has(ext)) { + audioRelPaths.push(relPath); + } else { + nonAudioRelPaths.push(relPath); + } + } + + const createdJobs = []; + + // Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag + for (const relPath of nonAudioRelPaths) { + const job = await this.createFileJob({ + kind: 'converter_entry', + relPath, + options: {} + }); + createdJobs.push(job); + } + + // Audio-Dateien + if (audioMode === 'shared' && audioRelPaths.length > 0) { + // Ein gemeinsamer Job für alle Audio-Dateien + const fullPaths = audioRelPaths.map((p) => path.join(rawDir, p)); + const rawPath = path.dirname(fullPaths[0]); + const detectedTitle = path.basename(rawPath) || 'Converter Audio Job'; + + const job = await historyService.createJob({ + discDevice: null, + status: 'READY_TO_START', + detectedTitle, + mediaType: 'converter', + jobKind: 'converter_audio' + }); + + await historyService.updateJob(job.id, { + media_type: 'converter', + job_kind: 'converter_audio', + raw_path: rawPath, + encode_plan_json: JSON.stringify({ + mediaProfile: 'converter', + jobKind: 'converter_audio', + converterMediaType: 'audio', + inputPath: rawPath, + inputPaths: fullPaths, + isFolder: false, + isSharedAudio: true + }), + last_state: 'READY_TO_START' + }); + + await converterScanService.assignEntriesToJob(audioRelPaths, job.id); + + await historyService.appendLog( + job.id, 'SYSTEM', + `Converter-Job (gemeinsam) erstellt: ${audioRelPaths.length} Audio-Datei(en)` + ); + + logger.info('converter:job:created-shared-audio', { + jobId: job.id, fileCount: audioRelPaths.length + }); + + createdJobs.push(await historyService.getJobById(job.id)); + } else { + // Einzelne Jobs für jede Audio-Datei + for (const relPath of audioRelPaths) { + const job = await this.createFileJob({ + kind: 'converter_entry', + relPath, + options: { converterMediaType: 'audio' } + }); + createdJobs.push(job); + } + } + + return createdJobs; + } + + /** + * Fügt Dateien einem existierenden (noch nicht gestarteten) Converter-Job hinzu. + * Aktuell werden Mehrfach-Dateien nur für Audio-Jobs unterstützt. + * + * @param {number} jobId + * @param {string[]} relPaths + * @returns {Promise<{job: object, addedRelPaths: string[]}>} + */ + async assignConverterFilesToJob(jobId, relPaths = []) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const converterScanService = require('./converterScanService'); + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) { + const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); + error.statusCode = 400; + throw error; + } + + const requestedRelPaths = Array.isArray(relPaths) + ? relPaths + .map((relPath) => converterScanService.normalizeRelPath(relPath)) + .filter((relPath) => relPath !== null && relPath !== '') + : []; + if (requestedRelPaths.length === 0) { + const error = new Error('Keine Dateien für Zuweisung übergeben.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(normalizedJobId); + if (!job) { + const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { + const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); + error.statusCode = 409; + throw error; + } + if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') { + const error = new Error('Dateien können nur nicht gestarteten Converter-Jobs zugewiesen werden.'); + error.statusCode = 409; + throw error; + } + + const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; + const converterMediaType = String(existingPlan.converterMediaType || '').trim().toLowerCase(); + if (converterMediaType !== 'audio') { + const error = new Error('Weitere Dateien können derzeit nur Audio-Converter-Jobs zugewiesen werden.'); + error.statusCode = 409; + throw error; + } + if (Boolean(existingPlan.isFolder)) { + const error = new Error('Ordnerbasierte Audio-Jobs können nicht erweitert werden. Bitte neuen gemeinsamen Audio-Job nutzen.'); + error.statusCode = 409; + throw error; + } + + const existingInputPaths = []; + if (Array.isArray(existingPlan.inputPaths) && existingPlan.inputPaths.length > 0) { + for (const item of existingPlan.inputPaths) { + const normalized = String(item || '').trim(); + if (normalized) existingInputPaths.push(normalized); + } + } else { + const singleInput = String(existingPlan.inputPath || job.raw_path || '').trim(); + if (singleInput && fs.existsSync(singleInput)) { + try { + const stat = fs.statSync(singleInput); + if (stat.isFile()) { + existingInputPaths.push(singleInput); + } + } catch (_err) { + // Ignored: validation below catches empty/invalid setups. + } + } + } + + if (existingInputPaths.length === 0) { + const error = new Error(`Job ${normalizedJobId} enthält keine gültige Eingabedatei.`); + error.statusCode = 409; + throw error; + } + + const nextInputPaths = [...existingInputPaths]; + const knownPaths = new Set( + existingInputPaths + .map((filePath) => normalizeComparablePath(filePath)) + .filter(Boolean) + ); + const addedRelPaths = []; + + for (const relPath of requestedRelPaths) { + const absolutePath = path.join(rawDir, relPath); + if (!fs.existsSync(absolutePath)) { + const error = new Error(`Datei nicht gefunden: ${relPath}`); + error.statusCode = 404; + throw error; + } + let stat; + try { + stat = fs.statSync(absolutePath); + } catch (_err) { + const error = new Error(`Datei nicht lesbar: ${relPath}`); + error.statusCode = 409; + throw error; + } + if (!stat.isFile()) { + const error = new Error(`Nur Dateien können zugewiesen werden: ${relPath}`); + error.statusCode = 409; + throw error; + } + const mediaType = converterScanService.detectMediaType(path.basename(relPath)); + if (mediaType !== 'audio') { + const error = new Error(`Nur Audio-Dateien können diesem Job zugewiesen werden: ${relPath}`); + error.statusCode = 409; + throw error; + } + + const entry = await converterScanService.getEntryByRelPath(relPath); + const assignedJobId = Number(entry?.job_id); + if (Number.isFinite(assignedJobId) && assignedJobId > 0 && assignedJobId !== normalizedJobId) { + const error = new Error(`Datei ist bereits Job #${assignedJobId} zugewiesen: ${relPath}`); + error.statusCode = 409; + throw error; + } + + const comparablePath = normalizeComparablePath(absolutePath); + if (comparablePath && knownPaths.has(comparablePath)) { + continue; + } + nextInputPaths.push(absolutePath); + if (comparablePath) knownPaths.add(comparablePath); + addedRelPaths.push(relPath); + } + + if (addedRelPaths.length === 0) { + return { + job: await historyService.getJobById(normalizedJobId), + addedRelPaths: [] + }; + } + + const isSharedAudio = nextInputPaths.length > 1; + const primaryInputPath = isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0]; + const nextPlan = { + ...existingPlan, + mediaProfile: 'converter', + jobKind: 'converter_audio', + converterMediaType: 'audio', + isFolder: false, + isSharedAudio, + inputPath: primaryInputPath, + inputPaths: isSharedAudio ? nextInputPaths : null, + audioFiles: null, + reviewConfirmed: false + }; + + await historyService.updateJob(normalizedJobId, { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + media_type: 'converter', + job_kind: 'converter_audio', + raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], + encode_plan_json: JSON.stringify(nextPlan), + encode_review_confirmed: 0, + error_message: null, + end_time: null + }); + + await converterScanService.assignEntriesToJob(addedRelPaths, normalizedJobId); + + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Dateien dem Converter-Job zugewiesen: ${addedRelPaths.join(', ')}` + ); + + return { + job: await historyService.getJobById(normalizedJobId), + addedRelPaths + }; + } + + /** + * Entfernt eine Datei aus einem Converter-Job. + * + * @param {number} jobId + * @param {string} relPath + * @returns {Promise<{job: object, removedRelPath: string}>} + */ + async removeConverterFileFromJob(jobId, relPath) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const converterScanService = require('./converterScanService'); + const normalizedRelPath = converterScanService.normalizeRelPath(relPath); + if (normalizedRelPath === null || normalizedRelPath === '') { + const error = new Error('Ungültiger relPath.'); + error.statusCode = 400; + throw error; + } + + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) { + const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(normalizedJobId); + if (!job) { + const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { + const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); + error.statusCode = 409; + throw error; + } + if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') { + const error = new Error('Dateien können nur aus nicht gestarteten Converter-Jobs entfernt werden.'); + error.statusCode = 409; + throw error; + } + + const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; + const converterMediaType = String(existingPlan.converterMediaType || '').trim().toLowerCase(); + if (converterMediaType !== 'audio' || Boolean(existingPlan.isFolder)) { + const error = new Error('Dateien können derzeit nur aus gemeinsamem Audio-Converter-Job entfernt werden.'); + error.statusCode = 409; + throw error; + } + + const allInputPaths = []; + if (Array.isArray(existingPlan.inputPaths) && existingPlan.inputPaths.length > 0) { + for (const item of existingPlan.inputPaths) { + const normalized = String(item || '').trim(); + if (normalized) allInputPaths.push(normalized); + } + } else { + const singleInput = String(existingPlan.inputPath || job.raw_path || '').trim(); + if (singleInput && fs.existsSync(singleInput)) { + try { + const stat = fs.statSync(singleInput); + if (stat.isFile()) { + allInputPaths.push(singleInput); + } + } catch (_err) { + // Keep empty fallback and validate below. + } + } + } + + if (allInputPaths.length === 0) { + const error = new Error(`Job ${normalizedJobId} enthält keine gültigen Eingabedateien.`); + error.statusCode = 409; + throw error; + } + + const targetPath = path.join(rawDir, normalizedRelPath); + const targetComparable = normalizeComparablePath(targetPath); + const hasTarget = allInputPaths.some( + (filePath) => normalizeComparablePath(filePath) === targetComparable + ); + if (!hasTarget) { + const error = new Error(`Datei ist diesem Job nicht zugewiesen: ${normalizedRelPath}`); + error.statusCode = 409; + throw error; + } + + if (allInputPaths.length <= 1) { + const error = new Error('Die letzte Datei kann nicht entfernt werden. Bitte Job löschen.'); + error.statusCode = 409; + throw error; + } + + const nextInputPaths = allInputPaths.filter( + (filePath) => normalizeComparablePath(filePath) !== targetComparable + ); + const isSharedAudio = nextInputPaths.length > 1; + const primaryInputPath = isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0]; + const nextPlan = { + ...existingPlan, + mediaProfile: 'converter', + jobKind: 'converter_audio', + converterMediaType: 'audio', + isFolder: false, + isSharedAudio, + inputPath: primaryInputPath, + inputPaths: isSharedAudio ? nextInputPaths : null, + audioFiles: null, + reviewConfirmed: false + }; + + await historyService.updateJob(normalizedJobId, { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + media_type: 'converter', + job_kind: 'converter_audio', + raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], + encode_plan_json: JSON.stringify(nextPlan), + encode_review_confirmed: 0, + error_message: null, + end_time: null + }); + + await converterScanService.clearEntryJobAssignment(normalizedRelPath, normalizedJobId); + + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Datei aus Converter-Job entfernt: ${normalizedRelPath}` + ); + + return { + job: await historyService.getJobById(normalizedJobId), + removedRelPath: normalizedRelPath + }; + } + + /** + * Entfernt eine Datei aus einem Converter-Job via absolutem Input-Pfad. + * Nützlich für Track-Tabellen, die mit inputPaths (absolut) arbeiten. + * + * @param {number} jobId + * @param {string} inputPath + * @returns {Promise<{job: object, removedRelPath: string|null}>} + */ + async removeConverterInputFromJob(jobId, inputPath) { + const normalizedJobId = Number(jobId); + const normalizedInputPath = String(inputPath || '').trim(); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + if (!normalizedInputPath) { + const error = new Error('inputPath fehlt.'); + error.statusCode = 400; + throw error; + } + + const converterScanService = require('./converterScanService'); + const rawDir = await converterScanService.getRawDir(); + if (rawDir) { + const absoluteRawDir = normalizeComparablePath(rawDir); + const absoluteInputPath = normalizeComparablePath(normalizedInputPath); + if (absoluteRawDir && absoluteInputPath && absoluteInputPath.startsWith(`${absoluteRawDir}${path.sep}`)) { + const relCandidate = path.relative(rawDir, normalizedInputPath); + const normalizedRelPath = converterScanService.normalizeRelPath(relCandidate); + if (normalizedRelPath && normalizedRelPath !== '.') { + return this.removeConverterFileFromJob(normalizedJobId, normalizedRelPath); + } + } + } + + // Fallback: direkte Plan-Manipulation (wenn kein valider relPath ableitbar ist) + const job = await historyService.getJobById(normalizedJobId); + if (!job) { + const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; + const allInputPaths = Array.isArray(existingPlan.inputPaths) + ? existingPlan.inputPaths.map((value) => String(value || '').trim()).filter(Boolean) + : [String(existingPlan.inputPath || job.raw_path || '').trim()].filter(Boolean); + if (allInputPaths.length <= 1) { + const error = new Error('Die letzte Datei kann nicht entfernt werden. Bitte Job löschen.'); + error.statusCode = 409; + throw error; + } + const targetComparable = normalizeComparablePath(normalizedInputPath); + const nextInputPaths = allInputPaths.filter((value) => normalizeComparablePath(value) !== targetComparable); + if (nextInputPaths.length === allInputPaths.length) { + const error = new Error('Datei ist diesem Job nicht zugewiesen.'); + error.statusCode = 409; + throw error; + } + const isSharedAudio = nextInputPaths.length > 1; + const nextPlan = { + ...existingPlan, + mediaProfile: 'converter', + jobKind: 'converter_audio', + converterMediaType: 'audio', + isFolder: false, + isSharedAudio, + inputPath: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], + inputPaths: isSharedAudio ? nextInputPaths : null, + audioFiles: null, + reviewConfirmed: false + }; + + await historyService.updateJob(normalizedJobId, { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + media_type: 'converter', + job_kind: 'converter_audio', + raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], + encode_plan_json: JSON.stringify(nextPlan), + encode_review_confirmed: 0, + error_message: null, + end_time: null + }); + + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Datei aus Converter-Job entfernt (inputPath): ${normalizedInputPath}` + ); + + return { + job: await historyService.getJobById(normalizedJobId), + removedRelPath: null + }; + } + + /** + * Speichert Converter-Draft-Konfiguration persistent für READY_TO_START Jobs. + * Damit bleiben UI-Eingaben (Metadaten, Tracktitel, Presets etc.) bei Reload erhalten. + * + * @param {number} jobId + * @param {object} config + * @returns {Promise<{job: object}>} + */ + async updateConverterJobConfig(jobId, config = {}) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(normalizedJobId); + if (!job) { + const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { + const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); + error.statusCode = 409; + throw error; + } + const statusUpper = String(job.status || '').trim().toUpperCase(); + const allowedDraftStates = new Set(['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION']); + if (!allowedDraftStates.has(statusUpper)) { + const error = new Error('Draft kann nur für nicht gestartete oder geprüfte Converter-Jobs gespeichert werden.'); + error.statusCode = 409; + throw error; + } + + const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; + const normalizeText = (value, maxLen = 300) => { + const normalized = String(value || '').trim(); + if (!normalized) return null; + return normalized.slice(0, maxLen); + }; + const normalizeNullableNumber = (value) => { + const n = Number(value); + return Number.isFinite(n) ? Math.trunc(n) : null; + }; + + const requestedMediaType = String( + config.converterMediaType || existingPlan.converterMediaType || 'video' + ).trim().toLowerCase(); + const converterMediaType = ['video', 'audio', 'iso'].includes(requestedMediaType) + ? requestedMediaType + : 'video'; + + const defaultFormat = converterMediaType === 'audio' ? 'flac' : 'mkv'; + const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); + const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']); + let outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20) + ?.toLowerCase() || defaultFormat; + if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) { + outputFormat = 'flac'; + } + if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) { + outputFormat = 'mkv'; + } + + let userPreset = existingPlan.userPreset || null; + if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) { + if (config.userPreset && typeof config.userPreset === 'object') { + const presetId = Number(config.userPreset.id); + userPreset = { + ...(Number.isFinite(presetId) && presetId > 0 ? { id: Math.trunc(presetId) } : {}), + ...(normalizeText(config.userPreset.handbrakePreset, 200) + ? { handbrakePreset: normalizeText(config.userPreset.handbrakePreset, 200) } + : {}), + ...(normalizeText(config.userPreset.extraArgs, 1000) + ? { extraArgs: normalizeText(config.userPreset.extraArgs, 1000) } + : {}) + }; + if (Object.keys(userPreset).length === 0) { + userPreset = null; + } + } else { + userPreset = null; + } + } + + const nextAudioFormatOptions = ( + Object.prototype.hasOwnProperty.call(config, 'audioFormatOptions') + && config.audioFormatOptions + && typeof config.audioFormatOptions === 'object' + ) + ? { ...config.audioFormatOptions } + : (existingPlan.audioFormatOptions || {}); + + const nextMetadata = ( + Object.prototype.hasOwnProperty.call(config, 'metadata') + && config.metadata + && typeof config.metadata === 'object' + ) + ? { + albumTitle: normalizeText(config.metadata.albumTitle, 300), + albumArtist: normalizeText(config.metadata.albumArtist, 300), + albumYear: normalizeNullableNumber(config.metadata.albumYear), + mbId: normalizeText(config.metadata.mbId, 120), + coverUrl: normalizeText(config.metadata.coverUrl, 500) || null + } + : (existingPlan.metadata || null); + + const normalizeTrack = (track) => { + const position = Number(track?.position); + if (!Number.isFinite(position) || position <= 0) return null; + return { + position: Math.trunc(position), + title: normalizeText(track?.title, 300) || null, + artist: normalizeText(track?.artist, 300) || null + }; + }; + const nextTracks = Array.isArray(config.tracks) + ? config.tracks.map(normalizeTrack).filter(Boolean) + : (Array.isArray(existingPlan.tracks) ? existingPlan.tracks : null); + + const sanitizeMbEntry = (entry) => { + if (!entry || typeof entry !== 'object') return null; + return { + mbId: normalizeText(entry.mbId, 120), + title: normalizeText(entry.title, 300), + artist: normalizeText(entry.artist, 300), + year: normalizeNullableNumber(entry.year), + country: normalizeText(entry.country, 16) + }; + }; + const nextMusicBrainz = ( + Object.prototype.hasOwnProperty.call(config, 'musicBrainz') + && config.musicBrainz + && typeof config.musicBrainz === 'object' + ) + ? { + query: normalizeText(config.musicBrainz.query, 400), + selected: sanitizeMbEntry(config.musicBrainz.selected), + results: Array.isArray(config.musicBrainz.results) + ? config.musicBrainz.results.map(sanitizeMbEntry).filter(Boolean).slice(0, 50) + : [] + } + : (existingPlan.musicBrainz || null); + + const normalizeEncodeItem = (item) => { + if (!item || typeof item !== 'object') return null; + const type = String(item.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') return null; + const id = Number(item.id); + if (!Number.isFinite(id) || id <= 0) return null; + return { type, id: Math.trunc(id) }; + }; + const nextPreEncodeItems = Array.isArray(config.preEncodeItems) + ? config.preEncodeItems.map(normalizeEncodeItem).filter(Boolean) + : (Array.isArray(existingPlan.preEncodeItems) ? existingPlan.preEncodeItems : []); + const nextPostEncodeItems = Array.isArray(config.postEncodeItems) + ? config.postEncodeItems.map(normalizeEncodeItem).filter(Boolean) + : (Array.isArray(existingPlan.postEncodeItems) ? existingPlan.postEncodeItems : []); + + const nextPlan = { + ...existingPlan, + mediaProfile: 'converter', + jobKind: resolveConverterJobKind(converterMediaType), + converterMediaType, + outputFormat, + userPreset, + audioFormatOptions: nextAudioFormatOptions, + metadata: nextMetadata, + tracks: nextTracks, + musicBrainz: nextMusicBrainz, + preEncodeItems: nextPreEncodeItems, + postEncodeItems: nextPostEncodeItems, + reviewConfirmed: false + }; + + const newCoverUrl = nextMetadata?.coverUrl || null; + const jobPatch = { + media_type: 'converter', + job_kind: resolveConverterJobKind(converterMediaType), + encode_plan_json: JSON.stringify(nextPlan), + encode_review_confirmed: 0, + error_message: null + }; + if (newCoverUrl && !thumbnailService.isLocalUrl(newCoverUrl)) { + jobPatch.poster_url = newCoverUrl; + } + + await historyService.updateJob(normalizedJobId, jobPatch); + + if (newCoverUrl && !thumbnailService.isLocalUrl(newCoverUrl)) { + historyService.queuePosterCache(normalizedJobId, newCoverUrl, { + source: 'Coverart', + logFailures: true + }); + } + + return { + job: await historyService.getJobById(normalizedJobId) + }; + } + + /** + * Startet einen Converter-Job mit der finalen Konfiguration (Preset, Format etc.). + * + * @param {number} jobId + * @param {object} config + * @param {string} config.converterMediaType - 'video'|'audio'|'iso' + * @param {string} config.outputFormat - Ausgabeformat (mkv, mp4, flac, mp3, ...) + * @param {object} [config.userPreset] - HandBrake User-Preset (Video) + * @param {object} [config.trackSelection] - Audio/Subtitle-Selektion (Video) + * @param {number} [config.handBrakeTitleId] - HandBrake Titel-ID (Video) + * @param {object} [config.audioFormatOptions] - Format-Optionen (Audio) + * @returns {Promise} + */ + async startConverterJob(jobId, config = {}) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + const error = new Error('Ungültige Job-ID für Converter-Start.'); + error.statusCode = 400; + throw error; + } + + const job = await historyService.getJobById(normalizedJobId); + if (!job) { + const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; + const converterMediaType = config.converterMediaType + || existingPlan.converterMediaType + || 'video'; + + const metadata = config.metadata || existingPlan.metadata || null; + const tracks = config.tracks || existingPlan.tracks || null; + + const converterScanService = require('./converterScanService'); + const settings = await settingsService.getSettingsMap(); + const rawDir = String( + settings?.converter_raw_dir || require('../config').defaultConverterRawDir || '' + ).trim(); + const movieDir = String( + settings?.converter_movie_dir || require('../config').defaultConverterMovieDir || '' + ).trim(); + const audioDir = String( + settings?.converter_audio_dir || require('../config').defaultConverterAudioDir || '' + ).trim(); + + const inputPath = existingPlan.inputPath || job.raw_path; + if (!inputPath || !fs.existsSync(inputPath)) { + const error = new Error(`Eingabedatei nicht gefunden: ${inputPath}`); + error.statusCode = 404; + throw error; + } + + // Ausgabepfad bestimmen + let outputPath = null; + let outputDir = null; + let audioTrackTemplate = null; + const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); + const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']); + const defaultOutputFormat = converterMediaType === 'audio' ? 'flac' : 'mkv'; + let outputFormat = String(config.outputFormat || existingPlan.outputFormat || defaultOutputFormat).trim().toLowerCase(); + if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) { + outputFormat = 'flac'; + } + if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) { + outputFormat = 'mkv'; + } + const baseName = sanitizeFileName( + path.basename(inputPath, path.extname(inputPath)) + ) || 'output'; + const title = job.title || job.detected_title || baseName; + const safeTitle = sanitizeFileName(title) || baseName; + + // Metadaten-basierte Namen (Album-Titel und Interpret) + const albumTitle = metadata?.albumTitle ? String(metadata.albumTitle).trim() : null; + const albumArtist = metadata?.albumArtist ? String(metadata.albumArtist).trim() : null; + const albumYear = Number.isFinite(Number(metadata?.albumYear)) + ? Math.trunc(Number(metadata.albumYear)) + : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null); + const safeAlbumTitle = albumTitle ? (sanitizeFileName(albumTitle) || safeTitle) : safeTitle; + const safeAlbumArtist = albumArtist ? (sanitizeFileName(albumArtist) || null) : null; + + const parseTemplateSegments = (template) => String(template || '') + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((segment) => String(segment || '').trim()) + .filter(Boolean); + + const normalizeAudioTemplate = (template) => { + let value = String(template || '').trim(); + if (!value) { + return '{artist} - {title}'; + } + + // Legacy-Recovery: alte fehlerhafte Werte konnten Placeholder-Klammern verlieren. + // Auch gemischte Templates (teilweise mit {}, teilweise ohne) werden repariert. + value = value.replace( + /(^|[^A-Za-z0-9_$\{])(trackNr|trackNumber|artist|album|title|year)(?=$|[^A-Za-z0-9_\}])/g, + (_full, prefix, key) => `${prefix}{${key}}` + ); + + // Falls ein kombiniertes Album+Track-Template ohne "/" gespeichert wurde, + // trennen wir vor der Tracknummer automatisch. + if ( + !value.includes('/') + && /\{(?:trackNr|trackNumber)\}/i.test(value) + && /\{(?:album|year)\}/i.test(value) + ) { + value = value.replace(/\)\s*(\{(?:trackNr|trackNumber)\})/i, ')/$1'); + } + + return value; + }; + + const renderTemplateSegment = (segment, values, fallback = 'unknown') => { + const rendered = renderTemplate(segment, values); + return sanitizeFileName(rendered) || fallback; + }; + + const albumTemplateValues = { + artist: safeAlbumArtist || safeAlbumTitle, + album: safeAlbumTitle, + title: safeAlbumTitle, + year: albumYear ?? new Date().getFullYear(), + trackNr: '01', + trackNumber: '1' + }; + + if (converterMediaType === 'video' || converterMediaType === 'iso') { + const videoTemplateRaw = String( + settings?.converter_output_template_video || '{title}' + ).trim() || '{title}'; + const videoSegmentsRaw = parseTemplateSegments(videoTemplateRaw); + const videoSegments = (videoSegmentsRaw.length > 0 ? videoSegmentsRaw : ['{title}']) + .map((segment) => renderTemplateSegment(segment, { + title: safeTitle, + year: Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : new Date().getFullYear() + }, safeTitle)); + const videoBaseName = videoSegments[videoSegments.length - 1] || safeTitle; + const videoFolders = videoSegments.slice(0, -1); + outputPath = path.join(movieDir, ...(videoFolders.length > 0 ? videoFolders : []), `${videoBaseName}.${outputFormat}`); + } else if (converterMediaType === 'audio') { + const isFolder = Boolean(existingPlan.isFolder || config.isFolder); + const isSharedAudio = Boolean(existingPlan.isSharedAudio); + const audioTemplateRaw = normalizeAudioTemplate(String( + settings?.converter_output_template_audio || '{artist} - {title}' + ).trim() || '{artist} - {title}'); + const audioSegmentsRaw = parseTemplateSegments(audioTemplateRaw); + if (isFolder || isSharedAudio) { + const folderSegmentRaw = audioSegmentsRaw.length > 1 + ? audioSegmentsRaw.slice(0, -1) + : audioSegmentsRaw; + const folderSegments = (folderSegmentRaw.length > 0 ? folderSegmentRaw : ['{artist} - {album}']) + .map((segment) => renderTemplateSegment(segment, albumTemplateValues, safeAlbumTitle)); + outputDir = path.join(audioDir, ...folderSegments); + audioTrackTemplate = audioSegmentsRaw.length > 1 + ? audioSegmentsRaw[audioSegmentsRaw.length - 1] + : (existingPlan.audioTrackTemplate || '{trackNr} - {title}'); + } else { + const firstTrack = Array.isArray(tracks) && tracks.length > 0 ? tracks[0] : null; + const singleValues = { + ...albumTemplateValues, + title: sanitizeFileName(String(firstTrack?.title || safeAlbumTitle).trim()) || safeAlbumTitle, + artist: sanitizeFileName(String(firstTrack?.artist || safeAlbumArtist || safeAlbumTitle).trim()) || (safeAlbumArtist || safeAlbumTitle), + trackNr: '01', + trackNumber: '1' + }; + const renderedSegments = (audioSegmentsRaw.length > 0 ? audioSegmentsRaw : ['{artist} - {title}']) + .map((segment) => renderTemplateSegment(segment, singleValues, safeAlbumTitle)); + const singleBaseName = renderedSegments[renderedSegments.length - 1] || safeAlbumTitle; + const singleFolders = renderedSegments.slice(0, -1); + outputPath = path.join(audioDir, ...(singleFolders.length > 0 ? singleFolders : []), `${singleBaseName}.${outputFormat}`); + } + } + + // Audiodateien im Ordner laden + let audioFiles = existingPlan.audioFiles; + if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) { + const { readdirSync } = fs; + const AUDIO_EXTS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']); + try { + audioFiles = readdirSync(inputPath) + .filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase())) + .sort(); + } catch (_err) { + audioFiles = []; + } + } + + const normalizeStartEncodeItem = (item) => { + if (!item || typeof item !== 'object') return null; + const type = String(item.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') return null; + const id = Number(item.id); + if (!Number.isFinite(id) || id <= 0) return null; + return { type, id: Math.trunc(id) }; + }; + const preEncodeItems = Array.isArray(config.preEncodeItems) + ? config.preEncodeItems.map(normalizeStartEncodeItem).filter(Boolean) + : (Array.isArray(existingPlan.preEncodeItems) ? existingPlan.preEncodeItems : []); + const postEncodeItems = Array.isArray(config.postEncodeItems) + ? config.postEncodeItems.map(normalizeStartEncodeItem).filter(Boolean) + : (Array.isArray(existingPlan.postEncodeItems) ? existingPlan.postEncodeItems : []); + const preEncodeScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id); + const postEncodeScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id); + const preEncodeChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id); + const postEncodeChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id); + + const nextEncodePlan = { + ...existingPlan, + mediaProfile: 'converter', + jobKind: resolveConverterJobKind(converterMediaType), + converterMediaType, + inputPath, + inputPaths: existingPlan.inputPaths || null, + outputPath: outputPath || null, + outputDir: outputDir || null, + outputFormat, + userPreset: config.userPreset || existingPlan.userPreset || null, + trackSelection: config.trackSelection || existingPlan.trackSelection || null, + handBrakeTitleId: config.handBrakeTitleId || existingPlan.handBrakeTitleId || null, + audioFormatOptions: config.audioFormatOptions || existingPlan.audioFormatOptions || {}, + audioTrackTemplate: audioTrackTemplate || existingPlan.audioTrackTemplate || null, + audioFiles: audioFiles || existingPlan.audioFiles || null, + metadata: metadata || null, + tracks: tracks || null, + preEncodeItems, + postEncodeItems, + preEncodeScriptIds, + postEncodeScriptIds, + preEncodeChainIds, + postEncodeChainIds, + reviewConfirmed: converterMediaType === 'audio' + }; + + const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso'; + const metadataTitle = String(metadata?.title || '').trim() || null; + const metadataYear = Number.isFinite(Number(metadata?.year)) ? Math.trunc(Number(metadata.year)) : null; + const metadataImdbId = String(metadata?.imdbId || '').trim() || null; + const metadataPoster = String(metadata?.poster || '').trim() || null; + const selectedFromMetadata = 0; + + const jobPatch = { + status: 'READY_TO_START', + last_state: 'READY_TO_START', + media_type: 'converter', + job_kind: resolveConverterJobKind(converterMediaType), + output_path: outputPath || outputDir || null, + encode_plan_json: JSON.stringify(nextEncodePlan), + encode_review_confirmed: converterMediaType === 'audio' ? 1 : 0, + error_message: null, + end_time: null + }; + if (isVideoLikeConverterJob) { + if (metadataTitle) { + jobPatch.title = metadataTitle; + } + if (metadataYear !== null) { + jobPatch.year = metadataYear; + } + if (metadataImdbId) { + jobPatch.imdb_id = metadataImdbId; + } + if (metadataPoster) { + jobPatch.poster_url = metadataPoster; + } + jobPatch.selected_from_omdb = selectedFromMetadata; + } + await historyService.updateJob(normalizedJobId, jobPatch); + + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Converter konfiguriert: Typ ${converterMediaType} | Format ${outputFormat}` + ); + if (isVideoLikeConverterJob) { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + 'Starte Mediainfo-Review mit Titel-/Spurauswahl (analog Rip-Workflow).' + ); + } + + const startResult = await this.startPreparedJob(normalizedJobId); + return { + jobId: normalizedJobId, + ...(startResult && typeof startResult === 'object' ? startResult : {}) + }; + } + + /** + * Gibt alle Audiobook-Jobs zurück (für die Audiobooks-Seite). + */ + async getAudiobookJobs() { + const db = await require('../db/database').getDb(); + const rows = await db.all(` + SELECT id, title, detected_title, year, poster_url, + status, last_state, media_type, job_kind, + encode_plan_json, handbrake_info_json, makemkv_info_json, + output_path, raw_path, error_message, + start_time, end_time, created_at, updated_at + FROM jobs + WHERE media_type = 'audiobook' + OR job_kind = 'audiobook' + OR ( + (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other') + AND ( + encode_plan_json LIKE '%"mediaProfile":"audiobook"%' + OR encode_plan_json LIKE '%"mode":"audiobook"%' + ) + ) + ORDER BY created_at DESC + LIMIT 200 + `); + return rows.map((row) => { + const encodePlan = this.safeParseJson(row.encode_plan_json); + const handbrakeInfo = this.safeParseJson(row.handbrake_info_json); + const makemkvInfo = this.safeParseJson(row.makemkv_info_json); + const liveProgressRaw = this.jobProgress.get(Number(row?.id)); + const liveProgress = liveProgressRaw && typeof liveProgressRaw === 'object' + ? this.safeParseJson(JSON.stringify(liveProgressRaw)) + : null; + const rawMediaType = String(row?.media_type || '').trim().toLowerCase(); + const planMode = String(encodePlan?.mode || '').trim().toLowerCase(); + const planProfile = String(encodePlan?.mediaProfile || '').trim().toLowerCase(); + const effectiveMediaType = rawMediaType === 'audiobook' + ? 'audiobook' + : ( + planProfile === 'audiobook' || planMode === 'audiobook' + ? 'audiobook' + : (rawMediaType || null) + ); + + return { + ...row, + media_type: effectiveMediaType, + encodePlan, + handbrakeInfo, + makemkvInfo, + liveProgress + }; + }); + } + + /** + * Gibt alle Converter-Jobs zurück (für die Converter-Seite). + */ + async getConverterJobs() { + const db = await require('../db/database').getDb(); + const rows = await db.all(` + SELECT id, title, detected_title, year, imdb_id, poster_url, + status, last_state, media_type, job_kind, encode_review_confirmed, + encode_plan_json, output_path, raw_path, encode_input_path, error_message, + start_time, end_time, created_at, updated_at + FROM jobs + WHERE media_type = 'converter' + OR job_kind LIKE 'converter_%' + OR ( + (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other') + AND ( + encode_plan_json LIKE '%"mediaProfile":"converter"%' + OR encode_plan_json LIKE '%"converterMediaType"%' + OR raw_path LIKE '%/converter/%' + OR encode_input_path LIKE '%/converter/%' + ) + ) + ORDER BY created_at DESC + LIMIT 200 + `); + return rows.map((r) => { + const encodePlan = this.safeParseJson(r.encode_plan_json); + const rawMediaType = String(r?.media_type || '').trim().toLowerCase(); + const converterHintFromPlan = ( + String(encodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter' + || ['video', 'audio', 'iso'].includes(String(encodePlan?.converterMediaType || '').trim().toLowerCase()) + ); + const converterHintFromPath = ( + hasConverterPathSegment(r?.raw_path) + || hasConverterPathSegment(r?.encode_input_path) + || hasConverterPathSegment(encodePlan?.inputPath) + ); + const effectiveMediaType = rawMediaType === 'converter' + ? 'converter' + : ( + (converterHintFromPlan || converterHintFromPath) + ? 'converter' + : (rawMediaType || null) + ); + return { + ...r, + media_type: effectiveMediaType, + encodePlan + }; + }); + } + +} + +module.exports = new PipelineService(); diff --git a/backend/src/services/processRunner.js b/backend/src/services/processRunner.js new file mode 100644 index 0000000..28403d9 --- /dev/null +++ b/backend/src/services/processRunner.js @@ -0,0 +1,114 @@ +const { spawn } = require('child_process'); +const logger = require('./logger').child('PROCESS'); +const { errorToMeta } = require('../utils/errorMeta'); + +function streamLines(stream, onLine) { + let buffer = ''; + stream.on('data', (chunk) => { + buffer += chunk.toString(); + const parts = buffer.split(/\r\n|\n|\r/); + buffer = parts.pop() ?? ''; + + for (const line of parts) { + if (line.length > 0) { + onLine(line); + } + } + }); + + stream.on('end', () => { + if (buffer.length > 0) { + onLine(buffer); + } + }); +} + +function spawnTrackedProcess({ + cmd, + args, + cwd, + onStdoutLine, + onStderrLine, + onStart, + context = {} +}) { + logger.info('spawn:start', { cmd, args, cwd, context }); + + const child = spawn(cmd, args, { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + }); + + if (onStart) { + onStart(child); + } + + if (child.stdout && onStdoutLine) { + streamLines(child.stdout, onStdoutLine); + } + + if (child.stderr && onStderrLine) { + streamLines(child.stderr, onStderrLine); + } + + const promise = new Promise((resolve, reject) => { + child.on('error', (error) => { + logger.error('spawn:error', { cmd, args, context, error: errorToMeta(error) }); + reject(error); + }); + + child.on('close', (code, signal) => { + logger.info('spawn:close', { cmd, args, code, signal, context }); + if (code === 0) { + resolve({ code, signal }); + } else { + const error = new Error(`Prozess ${cmd} beendet mit Code ${code ?? 'null'} (Signal ${signal ?? 'none'}).`); + error.code = code; + error.signal = signal; + reject(error); + } + }); + }); + + let cancelCalled = false; + const killProcessTree = (signal) => { + const pid = Number(child.pid); + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(-pid, signal); + return true; + } catch (_error) { + // fallback below + } + } + try { + child.kill(signal); + return true; + } catch (_error) { + return false; + } + }; + const cancel = () => { + if (cancelCalled) { + return; + } + cancelCalled = true; + + logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid }); + // Instant cancel by user request. + killProcessTree('SIGKILL'); + }; + + return { + child, + promise, + cancel + }; +} + +module.exports = { + spawnTrackedProcess, + streamLines +}; diff --git a/backend/src/services/runtimeActivityService.js b/backend/src/services/runtimeActivityService.js new file mode 100644 index 0000000..8845917 --- /dev/null +++ b/backend/src/services/runtimeActivityService.js @@ -0,0 +1,347 @@ +const wsService = require('./websocketService'); + +const MAX_RECENT_ACTIVITIES = 120; +const MAX_ACTIVITY_OUTPUT_CHARS = 12000; +const MAX_ACTIVITY_TEXT_CHARS = 2000; +const OUTPUT_BROADCAST_THROTTLE_MS = 180; + +function nowIso() { + return new Date().toISOString(); +} + +function normalizeNumber(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS } = {}) { + if (value === null || value === undefined) { + return null; + } + let text = String(value); + if (trim) { + text = text.trim(); + } + if (!text) { + return null; + } + if (text.length > maxChars) { + if (trim) { + const suffix = ' ...[gekürzt]'; + text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`; + } else { + const prefix = '...[gekürzt]\n'; + text = `${prefix}${text.slice(-Math.max(0, maxChars - prefix.length))}`; + } + } + return text; +} + +function normalizeOutputChunk(value) { + if (value === null || value === undefined) { + return ''; + } + const normalized = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (!normalized) { + return ''; + } + return normalized.endsWith('\n') ? normalized : `${normalized}\n`; +} + +function appendOutputTail(currentValue, chunk, maxChars = MAX_ACTIVITY_OUTPUT_CHARS) { + const normalizedChunk = normalizeOutputChunk(chunk); + const currentText = currentValue == null ? '' : String(currentValue); + if (!normalizedChunk) { + return { + value: currentText || null, + truncated: false + }; + } + + const combined = `${currentText}${normalizedChunk}`; + if (combined.length <= maxChars) { + return { + value: combined, + truncated: false + }; + } + + return { + value: combined.slice(-maxChars), + truncated: true + }; +} + +function sanitizeActivity(input = {}) { + const source = input && typeof input === 'object' ? input : {}; + const normalizedOutcome = normalizeText(source.outcome, { trim: true, maxChars: 40 }); + return { + id: normalizeNumber(source.id), + type: String(source.type || '').trim().toLowerCase() || 'task', + name: String(source.name || '').trim() || null, + status: String(source.status || '').trim().toLowerCase() || 'running', + source: String(source.source || '').trim() || null, + message: String(source.message || '').trim() || null, + currentStep: String(source.currentStep || '').trim() || null, + currentStepType: String(source.currentStepType || '').trim() || null, + currentScriptName: String(source.currentScriptName || '').trim() || null, + stepIndex: normalizeNumber(source.stepIndex), + stepTotal: normalizeNumber(source.stepTotal), + parentActivityId: normalizeNumber(source.parentActivityId), + jobId: normalizeNumber(source.jobId), + cronJobId: normalizeNumber(source.cronJobId), + chainId: normalizeNumber(source.chainId), + scriptId: normalizeNumber(source.scriptId), + canCancel: Boolean(source.canCancel), + canNextStep: Boolean(source.canNextStep), + outcome: normalizedOutcome ? String(normalizedOutcome).toLowerCase() : null, + errorMessage: normalizeText(source.errorMessage, { trim: true, maxChars: MAX_ACTIVITY_TEXT_CHARS }), + output: normalizeText(source.output, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }), + stdout: normalizeText(source.stdout, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }), + stderr: normalizeText(source.stderr, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }), + outputTruncated: Boolean(source.outputTruncated), + stdoutTruncated: Boolean(source.stdoutTruncated), + stderrTruncated: Boolean(source.stderrTruncated), + startedAt: source.startedAt || nowIso(), + finishedAt: source.finishedAt || null, + durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null, + exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null, + success: source.success === null || source.success === undefined ? null : Boolean(source.success) + }; +} + +class RuntimeActivityService { + constructor() { + this.nextId = 1; + this.active = new Map(); + this.recent = []; + this.controls = new Map(); + this.outputBroadcastTimer = null; + } + + buildSnapshot() { + const active = Array.from(this.active.values()) + .sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || ''))); + const recent = [...this.recent] + .sort((a, b) => String(b.finishedAt || b.startedAt || '').localeCompare(String(a.finishedAt || a.startedAt || ''))); + return { + active, + recent, + updatedAt: nowIso() + }; + } + + broadcastSnapshot() { + if (this.outputBroadcastTimer) { + clearTimeout(this.outputBroadcastTimer); + this.outputBroadcastTimer = null; + } + wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot()); + } + + scheduleOutputBroadcast() { + if (this.outputBroadcastTimer) { + return; + } + this.outputBroadcastTimer = setTimeout(() => { + this.outputBroadcastTimer = null; + wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot()); + }, OUTPUT_BROADCAST_THROTTLE_MS); + } + + startActivity(type, payload = {}) { + const id = this.nextId; + this.nextId += 1; + const activity = sanitizeActivity({ + ...payload, + id, + type, + status: 'running', + outcome: 'running', + startedAt: payload?.startedAt || nowIso(), + finishedAt: null, + durationMs: null, + canCancel: Boolean(payload?.canCancel), + canNextStep: Boolean(payload?.canNextStep) + }); + this.active.set(id, activity); + this.broadcastSnapshot(); + return id; + } + + updateActivity(activityId, patch = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return null; + } + const current = this.active.get(id); + const next = sanitizeActivity({ + ...current, + ...patch, + id: current.id, + type: current.type, + status: current.status === 'running' ? (patch?.status || current.status) : current.status, + startedAt: current.startedAt + }); + this.active.set(id, next); + this.broadcastSnapshot(); + return next; + } + + appendActivityOutput(activityId, patch = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return null; + } + + const current = this.active.get(id); + const nextOutput = appendOutputTail(current.output, patch?.output, MAX_ACTIVITY_OUTPUT_CHARS); + const nextStdout = appendOutputTail(current.stdout, patch?.stdout, MAX_ACTIVITY_OUTPUT_CHARS); + const nextStderr = appendOutputTail(current.stderr, patch?.stderr, MAX_ACTIVITY_OUTPUT_CHARS); + const next = sanitizeActivity({ + ...current, + ...patch, + id: current.id, + type: current.type, + status: current.status, + startedAt: current.startedAt, + output: nextOutput.value, + stdout: nextStdout.value, + stderr: nextStderr.value, + outputTruncated: Boolean(current.outputTruncated || patch?.outputTruncated || nextOutput.truncated), + stdoutTruncated: Boolean(current.stdoutTruncated || patch?.stdoutTruncated || nextStdout.truncated), + stderrTruncated: Boolean(current.stderrTruncated || patch?.stderrTruncated || nextStderr.truncated) + }); + this.active.set(id, next); + this.scheduleOutputBroadcast(); + return next; + } + + completeActivity(activityId, payload = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return null; + } + const current = this.active.get(id); + const finishedAt = payload?.finishedAt || nowIso(); + const startedAtDate = new Date(current.startedAt); + const finishedAtDate = new Date(finishedAt); + const durationMs = Number.isFinite(startedAtDate.getTime()) && Number.isFinite(finishedAtDate.getTime()) + ? Math.max(0, finishedAtDate.getTime() - startedAtDate.getTime()) + : null; + const status = String(payload?.status || '').trim().toLowerCase() || (payload?.success === false ? 'error' : 'success'); + let outcome = String(payload?.outcome || '').trim().toLowerCase(); + if (!outcome) { + if (Boolean(payload?.cancelled)) { + outcome = 'cancelled'; + } else if (Boolean(payload?.skipped)) { + outcome = 'skipped'; + } else { + outcome = status === 'success' ? 'success' : 'error'; + } + } + const finalized = sanitizeActivity({ + ...current, + ...payload, + id: current.id, + type: current.type, + status, + outcome, + canCancel: false, + canNextStep: false, + finishedAt, + durationMs + }); + this.active.delete(id); + this.controls.delete(id); + this.recent.unshift(finalized); + if (this.recent.length > MAX_RECENT_ACTIVITIES) { + this.recent = this.recent.slice(0, MAX_RECENT_ACTIVITIES); + } + this.broadcastSnapshot(); + return finalized; + } + + getSnapshot() { + return this.buildSnapshot(); + } + + clearRecent() { + const removed = this.recent.length; + if (removed === 0) { + return { removed: 0, snapshot: this.buildSnapshot() }; + } + this.recent = []; + this.broadcastSnapshot(); + return { + removed, + snapshot: this.buildSnapshot() + }; + } + + setControls(activityId, handlers = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return null; + } + const safeHandlers = { + cancel: typeof handlers?.cancel === 'function' ? handlers.cancel : null, + nextStep: typeof handlers?.nextStep === 'function' ? handlers.nextStep : null + }; + this.controls.set(id, safeHandlers); + return this.updateActivity(id, { + canCancel: Boolean(safeHandlers.cancel), + canNextStep: Boolean(safeHandlers.nextStep) + }); + } + + async invokeControl(activityId, control, payload = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return { + ok: false, + code: 'NOT_FOUND', + message: 'Aktivität nicht gefunden oder bereits abgeschlossen.' + }; + } + const handlers = this.controls.get(id) || {}; + const key = control === 'nextStep' ? 'nextStep' : 'cancel'; + const fn = handlers[key]; + if (typeof fn !== 'function') { + return { + ok: false, + code: 'UNSUPPORTED', + message: key === 'nextStep' + ? 'Nächster-Schritt ist für diese Aktivität nicht verfügbar.' + : 'Abbrechen ist für diese Aktivität nicht verfügbar.' + }; + } + try { + const result = await fn(payload); + return { + ok: true, + code: 'OK', + result: result && typeof result === 'object' ? result : null + }; + } catch (error) { + return { + ok: false, + code: 'FAILED', + message: error?.message || 'Aktion fehlgeschlagen.' + }; + } + } + + async requestCancel(activityId, payload = {}) { + return this.invokeControl(activityId, 'cancel', payload); + } + + async requestNextStep(activityId, payload = {}) { + return this.invokeControl(activityId, 'nextStep', payload); + } +} + +module.exports = new RuntimeActivityService(); diff --git a/backend/src/services/scriptChainService.js b/backend/src/services/scriptChainService.js new file mode 100644 index 0000000..b7154b2 --- /dev/null +++ b/backend/src/services/scriptChainService.js @@ -0,0 +1,1000 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('SCRIPT_CHAINS'); +const runtimeActivityService = require('./runtimeActivityService'); +const { spawnTrackedProcess } = require('./processRunner'); +const { errorToMeta } = require('../utils/errorMeta'); + +const CHAIN_NAME_MAX_LENGTH = 120; +const CHAIN_DESCRIPTION_MAX_LENGTH = 50; +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 normalizeFavoriteFlag(rawValue, fallback = 0) { + if (typeof rawValue === 'boolean') { + return rawValue ? 1 : 0; + } + const parsed = Number(rawValue); + if (Number.isFinite(parsed)) { + return parsed !== 0 ? 1 : 0; + } + const normalized = String(rawValue || '').trim().toLowerCase(); + if (!normalized) { + return fallback ? 1 : 0; + } + if (['true', 'yes', 'on', '1'].includes(normalized)) { + return 1; + } + if (['false', 'no', 'off', '0'].includes(normalized)) { + return 0; + } + return fallback ? 1 : 0; +} + +function normalizeChainDescription(rawValue) { + return String(rawValue || '').trim(); +} + +function mapChainRow(row, steps = []) { + if (!row) { + return null; + } + return { + id: Number(row.id), + name: String(row.name || ''), + description: normalizeChainDescription(row.description), + isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1, + 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 false; + } + const signal = immediate ? 'SIGKILL' : 'SIGTERM'; + const pid = Number(child.pid); + let signaled = false; + if (Number.isFinite(pid) && pid > 0) { + try { + // For detached children this targets the full process group. + process.kill(-pid, signal); + signaled = true; + } catch (_error) { + // ignore and continue with direct child signal + } + } + try { + child.kill(signal); + signaled = true; + } catch (_error) { + // ignore + } + return signaled; +} + +function appendTailText(currentValue, nextChunk, maxChars = 12000) { + const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (!chunk) { + return { + value: currentValue || '', + truncated: false + }; + } + const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`; + const combined = `${String(currentValue || '')}${normalizedChunk}`; + if (combined.length <= maxChars) { + return { + value: combined, + truncated: false + }; + } + return { + value: combined.slice(-maxChars), + truncated: true + }; +} + +function validateSteps(rawSteps) { + const steps = Array.isArray(rawSteps) ? rawSteps : []; + const errors = []; + const normalized = []; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i] && typeof steps[i] === 'object' ? steps[i] : {}; + const stepType = String(step.stepType || step.step_type || '').trim(); + + if (!VALID_STEP_TYPES.has(stepType)) { + errors.push({ field: `steps[${i}].stepType`, message: `Ungültiger Schritt-Typ: '${stepType}'. Erlaubt: script, wait.` }); + continue; + } + + if (stepType === STEP_TYPE_SCRIPT) { + const scriptId = Number(step.scriptId ?? step.script_id); + if (!Number.isFinite(scriptId) || scriptId <= 0) { + errors.push({ field: `steps[${i}].scriptId`, message: 'scriptId fehlt oder ist ungültig.' }); + continue; + } + normalized.push({ stepType, scriptId: Math.trunc(scriptId), waitSeconds: null }); + } else if (stepType === STEP_TYPE_WAIT) { + const waitSeconds = Number(step.waitSeconds ?? step.wait_seconds); + if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) { + errors.push({ field: `steps[${i}].waitSeconds`, message: 'waitSeconds muss zwischen 1 und 3600 liegen.' }); + continue; + } + normalized.push({ stepType, scriptId: null, waitSeconds: Math.round(waitSeconds) }); + } + } + + if (errors.length > 0) { + throw createValidationError('Ungültige Schritte in der Skriptkette.', errors); + } + + return normalized; +} + +async function getStepsForChain(db, chainId) { + return db.all( + ` + SELECT + s.id, + s.chain_id, + s.position, + s.step_type, + s.script_id, + s.wait_seconds, + sc.name AS script_name + FROM script_chain_steps s + LEFT JOIN scripts sc ON sc.id = s.script_id + WHERE s.chain_id = ? + ORDER BY s.position ASC, s.id ASC + `, + [chainId] + ); +} + +class ScriptChainService { + async listChains() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT id, name, description, is_favorite, 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, description, is_favorite, 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, description, is_favorite, 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(); + const description = normalizeChainDescription(body.description); + 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.` }]); + } + if (description.length > CHAIN_DESCRIPTION_MAX_LENGTH) { + throw createValidationError('Beschreibung zu lang.', [{ field: 'description', message: `Maximal ${CHAIN_DESCRIPTION_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, description, is_favorite, order_index, created_at, updated_at) + VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + [name, description || null, 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(); + const description = normalizeChainDescription(body.description); + 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.` }]); + } + if (description.length > CHAIN_DESCRIPTION_MAX_LENGTH) { + throw createValidationError('Beschreibung zu lang.', [{ field: 'description', message: `Maximal ${CHAIN_DESCRIPTION_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 = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [name, description || null, 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 setChainFavorite(chainId, isFavorite) { + const normalizedId = normalizeChainId(chainId); + if (!normalizedId) { + throw createValidationError('Ungültige chainId.'); + } + const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0); + const db = await getDb(); + await this.getChainById(normalizedId); + await db.run( + ` + UPDATE script_chains + SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [favoriteFlag, normalizedId] + ); + return this.getChainById(normalizedId); + } + + 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, + activeProcessHandle: 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 (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { + controlState.activeWaitResolve('cancel'); + } else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { + controlState.activeChildTermination = 'cancel'; + let handleCancelled = false; + if (typeof controlState.activeProcessHandle?.cancel === 'function') { + controlState.activeProcessHandle.cancel(); + handleCancelled = true; + } + const signaled = terminateChildProcess(controlState.activeChild, { immediate: true }); + if (!handleCancelled && !signaled) { + logger.warn('chain:cancel:no-signal-sent', { + chainId, + scriptPid: Number(controlState.activeChild?.pid || 0) || null + }); + } + } + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + return { accepted: true, message: 'Abbruch angefordert.' }; + }; + const requestNextStep = async () => { + if (controlState.cancelRequested) { + return { accepted: false, message: 'Kette wird bereits abgebrochen.' }; + } + if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { + controlState.activeWaitResolve('skip'); + runtimeActivityService.updateActivity(activityId, { + message: 'Nächster Schritt angefordert (Wait übersprungen)' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Wait-Schritt manuell übersprungen.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + return { accepted: true, message: 'Wait-Schritt übersprungen.' }; + } + if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { + controlState.activeChildTermination = 'skip'; + terminateChildProcess(controlState.activeChild, { immediate: true }); + runtimeActivityService.updateActivity(activityId, { + message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript-Schritt manuell übersprungen.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + return { accepted: true, message: 'Skript-Schritt wird übersprungen.' }; + } + return { accepted: false, message: 'Kein aktiver Schritt zum Überspringen.' }; + }; + runtimeActivityService.setControls(activityId, { + cancel: requestCancel, + nextStep: requestNextStep + }); + + const results = []; + let completionPayload = null; + let abortedByUser = false; + try { + for (let index = 0; index < chain.steps.length; index += 1) { + if (controlState.cancelRequested) { + abortedByUser = true; + break; + } + const step = chain.steps[index]; + const stepIndex = index + 1; + if (step.stepType === STEP_TYPE_WAIT) { + const seconds = Math.max(1, Number(step.waitSeconds || 1)); + const waitLabel = `Warte ${seconds} Sekunde(n)`; + controlState.currentStepType = STEP_TYPE_WAIT; + runtimeActivityService.updateActivity(activityId, { + currentStepType: 'wait', + currentStep: waitLabel, + currentScriptName: null, + stepIndex, + stepTotal: totalSteps + }); + emitRuntimeStep({ + stepType: 'wait', + stepIndex, + stepTotal: totalSteps, + currentStep: waitLabel + }); + logger.info('chain:step:wait', { chainId, seconds }); + if (typeof appendLog === 'function') { + await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`); + } + const waitOutcome = await new Promise((resolve) => { + const timer = setTimeout(() => { + controlState.activeWaitResolve = null; + resolve('done'); + }, seconds * 1000); + controlState.activeWaitResolve = (mode = 'done') => { + clearTimeout(timer); + controlState.activeWaitResolve = null; + resolve(mode); + }; + }); + controlState.currentStepType = null; + if (waitOutcome === 'skip') { + results.push({ stepType: 'wait', waitSeconds: seconds, success: true, skipped: true, reason: 'skipped_by_user' }); + continue; + } + if (waitOutcome === 'cancel' || controlState.cancelRequested) { + abortedByUser = true; + results.push({ stepType: 'wait', waitSeconds: seconds, success: false, aborted: true, reason: 'cancelled_by_user' }); + break; + } + results.push({ stepType: 'wait', waitSeconds: seconds, success: true }); + } else if (step.stepType === STEP_TYPE_SCRIPT) { + if (!step.scriptId) { + logger.warn('chain:step:script-missing', { chainId, stepId: step.id }); + results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' }); + continue; + } + + const scriptService = require('./scriptService'); + let script; + try { + script = await scriptService.getScriptById(step.scriptId); + } catch (error) { + logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); + results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' }); + continue; + } + + controlState.currentStepType = STEP_TYPE_SCRIPT; + runtimeActivityService.updateActivity(activityId, { + currentStepType: 'script', + currentStep: `Skript: ${script.name}`, + currentScriptName: script.name, + stepIndex, + stepTotal: totalSteps, + scriptId: script.id + }); + emitRuntimeStep({ + stepType: 'script', + stepIndex, + stepTotal: totalSteps, + scriptId: script.id, + scriptName: script.name, + currentScriptName: script.name, + currentStep: `Skript: ${script.name}` + }); + + if (typeof appendLog === 'function') { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`); + } + + const scriptActivityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: context?.source || 'chain', + scriptId: script.id, + chainId: chain.id, + jobId: context?.jobId || null, + cronJobId: context?.cronJobId || null, + parentActivityId: activityId, + currentStep: `Kette: ${chain.name}` + }); + + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { + ...context, + scriptId: script.id, + scriptName: script.name, + source: context?.source || 'chain' + }); + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + const processHandle = spawnTrackedProcess({ + cmd: prepared.cmd, + args: prepared.args, + context: { source: context?.source || 'chain', chainId: chain.id, scriptId: script.id }, + onStart: (child) => { + controlState.activeChild = child; + controlState.activeChildTermination = null; + }, + onStdoutLine: (line) => { + const next = appendTailText(stdout, line); + stdout = next.value; + stdoutTruncated = stdoutTruncated || next.truncated; + runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line }); + }, + onStderrLine: (line) => { + const next = appendTailText(stderr, line); + stderr = next.value; + stderrTruncated = stderrTruncated || next.truncated; + runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line }); + } + }); + controlState.activeProcessHandle = processHandle; + let runError = null; + let exitCode = 0; + let signal = null; + try { + const result = await processHandle.promise; + exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0; + signal = result?.signal || null; + } catch (error) { + runError = error; + exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null; + signal = error?.signal || null; + } + const termination = controlState.activeChildTermination; + controlState.activeChild = null; + controlState.activeProcessHandle = null; + controlState.activeChildTermination = null; + if (runError && exitCode === null && !termination) { + throw runError; + } + const run = { + code: exitCode, + signal, + stdout, + stderr, + stdoutTruncated, + stderrTruncated, + termination + }; + controlState.currentStepType = null; + + if (run.termination === 'skip') { + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'success', + success: true, + outcome: 'skipped', + skipped: true, + currentStep: null, + message: 'Schritt übersprungen', + output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, + stdout: run.stdout || null, + stderr: run.stderr || null, + stdoutTruncated: Boolean(run.stdoutTruncated), + stderrTruncated: Boolean(run.stderrTruncated) + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" übersprungen.`); + } catch (_error) { + // ignore appendLog failures on skip path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: true, + skipped: true, + reason: 'skipped_by_user' + }); + continue; + } + + if (run.termination === 'cancel' || controlState.cancelRequested) { + abortedByUser = true; + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'cancelled', + cancelled: true, + currentStep: null, + message: controlState.cancelReason || 'Von Benutzer abgebrochen', + output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, + stdout: run.stdout || null, + stderr: run.stderr || null, + stdoutTruncated: Boolean(run.stdoutTruncated), + stderrTruncated: Boolean(run.stderrTruncated), + errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`); + } catch (_error) { + // ignore appendLog failures on cancel path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: false, + aborted: true, + reason: 'cancelled_by_user' + }); + break; + } + + const success = run.code === 0; + runtimeActivityService.completeActivity(scriptActivityId, { + status: success ? 'success' : 'error', + success, + outcome: success ? 'success' : 'error', + exitCode: run.code, + currentStep: null, + message: success ? null : `Fehler (Exit ${run.code})`, + output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, + stderr: success ? null : (run.stderr || null), + stdout: success ? null : (run.stdout || null), + stdoutTruncated: Boolean(run.stdoutTruncated), + stderrTruncated: Boolean(run.stderrTruncated), + errorMessage: success ? null : `Fehler (Exit ${run.code})` + }); + logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success }); + if (typeof appendLog === 'function') { + await appendLog( + success ? 'SYSTEM' : 'ERROR', + `Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}` + ); + } + results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' }); + + if (!success) { + logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code }); + break; + } + } catch (error) { + controlState.currentStepType = null; + if (controlState.cancelRequested) { + abortedByUser = true; + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'cancelled', + cancelled: true, + message: controlState.cancelReason || 'Von Benutzer abgebrochen', + errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`); + } catch (_error) { + // ignore appendLog failures on cancel path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: false, + aborted: true, + reason: 'cancelled_by_user' + }); + break; + } + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'error', + message: error?.message || 'unknown', + errorMessage: error?.message || 'unknown' + }); + logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); + if (typeof appendLog === 'function') { + await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`); + } + results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message }); + break; + } finally { + controlState.activeChild = null; + controlState.activeProcessHandle = 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(); diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js new file mode 100644 index 0000000..b92de08 --- /dev/null +++ b/backend/src/services/scriptService.js @@ -0,0 +1,730 @@ +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const { getDb } = require('../db/database'); +const logger = require('./logger').child('SCRIPTS'); +const settingsService = require('./settingsService'); +const runtimeActivityService = require('./runtimeActivityService'); +const { streamLines } = require('./processRunner'); +const { errorToMeta } = require('../utils/errorMeta'); +const { tempDir } = require('../config'); + +const SCRIPT_NAME_MAX_LENGTH = 120; +const SCRIPT_BODY_MAX_LENGTH = 200000; +const SCRIPT_TEST_TIMEOUT_SETTING_KEY = 'script_test_timeout_ms'; +const DEFAULT_SCRIPT_TEST_TIMEOUT_MS = 0; +const SCRIPT_TEST_TIMEOUT_MS = (() => { + const parsed = Number(process.env.RIPSTER_SCRIPT_TEST_TIMEOUT_MS); + if (Number.isFinite(parsed)) { + return Math.max(0, Math.trunc(parsed)); + } + return DEFAULT_SCRIPT_TEST_TIMEOUT_MS; +})(); +const SCRIPT_OUTPUT_MAX_CHARS = 150000; + +function normalizeScriptTestTimeoutMs(rawValue, fallbackMs = SCRIPT_TEST_TIMEOUT_MS) { + const parsed = Number(rawValue); + if (Number.isFinite(parsed)) { + return Math.max(0, Math.trunc(parsed)); + } + if (fallbackMs === null || fallbackMs === undefined) { + return null; + } + const parsedFallback = Number(fallbackMs); + if (Number.isFinite(parsedFallback)) { + return Math.max(0, Math.trunc(parsedFallback)); + } + return 0; +} + +function normalizeScriptId(rawValue) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); +} + +function normalizeScriptIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const normalized = normalizeScriptId(item); + if (!normalized) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function normalizeScriptName(rawValue) { + return String(rawValue || '').trim(); +} + +function normalizeScriptBody(rawValue) { + return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function normalizeFavoriteFlag(rawValue, fallback = 0) { + if (typeof rawValue === 'boolean') { + return rawValue ? 1 : 0; + } + const parsed = Number(rawValue); + if (Number.isFinite(parsed)) { + return parsed !== 0 ? 1 : 0; + } + const normalized = String(rawValue || '').trim().toLowerCase(); + if (!normalized) { + return fallback ? 1 : 0; + } + if (['true', 'yes', 'on', '1'].includes(normalized)) { + return 1; + } + if (['false', 'no', 'off', '0'].includes(normalized)) { + return 0; + } + return fallback ? 1 : 0; +} + +function createValidationError(message, details = null) { + const error = new Error(message); + error.statusCode = 400; + if (details) { + error.details = details; + } + return error; +} + +function validateScriptPayload(payload, { partial = false } = {}) { + const body = payload && typeof payload === 'object' ? payload : {}; + const hasName = Object.prototype.hasOwnProperty.call(body, 'name'); + const hasScriptBody = Object.prototype.hasOwnProperty.call(body, 'scriptBody'); + const normalized = {}; + const errors = []; + + if (!partial || hasName) { + const name = normalizeScriptName(body.name); + if (!name) { + errors.push({ field: 'name', message: 'Name darf nicht leer sein.' }); + } else if (name.length > SCRIPT_NAME_MAX_LENGTH) { + errors.push({ field: 'name', message: `Name darf maximal ${SCRIPT_NAME_MAX_LENGTH} Zeichen enthalten.` }); + } else { + normalized.name = name; + } + } + + if (!partial || hasScriptBody) { + const scriptBody = normalizeScriptBody(body.scriptBody); + if (!scriptBody.trim()) { + errors.push({ field: 'scriptBody', message: 'Skript darf nicht leer sein.' }); + } else if (scriptBody.length > SCRIPT_BODY_MAX_LENGTH) { + errors.push({ field: 'scriptBody', message: `Skript darf maximal ${SCRIPT_BODY_MAX_LENGTH} Zeichen enthalten.` }); + } else { + normalized.scriptBody = scriptBody; + } + } + + if (errors.length > 0) { + throw createValidationError('Skript ist ungültig.', errors); + } + + return normalized; +} + +function mapScriptRow(row) { + if (!row) { + return null; + } + return { + id: Number(row.id), + name: String(row.name || ''), + scriptBody: String(row.script_body || ''), + isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1, + orderIndex: Number(row.order_index || 0), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function quoteForBashSingle(value) { + return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`; +} + +function buildScriptEnvironment(context = {}) { + const now = new Date().toISOString(); + const entries = { + RIPSTER_SCRIPT_RUN_AT: now, + RIPSTER_JOB_ID: context?.jobId ?? '', + RIPSTER_JOB_TITLE: context?.jobTitle ?? '', + RIPSTER_MODE: context?.mode ?? '', + RIPSTER_INPUT_PATH: context?.inputPath ?? '', + RIPSTER_OUTPUT_PATH: context?.outputPath ?? '', + RIPSTER_RAW_PATH: context?.rawPath ?? '', + RIPSTER_SCRIPT_ID: context?.scriptId ?? '', + RIPSTER_SCRIPT_NAME: context?.scriptName ?? '', + RIPSTER_SCRIPT_SOURCE: context?.source ?? '' + }; + + const output = {}; + for (const [key, value] of Object.entries(entries)) { + output[key] = String(value ?? ''); + } + return output; +} + +function buildScriptWrapper(scriptBody, context = {}) { + const envVars = buildScriptEnvironment(context); + const exportLines = Object.entries(envVars) + .map(([key, value]) => `export ${key}=${quoteForBashSingle(value)}`) + .join('\n'); + // Wait for potential background jobs started by the script before returning. + return `${exportLines}\n\n${String(scriptBody || '')}\n\nwait\n`; +} + +function appendWithCap(current, chunk, maxChars) { + const value = String(chunk || ''); + if (!value) { + return { value: current, truncated: false }; + } + const currentText = String(current || ''); + if (currentText.length >= maxChars) { + return { value: currentText, truncated: true }; + } + const available = maxChars - currentText.length; + if (value.length <= available) { + return { value: `${currentText}${value}`, truncated: false }; + } + return { + value: `${currentText}${value.slice(0, available)}`, + truncated: true + }; +} + +function killChildProcessTree(child, signal = 'SIGTERM') { + if (!child) { + return false; + } + const pid = Number(child.pid); + if (Number.isFinite(pid) && pid > 0) { + try { + // If spawned as detached=true this targets the full process group. + process.kill(-pid, signal); + return true; + } catch (_error) { + // Fallback below. + } + } + try { + child.kill(signal); + return true; + } catch (_error) { + return false; + } +} + +function runProcessCapture({ + cmd, + args, + timeoutMs = SCRIPT_TEST_TIMEOUT_MS, + cwd = process.cwd(), + onChild = null, + onStdoutLine = null, + onStderrLine = null +}) { + return new Promise((resolve, reject) => { + const effectiveTimeoutMs = normalizeScriptTestTimeoutMs(timeoutMs, SCRIPT_TEST_TIMEOUT_MS); + const startedAt = Date.now(); + let ended = false; + const child = spawn(cmd, args, { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + }); + if (typeof onChild === 'function') { + try { + onChild(child); + } catch (_error) { + // ignore observer errors + } + } + + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + let timedOut = false; + + let timeout = null; + if (effectiveTimeoutMs > 0) { + timeout = setTimeout(() => { + timedOut = true; + killChildProcessTree(child, 'SIGTERM'); + setTimeout(() => { + if (!ended) { + killChildProcessTree(child, 'SIGKILL'); + } + }, 2000); + }, effectiveTimeoutMs); + } + + const onData = (streamName, chunk) => { + if (streamName === 'stdout') { + const next = appendWithCap(stdout, chunk, SCRIPT_OUTPUT_MAX_CHARS); + stdout = next.value; + stdoutTruncated = stdoutTruncated || next.truncated; + } else { + const next = appendWithCap(stderr, chunk, SCRIPT_OUTPUT_MAX_CHARS); + stderr = next.value; + stderrTruncated = stderrTruncated || next.truncated; + } + }; + + child.stdout?.on('data', (chunk) => onData('stdout', chunk)); + child.stderr?.on('data', (chunk) => onData('stderr', chunk)); + + if (child.stdout && typeof onStdoutLine === 'function') { + streamLines(child.stdout, onStdoutLine); + } + if (child.stderr && typeof onStderrLine === 'function') { + streamLines(child.stderr, onStderrLine); + } + + child.on('error', (error) => { + ended = true; + if (timeout) { + clearTimeout(timeout); + } + reject(error); + }); + + child.on('close', (code, signal) => { + ended = true; + if (timeout) { + clearTimeout(timeout); + } + const endedAt = Date.now(); + resolve({ + code: Number.isFinite(Number(code)) ? Number(code) : null, + signal: signal || null, + durationMs: Math.max(0, endedAt - startedAt), + timedOut, + stdout, + stderr, + stdoutTruncated, + stderrTruncated + }); + }); + }); +} + +async function resolveScriptTestTimeoutMs(options = {}) { + const timeoutFromOptions = normalizeScriptTestTimeoutMs(options?.timeoutMs, null); + if (timeoutFromOptions !== null) { + return timeoutFromOptions; + } + try { + const settingsMap = await settingsService.getSettingsMap(); + return normalizeScriptTestTimeoutMs( + settingsMap?.[SCRIPT_TEST_TIMEOUT_SETTING_KEY], + SCRIPT_TEST_TIMEOUT_MS + ); + } catch (error) { + logger.warn('script:test-timeout:settings-read-failed', { error: errorToMeta(error) }); + return SCRIPT_TEST_TIMEOUT_MS; + } +} + +class ScriptService { + async listScripts() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at + FROM scripts + ORDER BY order_index ASC, id ASC + ` + ); + return rows.map(mapScriptRow); + } + + async getScriptById(scriptId) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const db = await getDb(); + const row = await db.get( + ` + SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at + FROM scripts + WHERE id = ? + `, + [normalizedId] + ); + if (!row) { + const error = new Error(`Skript #${normalizedId} wurde nicht gefunden.`); + error.statusCode = 404; + throw error; + } + return mapScriptRow(row); + } + + async createScript(payload = {}) { + const normalized = validateScriptPayload(payload, { partial: false }); + const db = await getDb(); + try { + const nextOrderIndex = await this._getNextOrderIndex(db); + const result = await db.run( + ` + INSERT INTO scripts (name, script_body, is_favorite, order_index, created_at, updated_at) + VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + [normalized.name, normalized.scriptBody, nextOrderIndex] + ); + return this.getScriptById(result.lastID); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE constraint failed')) { + throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [ + { field: 'name', message: 'Name muss eindeutig sein.' } + ]); + } + throw error; + } + } + + async updateScript(scriptId, payload = {}) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const normalized = validateScriptPayload(payload, { partial: false }); + const db = await getDb(); + await this.getScriptById(normalizedId); + try { + await db.run( + ` + UPDATE scripts + SET + name = ?, + script_body = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [normalized.name, normalized.scriptBody, normalizedId] + ); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE constraint failed')) { + throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [ + { field: 'name', message: 'Name muss eindeutig sein.' } + ]); + } + throw error; + } + return this.getScriptById(normalizedId); + } + + async deleteScript(scriptId) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const db = await getDb(); + const existing = await this.getScriptById(normalizedId); + await db.run('DELETE FROM scripts WHERE id = ?', [normalizedId]); + return existing; + } + + async setScriptFavorite(scriptId, isFavorite) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0); + const db = await getDb(); + await this.getScriptById(normalizedId); + await db.run( + ` + UPDATE scripts + SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [favoriteFlag, normalizedId] + ); + return this.getScriptById(normalizedId); + } + + async getScriptsByIds(rawIds = []) { + const ids = normalizeScriptIdList(rawIds); + if (ids.length === 0) { + return []; + } + const db = await getDb(); + const placeholders = ids.map(() => '?').join(', '); + const rows = await db.all( + ` + SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at + FROM scripts + WHERE id IN (${placeholders}) + `, + ids + ); + const byId = new Map(rows.map((row) => [Number(row.id), mapScriptRow(row)])); + return ids.map((id) => byId.get(id)).filter(Boolean); + } + + async resolveScriptsByIds(rawIds = [], options = {}) { + const ids = normalizeScriptIdList(rawIds); + if (ids.length === 0) { + return []; + } + const strict = options?.strict !== false; + const scripts = await this.getScriptsByIds(ids); + if (!strict) { + return scripts; + } + const foundIds = new Set(scripts.map((item) => Number(item.id))); + const missing = ids.filter((id) => !foundIds.has(Number(id))); + if (missing.length > 0) { + throw createValidationError(`Skript(e) nicht gefunden: ${missing.join(', ')}`, [ + { field: 'selectedPostEncodeScriptIds', message: `Nicht gefunden: ${missing.join(', ')}` } + ]); + } + return scripts; + } + + async reorderScripts(orderedIds = []) { + const db = await getDb(); + const providedIds = normalizeScriptIdList(orderedIds); + const rows = await db.all( + ` + SELECT id + FROM scripts + ORDER BY order_index ASC, id ASC + ` + ); + if (rows.length === 0) { + return []; + } + + const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0); + const existingSet = new Set(existingIds); + const used = new Set(); + const nextOrder = []; + + for (const id of providedIds) { + if (!existingSet.has(id) || used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + for (const id of existingIds) { + if (used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + await db.exec('BEGIN'); + try { + for (let i = 0; i < nextOrder.length; i += 1) { + await db.run( + ` + UPDATE scripts + SET order_index = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [i + 1, nextOrder[i]] + ); + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + return this.listScripts(); + } + + async _getNextOrderIndex(db) { + const row = await db.get( + ` + SELECT COALESCE(MAX(order_index), 0) AS max_order_index + FROM scripts + ` + ); + const maxOrder = Number(row?.max_order_index || 0); + if (!Number.isFinite(maxOrder) || maxOrder < 0) { + return 1; + } + return Math.trunc(maxOrder) + 1; + } + + async createExecutableScriptFile(script, context = {}) { + const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`; + const scriptBody = normalizeScriptBody(script?.scriptBody); + fs.mkdirSync(tempDir, { recursive: true }); + const scriptTempDir = await fs.promises.mkdtemp(path.join(tempDir, 'ripster-script-')); + const scriptPath = path.join(scriptTempDir, 'script.sh'); + const wrapped = buildScriptWrapper(scriptBody, { + ...context, + scriptId: script?.id ?? context?.scriptId ?? '', + scriptName: name, + source: context?.source || 'post_encode' + }); + + await fs.promises.writeFile(scriptPath, wrapped, { + encoding: 'utf-8', + mode: 0o700 + }); + + const cleanup = async () => { + try { + await fs.promises.rm(scriptTempDir, { recursive: true, force: true }); + } catch (error) { + logger.warn('script:temp-cleanup-failed', { + scriptId: script?.id ?? null, + scriptName: name, + tempDir: scriptTempDir, + error: errorToMeta(error) + }); + } + }; + + return { + tempDir: scriptTempDir, + scriptPath, + cmd: '/usr/bin/env', + args: ['bash', scriptPath], + argsForLog: ['bash', ``], + cleanup + }; + } + + async testScript(scriptId, options = {}) { + const script = await this.getScriptById(scriptId); + const effectiveTimeoutMs = await resolveScriptTestTimeoutMs(options); + const prepared = await this.createExecutableScriptFile(script, { + source: 'settings_test', + mode: 'test' + }); + const activityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'settings_test', + scriptId: script.id, + currentStep: 'Skript-Test läuft' + }); + const controlState = { + cancelRequested: false, + cancelReason: null, + child: null, + cancelSignalSent: false + }; + runtimeActivityService.setControls(activityId, { + cancel: async (payload = {}) => { + if (controlState.cancelRequested) { + return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' }; + } + controlState.cancelRequested = true; + controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen'; + runtimeActivityService.updateActivity(activityId, { + message: 'Abbruch angefordert' + }); + if (controlState.child) { + // User cancel should stop instantly. + controlState.cancelSignalSent = killChildProcessTree(controlState.child, 'SIGKILL') || controlState.cancelSignalSent; + } + return { accepted: true, message: 'Abbruch angefordert.' }; + } + }); + + try { + const run = await runProcessCapture({ + cmd: prepared.cmd, + args: prepared.args, + timeoutMs: effectiveTimeoutMs, + onChild: (child) => { + controlState.child = child; + }, + onStdoutLine: (line) => { + runtimeActivityService.appendActivityOutput(activityId, { stdout: line }); + }, + onStderrLine: (line) => { + runtimeActivityService.appendActivityOutput(activityId, { stderr: line }); + } + }); + const exitCode = Number.isFinite(Number(run.code)) ? Number(run.code) : null; + const finishedSuccessfully = exitCode === 0 && !run.timedOut; + const cancelledByUser = Boolean(controlState.cancelRequested) + && (Boolean(controlState.cancelSignalSent) || !finishedSuccessfully); + const success = finishedSuccessfully; + const message = cancelledByUser + ? (controlState.cancelReason || 'Von Benutzer abgebrochen') + : (run.timedOut + ? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s` + : (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`)); + const errorMessage = success + ? null + : (cancelledByUser + ? (controlState.cancelReason || 'Von Benutzer abgebrochen') + : (run.timedOut + ? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s` + : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`)); + runtimeActivityService.completeActivity(activityId, { + status: success ? 'success' : 'error', + success, + outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'), + cancelled: cancelledByUser, + exitCode, + stdout: run.stdout || null, + stderr: run.stderr || null, + stdoutTruncated: Boolean(run.stdoutTruncated), + stderrTruncated: Boolean(run.stderrTruncated), + errorMessage, + message + }); + return { + scriptId: script.id, + scriptName: script.name, + success, + exitCode: run.code, + signal: run.signal, + timedOut: run.timedOut, + durationMs: run.durationMs, + stdout: run.stdout, + stderr: run.stderr, + stdoutTruncated: run.stdoutTruncated, + stderrTruncated: run.stderrTruncated + }; + } catch (error) { + runtimeActivityService.completeActivity(activityId, { + status: 'error', + success: false, + outcome: controlState.cancelRequested ? 'cancelled' : 'error', + cancelled: Boolean(controlState.cancelRequested), + errorMessage: controlState.cancelRequested + ? (controlState.cancelReason || 'Von Benutzer abgebrochen') + : (error?.message || 'Skript-Test Fehler'), + message: controlState.cancelRequested + ? (controlState.cancelReason || 'Von Benutzer abgebrochen') + : (error?.message || 'Skript-Test Fehler') + }); + throw error; + } finally { + controlState.child = null; + await prepared.cleanup(); + } + } +} + +module.exports = new ScriptService(); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js new file mode 100644 index 0000000..474743e --- /dev/null +++ b/backend/src/services/settingsService.js @@ -0,0 +1,1886 @@ +const fs = require('fs'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); +const { getDb } = require('../db/database'); +const loggerService = require('./logger'); +const logger = loggerService.child('SETTINGS'); +const { + parseJson, + normalizeValueByType, + serializeValueByType, + validateSetting, + toBoolean +} = require('../utils/validators'); +const { splitArgs } = require('../utils/commandLine'); +const { setLogRootDir } = require('./logPathService'); +const { + syncRegistrationKeyToConfig, + normalizeRegistrationKey +} = require('./makemkvKeyService'); + +const { + defaultRawDir: DEFAULT_RAW_DIR, + defaultMovieDir: DEFAULT_MOVIE_DIR, + defaultSeriesDir: DEFAULT_SERIES_DIR, + defaultCdDir: DEFAULT_CD_DIR, + defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR, + defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR, + defaultDownloadDir: DEFAULT_DOWNLOAD_DIR, + defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR, + defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR, + defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR, + tempDir: DEFAULT_TEMP_DIR +} = require('../config'); + +const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac']; +const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000; +const SETTINGS_CACHE_TTL_MS = 15000; +const HANDBRAKE_PRESET_CACHE_TTL_MS = 5 * 60 * 1000; +const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([ + 'handbrake_command', + 'handbrake_preset', + 'handbrake_preset_bluray', + 'handbrake_preset_dvd' +]); +const SENSITIVE_SETTING_KEYS = new Set([ + 'makemkv_registration_key', + 'tmdb_api_read_access_token', + 'pushover_token', + 'pushover_user' +]); +const LEGACY_HIDDEN_SETTING_KEYS = new Set([ + 'omdb_api_key', + 'omdb_default_type', + 'omdb_timeout_ms' +]); +const IMMUTABLE_SETTING_KEYS = new Set([ + 'makemkv_command', + 'mediainfo_command', + 'handbrake_command', + 'ffmpeg_command', + 'ffprobe_command', + 'cdparanoia_command', + 'mkvmerge_command' +]); +const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']); +const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']); +const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']); +const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-subtitle']); +const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']); +const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']); +const LOG_DIR_SETTING_KEY = 'log_dir'; +const SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY = 'server_console_log_output_enabled'; +const SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY = 'server_console_log_http_enabled'; +const SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY = 'server_console_log_debug_enabled'; +const SERVER_CONSOLE_LOG_INFO_ENABLED_KEY = 'server_console_log_info_enabled'; +const SERVER_CONSOLE_LOG_WARN_ENABLED_KEY = 'server_console_log_warn_enabled'; +const SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY = 'server_console_log_error_enabled'; +const SERVER_CONSOLE_LOG_SETTING_KEYS = new Set([ + SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY, + SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY, + SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY, + SERVER_CONSOLE_LOG_INFO_ENABLED_KEY, + SERVER_CONSOLE_LOG_WARN_ENABLED_KEY, + SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY +]); +const MAKEMKV_REGISTRATION_KEY_SETTING_KEY = 'makemkv_registration_key'; +const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook']; +const PROFILED_SETTINGS = { + raw_dir: { + bluray: 'raw_dir_bluray', + dvd: 'raw_dir_dvd', + cd: 'raw_dir_cd', + audiobook: 'raw_dir_audiobook' + }, + raw_dir_owner: { + bluray: 'raw_dir_bluray_owner', + dvd: 'raw_dir_dvd_owner', + cd: 'raw_dir_cd_owner', + audiobook: 'raw_dir_audiobook_owner' + }, + series_raw_dir: { + bluray: 'raw_dir_bluray_series', + dvd: 'raw_dir_dvd_series' + }, + series_raw_dir_owner: { + bluray: 'raw_dir_bluray_series_owner', + dvd: 'raw_dir_dvd_series_owner' + }, + movie_dir: { + bluray: 'movie_dir_bluray', + dvd: 'movie_dir_dvd', + cd: 'movie_dir_cd', + audiobook: 'movie_dir_audiobook' + }, + series_dir: { + bluray: 'series_dir_bluray', + dvd: 'series_dir_dvd' + }, + movie_dir_owner: { + bluray: 'movie_dir_bluray_owner', + dvd: 'movie_dir_dvd_owner', + cd: 'movie_dir_cd_owner', + audiobook: 'movie_dir_audiobook_owner' + }, + series_dir_owner: { + bluray: 'series_dir_bluray_owner', + dvd: 'series_dir_dvd_owner' + }, + mediainfo_extra_args: { + bluray: 'mediainfo_extra_args_bluray', + dvd: 'mediainfo_extra_args_dvd' + }, + makemkv_rip_mode: { + bluray: 'makemkv_rip_mode_bluray', + dvd: 'makemkv_rip_mode_dvd' + }, + makemkv_analyze_extra_args: { + bluray: 'makemkv_analyze_extra_args_bluray', + dvd: 'makemkv_analyze_extra_args_dvd' + }, + makemkv_rip_extra_args: { + bluray: 'makemkv_rip_extra_args_bluray', + dvd: 'makemkv_rip_extra_args_dvd' + }, + handbrake_preset: { + bluray: 'handbrake_preset_bluray', + dvd: 'handbrake_preset_dvd' + }, + handbrake_extra_args: { + bluray: 'handbrake_extra_args_bluray', + dvd: 'handbrake_extra_args_dvd' + }, + handbrake_review_audio_languages: { + bluray: 'handbrake_review_audio_languages_bluray', + dvd: 'handbrake_review_audio_languages_dvd' + }, + handbrake_review_subtitle_languages: { + bluray: 'handbrake_review_subtitle_languages_bluray', + dvd: 'handbrake_review_subtitle_languages_dvd' + }, + output_extension: { + bluray: 'output_extension_bluray', + dvd: 'output_extension_dvd' + }, + output_template: { + bluray: 'output_template_bluray', + dvd: 'output_template_dvd', + audiobook: 'output_template_audiobook' + } +}; +const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([ + 'raw_dir', + 'raw_dir_owner', + 'series_raw_dir', + 'series_raw_dir_owner', + 'movie_dir', + 'movie_dir_owner', + 'series_dir', + 'series_dir_owner' +]); + +function applyRuntimeLogDirSetting(rawValue) { + const resolved = setLogRootDir(rawValue); + try { + fs.mkdirSync(resolved, { recursive: true }); + return resolved; + } catch (error) { + const fallbackResolved = setLogRootDir(null); + try { + fs.mkdirSync(fallbackResolved, { recursive: true }); + } catch (_fallbackError) { + // ignore fallback fs errors here; logger may still print to console + } + logger.warn('setting:log-dir:fallback', { + configured: String(rawValue || '').trim() || null, + resolved, + fallbackResolved, + error: error?.message || String(error) + }); + return fallbackResolved; + } +} + +function normalizeBooleanSetting(rawValue, fallback = true) { + const normalizedRaw = typeof rawValue === 'string' ? rawValue.trim() : rawValue; + if (normalizedRaw === null || normalizedRaw === undefined || normalizedRaw === '') { + return fallback; + } + return toBoolean(normalizedRaw); +} + +function isHiddenSettingKey(key) { + const normalized = String(key || '').trim().toLowerCase(); + return LEGACY_HIDDEN_SETTING_KEYS.has(normalized); +} + +function applyRuntimeServerConsoleLoggingSettingsFromMap(settingsMap = {}) { + const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; + const enabled = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY], true); + const http = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY], true); + const debug = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY], true); + const info = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_INFO_ENABLED_KEY], true); + const warn = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_WARN_ENABLED_KEY], true); + const error = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY], true); + const applied = loggerService.configureConsoleOutput({ + enabled, + http, + levels: { + debug, + info, + warn, + error + } + }); + return applied; +} + +function isImmutableSettingKey(key) { + return IMMUTABLE_SETTING_KEYS.has(String(key || '').trim().toLowerCase()); +} + +function normalizeTrackIds(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = String(Math.trunc(value)); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + output.push(normalized); + } + return output; +} + +function normalizeTrackIdSequence(rawList, options = {}) { + const list = Array.isArray(rawList) ? rawList : []; + const dedupe = options?.dedupe !== false; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = String(Math.trunc(value)); + if (dedupe && seen.has(normalized)) { + continue; + } + if (dedupe) { + seen.add(normalized); + } + output.push(normalized); + } + return output; +} + +function normalizePositiveIndexes(rawList, maxValue = null) { + const values = normalizeTrackIds(rawList) + .map((item) => Number(item)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + if (!Number.isFinite(maxValue) || maxValue <= 0) { + return values; + } + const limit = Math.trunc(maxValue); + return values.filter((value) => value <= limit); +} + +function normalizeNonNegativeInteger(rawValue) { + if (rawValue === null || rawValue === undefined) { + return null; + } + if (typeof rawValue === 'string' && rawValue.trim() === '') { + return null; + } + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + return null; + } + return Math.trunc(value); +} + +function removeSelectionArgs(extraArgs) { + const args = Array.isArray(extraArgs) ? extraArgs : []; + const filtered = []; + + for (let i = 0; i < args.length; i += 1) { + const token = String(args[i] || ''); + const key = token.includes('=') ? token.slice(0, token.indexOf('=')) : token; + + const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key); + const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key); + const isSubtitleSelectionWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key); + const isSubtitleFlagWithValue = SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key); + const isSubtitleWithValue = isSubtitleSelectionWithValue || isSubtitleFlagWithValue; + const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key); + const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key); + const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue; + + if (isSubtitleFlagWithValue) { + const inlineValue = token.includes('=') + ? token.slice(token.indexOf('=') + 1) + : ''; + const hasAttachedValue = token.includes('='); + const nextToken = String(args[i + 1] || ''); + const hasSeparateValue = !hasAttachedValue && nextToken && !nextToken.startsWith('-'); + const candidateValue = String( + hasAttachedValue + ? inlineValue + : (hasSeparateValue ? nextToken : '') + ).trim().toLowerCase(); + + // Keep explicit "none" subtitle flags from extra args. + // This allows users to intentionally clear subtitle burn/default/forced behavior. + if (candidateValue === 'none') { + filtered.push(token); + if (hasSeparateValue) { + filtered.push(nextToken); + i += 1; + } + continue; + } + } + + if (!skip) { + filtered.push(token); + continue; + } + + if ((isAudioWithValue || isSubtitleWithValue || isTitleWithValue) && !token.includes('=')) { + const nextToken = String(args[i + 1] || ''); + if (nextToken && !nextToken.startsWith('-')) { + i += 1; + } + } + } + + return filtered; +} + +function flattenPresetList(input, output = []) { + const list = Array.isArray(input) ? input : []; + for (const entry of list) { + if (!entry || typeof entry !== 'object') { + continue; + } + if (Array.isArray(entry.ChildrenArray) && entry.ChildrenArray.length > 0) { + flattenPresetList(entry.ChildrenArray, output); + continue; + } + output.push(entry); + } + return output; +} + +function buildFallbackPresetProfile(presetName, message = null) { + return { + source: 'fallback', + message, + presetName: presetName || null, + audioTrackSelectionBehavior: 'first', + audioLanguages: [], + audioEncoders: [], + audioCopyMask: DEFAULT_AUDIO_COPY_MASK, + audioFallback: 'av_aac', + subtitleTrackSelectionBehavior: 'none', + subtitleLanguages: [], + subtitleBurnBehavior: 'none' + }; +} + +function stripAnsiEscapeCodes(value) { + return String(value || '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); +} + +function uniqueOrderedValues(values) { + const unique = []; + const seen = new Set(); + for (const value of values || []) { + const normalized = String(value || '').trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + unique.push(normalized); + } + return unique; +} + +function normalizeSettingKey(value) { + return String(value || '').trim().toLowerCase(); +} + +function runCommandCapture(cmd, args = [], options = {}) { + const timeoutMs = Math.max(0, Number(options.timeout || 0)); + const maxBuffer = Math.max(1024, Number(options.maxBuffer || 8 * 1024 * 1024)); + const argv = Array.isArray(args) ? args : []; + + return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let timer = null; + let stdout = ''; + let stderr = ''; + let totalBytes = 0; + + const finish = (handler, payload) => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + handler(payload); + }; + + const child = spawn(cmd, argv, { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const appendChunk = (chunk, target) => { + if (settled) { + return; + } + const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'); + totalBytes += Buffer.byteLength(text, 'utf-8'); + if (totalBytes > maxBuffer) { + try { + child.kill('SIGKILL'); + } catch (_error) { + // ignore kill errors + } + finish(reject, new Error(`Command output exceeded ${maxBuffer} bytes.`)); + return; + } + if (target === 'stdout') { + stdout += text; + } else { + stderr += text; + } + }; + + child.on('error', (error) => finish(reject, error)); + child.on('close', (status, signal) => { + finish(resolve, { + status, + signal, + timedOut, + stdout, + stderr + }); + }); + + if (child.stdout) { + child.stdout.on('data', (chunk) => appendChunk(chunk, 'stdout')); + } + if (child.stderr) { + child.stderr.on('data', (chunk) => appendChunk(chunk, 'stderr')); + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + timedOut = true; + try { + child.kill('SIGKILL'); + } catch (_error) { + // ignore kill errors + } + }, timeoutMs); + } + }); +} + +function uniquePresetEntries(entries) { + const unique = []; + const seenNames = new Set(); + for (const entry of entries || []) { + const name = String(entry?.name || '').trim(); + if (!name || seenNames.has(name)) { + continue; + } + seenNames.add(name); + const categoryRaw = entry?.category; + const category = categoryRaw === null || categoryRaw === undefined + ? null + : String(categoryRaw).trim() || null; + unique.push({ name, category }); + } + return unique; +} + +function normalizeMediaProfileValue(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if ( + raw === 'bluray' + || raw === 'blu-ray' + || raw === 'blu_ray' + || raw === 'bd' + || raw === 'bdmv' + || raw === 'bdrom' + || raw === 'bd-rom' + || raw === 'bd-r' + || raw === 'bd-re' + ) { + return 'bluray'; + } + if ( + raw === 'dvd' + || raw === 'dvdvideo' + || raw === 'dvd-video' + || raw === 'dvdrom' + || raw === 'dvd-rom' + || raw === 'video_ts' + || raw === 'iso9660' + ) { + return 'dvd'; + } + if (raw === 'cd' || raw === 'audio_cd') { + return 'cd'; + } + if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') { + return 'audiobook'; + } + return null; +} + +function resolveProfileFallbackOrder(profile) { + const normalized = normalizeMediaProfileValue(profile); + if (normalized === 'audiobook') { + return ['audiobook']; + } + if (normalized === 'bluray') { + return ['bluray', 'dvd']; + } + if (normalized === 'dvd') { + return ['dvd', 'bluray']; + } + return ['dvd', 'bluray']; +} + +function hasUsableProfileSpecificValue(value) { + if (value === null || value === undefined) { + return false; + } + if (typeof value === 'string') { + return value.trim().length > 0; + } + return true; +} + +function normalizePresetListLines(rawOutput) { + const lines = String(rawOutput || '').split(/\r?\n/); + const normalized = []; + + for (const line of lines) { + const sanitized = stripAnsiEscapeCodes(line || '').replace(/\r/g, ''); + if (!sanitized.trim()) { + continue; + } + if (/^\s*\[[^\]]+\]/.test(sanitized)) { + continue; + } + if ( + /^\s*(Cannot load|Compile-time|qsv:|HandBrake \d|Opening |No title found|libhb:|hb_init:|thread |bdj\.c:|stream:|scan:|bd:|libdvdnav:|libdvdread:)/i + .test(sanitized) + ) { + continue; + } + if (/^\s*HandBrake has exited\.?\s*$/i.test(sanitized)) { + continue; + } + const leadingWhitespace = (sanitized.match(/^[\t ]*/) || [''])[0]; + const indentation = leadingWhitespace.replace(/\t/g, ' ').length; + const text = sanitized.trim(); + normalized.push({ indentation, text }); + } + + return normalized; +} + +function parsePlusTreePresetEntries(lines) { + const plusEntries = []; + for (const line of lines || []) { + const match = String(line?.text || '').match(/^\+\s+(.+?)\s*$/); + if (!match) { + continue; + } + plusEntries.push({ + indentation: Number(line?.indentation || 0), + name: String(match[1] || '').trim() + }); + } + + if (plusEntries.length === 0) { + return []; + } + + const leafEntries = []; + for (let index = 0; index < plusEntries.length; index += 1) { + const current = plusEntries[index]; + const next = plusEntries[index + 1]; + const hasChildren = Boolean(next) && next.indentation > current.indentation; + if (!hasChildren) { + let category = null; + for (let parentIndex = index - 1; parentIndex >= 0; parentIndex -= 1) { + const candidate = plusEntries[parentIndex]; + if (candidate.indentation < current.indentation) { + category = candidate.name || null; + break; + } + } + leafEntries.push({ + name: current.name, + category + }); + } + } + + return uniquePresetEntries(leafEntries); +} + +function parseSlashTreePresetEntries(lines) { + const list = Array.isArray(lines) ? lines : []; + const presetEntries = []; + let currentCategoryIndent = null; + let currentCategoryName = null; + let currentPresetIndent = null; + + for (const line of list) { + const indentation = Number(line?.indentation || 0); + const text = String(line?.text || '').trim(); + if (!text) { + continue; + } + + if (text.endsWith('/')) { + currentCategoryIndent = indentation; + currentCategoryName = String(text.slice(0, -1) || '').trim() || null; + currentPresetIndent = null; + continue; + } + + if (currentCategoryIndent === null) { + continue; + } + + if (indentation <= currentCategoryIndent) { + currentCategoryIndent = null; + currentCategoryName = null; + currentPresetIndent = null; + continue; + } + + if (currentPresetIndent === null) { + currentPresetIndent = indentation; + } + + if (indentation === currentPresetIndent) { + presetEntries.push({ + name: text, + category: currentCategoryName + }); + } + } + + return uniquePresetEntries(presetEntries); +} + +function parseHandBrakePresetEntriesFromListOutput(rawOutput) { + const lines = normalizePresetListLines(rawOutput); + const plusTreeEntries = parsePlusTreePresetEntries(lines); + if (plusTreeEntries.length > 0) { + return plusTreeEntries; + } + return parseSlashTreePresetEntries(lines); +} + +function mapPresetEntriesToOptions(entries) { + const list = Array.isArray(entries) ? entries : []; + const options = []; + const seenCategories = new Set(); + const INDENT = '\u00A0\u00A0\u00A0'; + + for (const entry of list) { + const name = String(entry?.name || '').trim(); + if (!name) { + continue; + } + const category = entry?.category ? String(entry.category).trim() : ''; + if (category && !seenCategories.has(category)) { + seenCategories.add(category); + options.push({ + label: `${category}/`, + value: `__group__${category.toLowerCase().replace(/\s+/g, '_')}`, + disabled: true, + category + }); + } + options.push({ + label: category ? `${INDENT}${name}` : name, + value: name, + category: category || null + }); + } + + return options; +} + +/** + * Parses the drive_devices JSON setting into a normalized array of {path, makemkvIndex}. + * Supports both the legacy string format ["/dev/sr0"] and the object format + * [{"path": "/dev/sr0", "makemkvIndex": 0}]. + * When makemkvIndex is missing, it is assigned by list order (0,1,2,...) so + * sparse kernel names like /dev/sr2 still map to contiguous MakeMKV indices. + */ +function parseDriveDeviceEntries(raw) { + try { + const arr = JSON.parse(raw || '[]'); + if (!Array.isArray(arr)) { + return []; + } + const normalized = arr.map((entry) => { + if (typeof entry === 'string') { + const p = entry.trim(); + if (!p) { + return null; + } + return { path: p, makemkvIndex: null }; + } + if (entry && typeof entry === 'object' && entry.path) { + const p = String(entry.path || '').trim(); + if (!p) { + return null; + } + const idxRaw = Number(entry.makemkvIndex); + return { + path: p, + makemkvIndex: Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : null + }; + } + return null; + }).filter(Boolean); + + const used = new Set(); + let nextAutoIndex = 0; + return normalized.map((entry) => { + if (entry.makemkvIndex != null) { + used.add(entry.makemkvIndex); + if (entry.makemkvIndex >= nextAutoIndex) { + nextAutoIndex = entry.makemkvIndex + 1; + } + return entry; + } + while (used.has(nextAutoIndex)) { + nextAutoIndex += 1; + } + const resolved = { + path: entry.path, + makemkvIndex: nextAutoIndex + }; + used.add(nextAutoIndex); + nextAutoIndex += 1; + return resolved; + }); + } catch (_error) { + return []; + } +} + +class SettingsService { + constructor() { + this.settingsSnapshotCache = { + expiresAt: 0, + snapshot: null, + inFlight: null + }; + this.handBrakePresetCache = { + expiresAt: 0, + cacheKey: null, + payload: null, + inFlight: null + }; + } + + buildSettingsSnapshot(flat = []) { + const list = Array.isArray(flat) ? flat : []; + const map = {}; + const byCategory = new Map(); + + for (const item of list) { + map[item.key] = item.value; + if (!byCategory.has(item.category)) { + byCategory.set(item.category, []); + } + byCategory.get(item.category).push(item); + } + + return { + flat: list, + map, + categorized: Array.from(byCategory.entries()).map(([category, settings]) => ({ + category, + settings + })) + }; + } + + invalidateHandBrakePresetCache() { + this.handBrakePresetCache = { + expiresAt: 0, + cacheKey: null, + payload: null, + inFlight: null + }; + } + + invalidateSettingsCache(changedKeys = []) { + this.settingsSnapshotCache = { + expiresAt: 0, + snapshot: null, + inFlight: null + }; + const normalizedKeys = Array.isArray(changedKeys) + ? changedKeys.map((key) => normalizeSettingKey(key)).filter(Boolean) + : []; + const shouldInvalidatePresets = normalizedKeys.some((key) => HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS.has(key)); + if (shouldInvalidatePresets) { + this.invalidateHandBrakePresetCache(); + } + } + + buildHandBrakePresetCacheKey(map = {}) { + const source = map && typeof map === 'object' ? map : {}; + return JSON.stringify({ + cmd: String(source.handbrake_command || 'HandBrakeCLI').trim(), + bluray: String(source.handbrake_preset_bluray || '').trim(), + dvd: String(source.handbrake_preset_dvd || '').trim(), + fallback: String(source.handbrake_preset || '').trim() + }); + } + + async getSettingsSnapshot(options = {}) { + const forceRefresh = Boolean(options?.forceRefresh); + const now = Date.now(); + + if (!forceRefresh && this.settingsSnapshotCache.snapshot && this.settingsSnapshotCache.expiresAt > now) { + return this.settingsSnapshotCache.snapshot; + } + if (!forceRefresh && this.settingsSnapshotCache.inFlight) { + return this.settingsSnapshotCache.inFlight; + } + + let loadPromise = null; + loadPromise = (async () => { + const flat = await this.fetchFlatSettingsFromDb(); + const snapshot = this.buildSettingsSnapshot(flat); + this.settingsSnapshotCache.snapshot = snapshot; + this.settingsSnapshotCache.expiresAt = Date.now() + SETTINGS_CACHE_TTL_MS; + return snapshot; + })().finally(() => { + if (this.settingsSnapshotCache.inFlight === loadPromise) { + this.settingsSnapshotCache.inFlight = null; + } + }); + this.settingsSnapshotCache.inFlight = loadPromise; + return loadPromise; + } + + async getSchemaRows() { + const db = await getDb(); + const rows = await db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC'); + return rows.filter((row) => !isHiddenSettingKey(row?.key)); + } + + async getSettingsMap(options = {}) { + const snapshot = await this.getSettingsSnapshot(options); + return { ...(snapshot?.map || {}) }; + } + + applyRuntimeSettingsFromMap(settingsMap = {}) { + const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; + const logDir = applyRuntimeLogDirSetting(source[LOG_DIR_SETTING_KEY]); + const consoleLogConfig = applyRuntimeServerConsoleLoggingSettingsFromMap(source); + return { + logDir, + serverConsoleLogConfig: consoleLogConfig + }; + } + + async applyRuntimeSettings() { + const map = await this.getSettingsMap(); + return this.applyRuntimeSettingsFromMap(map); + } + + normalizeMediaProfile(value) { + return normalizeMediaProfileValue(value); + } + + resolveEffectiveToolSettings(settingsMap = {}, mediaProfile = null) { + const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; + const normalizedRequestedProfile = normalizeMediaProfileValue(mediaProfile); + const fallbackOrder = resolveProfileFallbackOrder(normalizedRequestedProfile); + const resolvedMediaProfile = normalizedRequestedProfile || fallbackOrder[0] || 'dvd'; + const effective = { + ...sourceMap, + media_profile: resolvedMediaProfile + }; + + for (const [legacyKey, profileKeys] of Object.entries(PROFILED_SETTINGS)) { + let resolvedValue = sourceMap[legacyKey]; + if (STRICT_PROFILE_ONLY_SETTING_KEYS.has(legacyKey)) { + const selectedProfileKey = normalizedRequestedProfile + ? profileKeys?.[normalizedRequestedProfile] + : null; + const selectedProfileValue = selectedProfileKey ? sourceMap[selectedProfileKey] : undefined; + if (hasUsableProfileSpecificValue(selectedProfileValue)) { + resolvedValue = selectedProfileValue; + } + // Fallback to hardcoded install defaults when no setting value is configured + if (!hasUsableProfileSpecificValue(resolvedValue)) { + if (legacyKey === 'raw_dir') { + if (normalizedRequestedProfile === 'cd') { + resolvedValue = DEFAULT_CD_DIR; + } else if (normalizedRequestedProfile === 'audiobook') { + resolvedValue = DEFAULT_AUDIOBOOK_RAW_DIR; + } else { + resolvedValue = DEFAULT_RAW_DIR; + } + } else if (legacyKey === 'series_dir') { + resolvedValue = DEFAULT_SERIES_DIR; + } else if (legacyKey === 'movie_dir') { + if (normalizedRequestedProfile === 'cd') { + resolvedValue = DEFAULT_CD_DIR; + } else if (normalizedRequestedProfile === 'audiobook') { + resolvedValue = DEFAULT_AUDIOBOOK_DIR; + } else { + resolvedValue = DEFAULT_MOVIE_DIR; + } + } + } + effective[legacyKey] = resolvedValue; + continue; + } + for (const profile of fallbackOrder) { + const profileKey = profileKeys?.[profile]; + if (!profileKey) { + continue; + } + if (sourceMap[profileKey] !== undefined) { + resolvedValue = sourceMap[profileKey]; + break; + } + } + effective[legacyKey] = resolvedValue; + } + + effective.download_dir = String(sourceMap.download_dir || '').trim() || DEFAULT_DOWNLOAD_DIR; + effective.download_dir_owner = String(sourceMap.download_dir_owner || '').trim() || null; + + return effective; + } + + async getEffectiveSettingsMap(mediaProfile = null) { + const map = await this.getSettingsMap(); + return this.resolveEffectiveToolSettings(map, mediaProfile); + } + + async getEffectivePaths() { + const map = await this.getSettingsMap(); + const bluray = this.resolveEffectiveToolSettings(map, 'bluray'); + const dvd = this.resolveEffectiveToolSettings(map, 'dvd'); + const cd = this.resolveEffectiveToolSettings(map, 'cd'); + const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook'); + return { + bluray: { + raw: bluray.raw_dir, + seriesRaw: bluray.series_raw_dir || bluray.raw_dir, + movies: bluray.movie_dir, + series: bluray.series_dir + }, + dvd: { raw: dvd.raw_dir, seriesRaw: dvd.series_raw_dir || dvd.raw_dir, movies: dvd.movie_dir, series: dvd.series_dir }, + cd: { raw: cd.raw_dir, movies: cd.movie_dir }, + audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir }, + downloads: { path: bluray.download_dir }, + converter: { + raw: String(map.converter_raw_dir || '').trim() || DEFAULT_CONVERTER_RAW_DIR, + movies: String(map.converter_movie_dir || '').trim() || DEFAULT_CONVERTER_MOVIE_DIR, + audio: String(map.converter_audio_dir || '').trim() || DEFAULT_CONVERTER_AUDIO_DIR + }, + defaults: { + raw: DEFAULT_RAW_DIR, + movies: DEFAULT_MOVIE_DIR, + series: DEFAULT_SERIES_DIR, + cd: DEFAULT_CD_DIR, + audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR, + audiobookMovies: DEFAULT_AUDIOBOOK_DIR, + downloads: DEFAULT_DOWNLOAD_DIR, + converterRaw: DEFAULT_CONVERTER_RAW_DIR, + converterMovies: DEFAULT_CONVERTER_MOVIE_DIR, + converterAudio: DEFAULT_CONVERTER_AUDIO_DIR + } + }; + } + + async fetchFlatSettingsFromDb() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT + s.key, + s.category, + s.label, + s.type, + s.required, + s.description, + s.default_value, + s.options_json, + s.validation_json, + s.order_index, + s.depends_on, + v.value as current_value + FROM settings_schema s + LEFT JOIN settings_values v ON v.key = s.key + ORDER BY s.category ASC, s.order_index ASC + ` + ); + + return rows + .filter((row) => !isHiddenSettingKey(row?.key)) + .map((row) => ({ + key: row.key, + category: row.category, + label: row.label, + type: row.type, + required: Boolean(row.required), + description: row.description, + defaultValue: row.default_value, + options: parseJson(row.options_json, []), + validation: parseJson(row.validation_json, {}), + value: normalizeValueByType(row.type, row.current_value ?? row.default_value), + orderIndex: row.order_index, + depends_on: row.depends_on ?? null + })); + } + + async getFlatSettings(options = {}) { + const snapshot = await this.getSettingsSnapshot(options); + return Array.isArray(snapshot?.flat) ? [...snapshot.flat] : []; + } + + async getCategorizedSettings(options = {}) { + const snapshot = await this.getSettingsSnapshot(options); + return Array.isArray(snapshot?.categorized) ? [...snapshot.categorized] : []; + } + + async setSettingValue(key, rawValue) { + if (isImmutableSettingKey(key)) { + const error = new Error(`Setting ${key} ist schreibgeschützt und kann nicht geändert werden.`); + error.statusCode = 403; + throw error; + } + + const db = await getDb(); + const schema = await db.get('SELECT * FROM settings_schema WHERE key = ?', [key]); + if (!schema) { + const error = new Error(`Setting ${key} existiert nicht.`); + error.statusCode = 404; + throw error; + } + + const result = validateSetting(schema, rawValue); + if (!result.valid) { + const error = new Error(result.errors.join(' ')); + error.statusCode = 400; + throw error; + } + + const serializedValue = serializeValueByType(schema.type, result.normalized); + const normalizedKey = String(key || '').trim().toLowerCase(); + + try { + await db.exec('BEGIN'); + 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 + `, + [key, serializedValue] + ); + if (normalizedKey === MAKEMKV_REGISTRATION_KEY_SETTING_KEY) { + await syncRegistrationKeyToConfig(result.normalized); + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + logger.info('setting:updated', { + key, + value: SENSITIVE_SETTING_KEYS.has(String(key || '').trim().toLowerCase()) ? '[redacted]' : result.normalized + }); + if (normalizedKey === LOG_DIR_SETTING_KEY) { + applyRuntimeLogDirSetting(result.normalized); + } + if (SERVER_CONSOLE_LOG_SETTING_KEYS.has(normalizedKey)) { + const map = await this.getSettingsMap({ forceRefresh: true }); + applyRuntimeServerConsoleLoggingSettingsFromMap(map); + } + this.invalidateSettingsCache([key]); + + return { + key, + value: result.normalized + }; + } + + async setSettingsBulk(rawPatch) { + if (!rawPatch || typeof rawPatch !== 'object' || Array.isArray(rawPatch)) { + const error = new Error('Ungültiger Payload. Erwartet wird ein Objekt mit key/value Paaren.'); + error.statusCode = 400; + throw error; + } + + const entries = Object.entries(rawPatch); + if (entries.length === 0) { + return []; + } + + const db = await getDb(); + const schemaRows = await db.all('SELECT * FROM settings_schema'); + const schemaByKey = new Map(schemaRows.map((row) => [row.key, row])); + const normalizedEntries = []; + const validationErrors = []; + + for (const [key, rawValue] of entries) { + if (isImmutableSettingKey(key)) { + validationErrors.push({ + key, + message: 'Dieses Setting ist schreibgeschützt und kann nicht geändert werden.' + }); + continue; + } + + const schema = schemaByKey.get(key); + if (!schema) { + const error = new Error(`Setting ${key} existiert nicht.`); + error.statusCode = 404; + throw error; + } + + const result = validateSetting(schema, rawValue); + if (!result.valid) { + validationErrors.push({ + key, + message: result.errors.join(' ') + }); + continue; + } + + normalizedEntries.push({ + key, + value: result.normalized, + serializedValue: serializeValueByType(schema.type, result.normalized) + }); + } + + if (validationErrors.length > 0) { + const error = new Error('Mindestens ein Setting ist ungültig.'); + error.statusCode = validationErrors.some((item) => String(item?.message || '').includes('schreibgeschützt')) + ? 403 + : 400; + error.details = validationErrors; + throw error; + } + + try { + await db.exec('BEGIN'); + for (const item of normalizedEntries) { + 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 + `, + [item.key, item.serializedValue] + ); + } + const makemkvKeyChange = normalizedEntries.find( + (item) => String(item?.key || '').trim().toLowerCase() === MAKEMKV_REGISTRATION_KEY_SETTING_KEY + ); + if (makemkvKeyChange) { + await syncRegistrationKeyToConfig(makemkvKeyChange.value); + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + const logDirChange = normalizedEntries.find( + (item) => String(item?.key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY + ); + if (logDirChange) { + applyRuntimeLogDirSetting(logDirChange.value); + } + const consoleOutputChange = normalizedEntries.find( + (item) => SERVER_CONSOLE_LOG_SETTING_KEYS.has(String(item?.key || '').trim().toLowerCase()) + ); + if (consoleOutputChange) { + const map = await this.getSettingsMap({ forceRefresh: true }); + applyRuntimeServerConsoleLoggingSettingsFromMap(map); + } + + this.invalidateSettingsCache(normalizedEntries.map((item) => item.key)); + logger.info('settings:bulk-updated', { count: normalizedEntries.length }); + return normalizedEntries.map((item) => ({ + key: item.key, + value: item.value + })); + } + + async buildMakeMKVAnalyzeConfig(deviceInfo = null, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings( + rawMap, + options?.mediaProfile || deviceInfo?.mediaProfile || null + ); + const cmd = map.makemkv_command; + const extraArgs = splitArgs(map.makemkv_analyze_extra_args); + const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter); + const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim())); + const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0); + const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0 + ? Math.round(minLengthMinutes * 60) + : 0; + const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0) + ? [`--minlength=${minLengthSeconds}`] + : []; + const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', this.resolveSourceArg(map, deviceInfo)]; + logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo, disableMinLengthFilter }); + return { cmd, args }; + } + + async buildMakeMKVAnalyzePathConfig(sourcePath, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); + const cmd = map.makemkv_command; + const sourceArg = `file:${sourcePath}`; + const extraArgs = splitArgs(map.makemkv_analyze_extra_args); + const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter); + const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim())); + const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0); + const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0 + ? Math.round(minLengthMinutes * 60) + : 0; + const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0) + ? [`--minlength=${minLengthSeconds}`] + : []; + const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', sourceArg]; + const titleIdRaw = Number(options?.titleId); + // "makemkvcon info" supports only ; title filtering is done in app parser. + logger.debug('cli:makemkv:analyze:path', { + cmd, + args, + sourcePath, + disableMinLengthFilter, + requestedTitleId: Number.isFinite(titleIdRaw) && titleIdRaw >= 0 ? Math.trunc(titleIdRaw) : null + }); + return { cmd, args, sourceArg }; + } + + async buildMakeMKVRipConfig(rawJobDir, deviceInfo = null, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings( + rawMap, + options?.mediaProfile || deviceInfo?.mediaProfile || null + ); + const cmd = map.makemkv_command; + const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' + ? 'backup' + : 'mkv'; + const sourceArgOverride = String(options?.sourceArgOverride || '').trim(); + const sourceArg = sourceArgOverride || this.resolveSourceArg(map, deviceInfo); + const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId); + const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter); + const parsedExtra = splitArgs(map.makemkv_rip_extra_args); + let extra = []; + let baseArgs = []; + + if (ripMode === 'backup') { + if (parsedExtra.length > 0) { + logger.warn('cli:makemkv:rip:backup:ignored-extra-args', { + ignored: parsedExtra + }); + } + const normalizedProfile = normalizeMediaProfileValue(options?.mediaProfile || deviceInfo?.mediaProfile || null); + const isDvd = normalizedProfile === 'dvd'; + if (isDvd) { + const backupBase = options?.backupOutputBase + ? path.join(rawJobDir, options.backupOutputBase) + : rawJobDir; + baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', '--noscan', sourceArg, backupBase]; + } else { + baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', sourceArg, rawJobDir]; + } + } else { + extra = parsedExtra; + const minLength = Number(map.makemkv_min_length_minutes || 60); + const hasExplicitTitle = rawSelectedTitleId !== null; + const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all'; + const minLengthSeconds = Number.isFinite(minLength) && minLength > 0 + ? Math.round(minLength * 60) + : 0; + if (hasExplicitTitle) { + baseArgs = [ + '-r', '--progress=-same', + '--decrypt', + 'mkv', + sourceArg, + targetTitle, + rawJobDir + ]; + } else { + const minLengthArgs = (!disableMinLengthFilter && minLengthSeconds > 0) + ? [`--minlength=${minLengthSeconds}`] + : []; + baseArgs = [ + '-r', '--progress=-same', + '--decrypt', + ...minLengthArgs, + 'mkv', + sourceArg, + targetTitle, + rawJobDir + ]; + } + } + logger.debug('cli:makemkv:rip', { + cmd, + args: [...baseArgs, ...extra], + ripMode, + rawJobDir, + deviceInfo, + disableMinLengthFilter: ripMode === 'mkv' ? disableMinLengthFilter : false, + selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0 + ? Math.trunc(rawSelectedTitleId) + : null + }); + return { cmd, args: [...baseArgs, ...extra] }; + } + + async syncMakeMKVRegistrationKeyFromSettings(options = {}) { + const map = options?.settingsMap || await this.getSettingsMap(); + const registrationKey = normalizeRegistrationKey(map?.makemkv_registration_key); + const fileInfo = await syncRegistrationKeyToConfig(registrationKey, options); + return { + applied: Boolean(registrationKey), + key: registrationKey || null, + path: fileInfo?.path || null, + changed: Boolean(fileInfo?.changed) + }; + } + + async buildMediaInfoConfig(inputPath, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); + const cmd = map.mediainfo_command || 'mediainfo'; + const baseArgs = ['--Output=JSON']; + const extra = splitArgs(map.mediainfo_extra_args); + const args = [...baseArgs, ...extra, inputPath]; + logger.debug('cli:mediainfo', { cmd, args, inputPath }); + return { cmd, args }; + } + + async buildHandBrakeConfig(inputFile, outputFile, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); + const cmd = map.handbrake_command; + const rawTitleId = Number(options?.titleId); + const selectedTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0 + ? Math.trunc(rawTitleId) + : null; + const baseArgs = ['-i', inputFile, '-o', outputFile]; + if (selectedTitleId !== null) { + baseArgs.push('-t', String(selectedTitleId)); + } + + // User preset overrides settings-derived preset and extra args + const userPreset = options?.userPreset || null; + const effectiveHandbrakePreset = userPreset !== null + ? (userPreset.handbrakePreset || null) + : (map.handbrake_preset || null); + const effectiveExtraArgs = userPreset !== null + ? (userPreset.extraArgs || '') + : (map.handbrake_extra_args || ''); + + if (effectiveHandbrakePreset) { + baseArgs.push('-Z', effectiveHandbrakePreset); + } + const extra = splitArgs(effectiveExtraArgs); + const rawSelection = options?.trackSelection || null; + const hasSelection = rawSelection && typeof rawSelection === 'object'; + + if (!hasSelection) { + logger.debug('cli:handbrake', { + cmd, + args: [...baseArgs, ...extra], + inputFile, + outputFile, + selectedTitleId, + userPresetId: userPreset?.id || null + }); + return { cmd, args: [...baseArgs, ...extra] }; + } + + const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds); + const subtitleTrackIds = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false }); + const subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null; + const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null; + const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null; + const subtitleForcedTrackIndexes = normalizePositiveIndexes( + rawSelection.subtitleForcedTrackIndexes, + subtitleTrackIds.length + ); + const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly); + const filteredExtra = removeSelectionArgs(extra); + const overrideArgs = [ + '-a', + audioTrackIds.length > 0 ? audioTrackIds.join(',') : 'none', + '-s', + subtitleTrackIds.length > 0 ? subtitleTrackIds.join(',') : 'none' + ]; + if (subtitleBurnTrackId !== null) { + overrideArgs.push(`--subtitle-burned=${subtitleBurnTrackId}`); + } + if (subtitleDefaultTrackId !== null) { + overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`); + } + if (subtitleForcedTrackIndexes.length > 0) { + overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackIndexes.join(',')}`); + } else if (subtitleForcedTrackId !== null) { + overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`); + } else if (subtitleForcedOnly) { + overrideArgs.push('--subtitle-forced'); + } + const args = [...baseArgs, ...filteredExtra, ...overrideArgs]; + + logger.debug('cli:handbrake:with-selection', { + cmd, + args, + inputFile, + outputFile, + selectedTitleId, + trackSelection: { + audioTrackIds, + subtitleTrackIds, + subtitleBurnTrackId, + subtitleDefaultTrackId, + subtitleForcedTrackIndexes, + subtitleForcedTrackId, + subtitleForcedOnly + } + }); + + return { + cmd, + args, + trackSelection: { + audioTrackIds, + subtitleTrackIds, + subtitleBurnTrackId, + subtitleDefaultTrackId, + subtitleForcedTrackIndexes, + subtitleForcedTrackId, + subtitleForcedOnly + } + }; + } + + resolveHandBrakeSourceArg(map, deviceInfo = null) { + if (map.drive_mode === 'explicit') { + const entries = parseDriveDeviceEntries(map.drive_devices); + const firstPath = entries[0]?.path || String(map.drive_device || '').trim(); + if (!firstPath) { + throw new Error('Kein Laufwerk konfiguriert, obwohl drive_mode=explicit gesetzt ist.'); + } + return firstPath; + } + + const detectedPath = String(deviceInfo?.path || '').trim(); + if (detectedPath) { + return detectedPath; + } + + // Fallback: first configured device or legacy drive_device + const entries = parseDriveDeviceEntries(map.drive_devices); + if (entries.length > 0) { + return entries[0].path; + } + const configuredPath = String(map.drive_device || '').trim(); + if (configuredPath) { + return configuredPath; + } + + return '/dev/sr0'; + } + + async buildHandBrakeScanConfig(deviceInfo = null, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings( + rawMap, + options?.mediaProfile || deviceInfo?.mediaProfile || null + ); + const cmd = map.handbrake_command || 'HandBrakeCLI'; + const sourceArg = this.resolveHandBrakeSourceArg(map, deviceInfo); + // Match legacy rip.sh behavior: scan all titles, then decide in app logic. + const args = ['--scan', '--json', '-i', sourceArg]; + logger.debug('cli:handbrake:scan', { + cmd, + args, + deviceInfo + }); + return { cmd, args, sourceArg }; + } + + async buildHandBrakeScanConfigForInput(inputPath, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); + const cmd = map.handbrake_command || 'HandBrakeCLI'; + // RAW backup folders must be scanned as full BD source to get usable title list. + const rawTitleId = Number(options?.titleId); + const titleId = Number.isFinite(rawTitleId) && rawTitleId > 0 + ? Math.trunc(rawTitleId) + : 0; + const args = ['--scan', '--json', '-i', inputPath, '-t', String(titleId)]; + logger.debug('cli:handbrake:scan:input', { + cmd, + args, + inputPath, + titleId: titleId > 0 ? titleId : null + }); + return { cmd, args, sourceArg: inputPath }; + } + + async buildHandBrakePresetProfile(sampleInputPath = null, options = {}) { + const rawMap = options?.settingsMap || await this.getSettingsMap(); + const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null); + const cmd = map.handbrake_command || 'HandBrakeCLI'; + const presetName = map.handbrake_preset || null; + const rawTitleId = Number(options?.titleId); + const presetScanTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0 + ? Math.trunc(rawTitleId) + : 1; + + if (!presetName) { + return buildFallbackPresetProfile(null, 'Kein HandBrake-Preset konfiguriert.'); + } + + if (!sampleInputPath || !fs.existsSync(sampleInputPath)) { + return buildFallbackPresetProfile( + presetName, + 'Preset-Export übersprungen: kein gültiger Sample-Input für HandBrake-Scan.' + ); + } + + const exportName = `ripster-export-${Date.now()}-${Math.floor(Math.random() * 10000)}`; + fs.mkdirSync(DEFAULT_TEMP_DIR, { recursive: true }); + const exportFile = path.join(DEFAULT_TEMP_DIR, `${exportName}.json`); + const args = [ + '--scan', + '-i', + sampleInputPath, + '-t', + String(presetScanTitleId), + '-Z', + presetName, + '--preset-export', + exportName, + '--preset-export-file', + exportFile + ]; + + try { + const result = spawnSync(cmd, args, { + encoding: 'utf-8', + timeout: 180000, + maxBuffer: 10 * 1024 * 1024 + }); + + if (result.error) { + return buildFallbackPresetProfile( + presetName, + `Preset-Export fehlgeschlagen: ${result.error.message}` + ); + } + + if (result.status !== 0) { + const stderr = String(result.stderr || '').trim(); + const stdout = String(result.stdout || '').trim(); + const tail = stderr || stdout || `exit=${result.status}`; + return buildFallbackPresetProfile( + presetName, + `Preset-Export fehlgeschlagen (${tail.slice(0, 280)})` + ); + } + + if (!fs.existsSync(exportFile)) { + return buildFallbackPresetProfile( + presetName, + 'Preset-Export fehlgeschlagen: Exportdatei wurde nicht erzeugt.' + ); + } + + const raw = fs.readFileSync(exportFile, 'utf-8'); + const parsed = JSON.parse(raw); + const presetEntries = flattenPresetList(parsed?.PresetList || []); + const exported = presetEntries.find((entry) => entry.PresetName === exportName) || presetEntries[0]; + + if (!exported) { + return buildFallbackPresetProfile( + presetName, + 'Preset-Export fehlgeschlagen: Kein Preset in Exportdatei gefunden.' + ); + } + + return { + source: 'preset-export', + message: null, + presetName, + audioTrackSelectionBehavior: exported.AudioTrackSelectionBehavior || 'first', + audioLanguages: Array.isArray(exported.AudioLanguageList) ? exported.AudioLanguageList : [], + audioEncoders: Array.isArray(exported.AudioList) + ? exported.AudioList + .map((item) => item?.AudioEncoder) + .filter(Boolean) + : [], + audioCopyMask: Array.isArray(exported.AudioCopyMask) + ? exported.AudioCopyMask + : DEFAULT_AUDIO_COPY_MASK, + audioFallback: exported.AudioEncoderFallback || 'av_aac', + subtitleTrackSelectionBehavior: exported.SubtitleTrackSelectionBehavior || 'none', + subtitleLanguages: Array.isArray(exported.SubtitleLanguageList) ? exported.SubtitleLanguageList : [], + subtitleBurnBehavior: exported.SubtitleBurnBehavior || 'none' + }; + } catch (error) { + return buildFallbackPresetProfile( + presetName, + `Preset-Export Ausnahme: ${error.message}` + ); + } finally { + try { + if (fs.existsSync(exportFile)) { + fs.unlinkSync(exportFile); + } + } catch (_error) { + // ignore cleanup errors + } + } + } + + resolveSourceArg(map, deviceInfo = null) { + const devicePath = String(deviceInfo?.path || '').trim(); + const deviceIndex = Number(deviceInfo?.makemkvIndex ?? deviceInfo?.index); + const configuredEntries = parseDriveDeviceEntries(map.drive_devices); + + // In explicit mode: look up per-drive makemkvIndex from drive_devices + if (devicePath && map.drive_mode === 'explicit') { + const entry = configuredEntries.find((e) => e.path === devicePath); + if (entry) { + return `disc:${entry.makemkvIndex}`; + } + } + + // Prefer configured per-drive index when a path match exists (also in auto mode). + if (devicePath) { + const configured = configuredEntries.find((e) => e.path === devicePath); + if (configured) { + return `disc:${configured.makemkvIndex}`; + } + } + + // Prefer device-provided MakeMKV index from disk detection service. + if (Number.isFinite(deviceIndex) && deviceIndex >= 0) { + return `disc:${Math.trunc(deviceIndex)}`; + } + + // Last automatic fallback: derive from device name (/dev/sr0 → disc:0). + if (devicePath) { + const match = devicePath.match(/sr(\d+)$/); + if (match) { + return `disc:${match[1]}`; + } + } + + // Fall back to global makemkv_source_index setting + const sourceIndex = Number(map.makemkv_source_index ?? 0); + return `disc:${Number.isFinite(sourceIndex) && sourceIndex >= 0 ? Math.trunc(sourceIndex) : 0}`; + } + + async loadHandBrakePresetOptionsFromCli(map = {}) { + const configuredPresets = uniqueOrderedValues([ + map.handbrake_preset_bluray, + map.handbrake_preset_dvd, + map.handbrake_preset + ]); + const fallbackOptions = configuredPresets.map((preset) => ({ label: preset, value: preset })); + const rawCommand = String(map.handbrake_command || 'HandBrakeCLI').trim(); + const commandTokens = splitArgs(rawCommand); + const cmd = commandTokens[0] || 'HandBrakeCLI'; + const baseArgs = commandTokens.slice(1); + const args = [...baseArgs, '-z']; + + try { + const result = await runCommandCapture(cmd, args, { + timeout: HANDBRAKE_PRESET_LIST_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024 + }); + + if (result.timedOut) { + return { + source: 'fallback', + message: 'Preset-Liste konnte nicht geladen werden (Timeout).', + options: fallbackOptions + }; + } + + if (Number(result.status) !== 0) { + const stderr = String(result.stderr || '').trim(); + const stdout = String(result.stdout || '').trim(); + const detail = (stderr || stdout || `exit=${result.status}`).slice(0, 280); + return { + source: 'fallback', + message: `Preset-Liste konnte nicht geladen werden (${detail})`, + options: fallbackOptions + }; + } + + const combinedOutput = `${String(result.stdout || '')}\n${String(result.stderr || '')}`; + const entries = parseHandBrakePresetEntriesFromListOutput(combinedOutput); + const options = mapPresetEntriesToOptions(entries); + if (options.length === 0) { + return { + source: 'fallback', + message: 'Preset-Liste konnte aus HandBrakeCLI -z nicht geparst werden.', + options: fallbackOptions + }; + } + if (configuredPresets.length === 0) { + return { + source: 'handbrake-cli', + message: null, + options + }; + } + + const missingConfiguredPresets = configuredPresets.filter( + (preset) => !options.some((option) => option.value === preset) + ); + if (missingConfiguredPresets.length === 0) { + return { + source: 'handbrake-cli', + message: null, + options + }; + } + + return { + source: 'handbrake-cli', + message: `Konfigurierte Presets wurden in HandBrakeCLI -z nicht gefunden: ${missingConfiguredPresets.join(', ')}`, + options: [ + ...missingConfiguredPresets.map((preset) => ({ label: preset, value: preset })), + ...options + ] + }; + } catch (error) { + return { + source: 'fallback', + message: `Preset-Liste konnte nicht geladen werden: ${error.message}`, + options: fallbackOptions + }; + } + } + + async refreshHandBrakePresetCache(map = null, cacheKey = null) { + const resolvedMap = map && typeof map === 'object' + ? map + : await this.getSettingsMap(); + const resolvedCacheKey = String(cacheKey || this.buildHandBrakePresetCacheKey(resolvedMap)); + this.handBrakePresetCache.cacheKey = resolvedCacheKey; + + let loadPromise = null; + loadPromise = this.loadHandBrakePresetOptionsFromCli(resolvedMap) + .then((payload) => { + this.handBrakePresetCache.payload = payload; + this.handBrakePresetCache.cacheKey = resolvedCacheKey; + this.handBrakePresetCache.expiresAt = Date.now() + HANDBRAKE_PRESET_CACHE_TTL_MS; + return payload; + }) + .finally(() => { + if (this.handBrakePresetCache.inFlight === loadPromise) { + this.handBrakePresetCache.inFlight = null; + } + }); + this.handBrakePresetCache.inFlight = loadPromise; + return loadPromise; + } + + async getHandBrakePresetOptions(options = {}) { + const forceRefresh = Boolean(options?.forceRefresh); + const map = options?.settingsMap && typeof options.settingsMap === 'object' + ? options.settingsMap + : await this.getSettingsMap(); + const cacheKey = this.buildHandBrakePresetCacheKey(map); + const now = Date.now(); + + if ( + !forceRefresh + && this.handBrakePresetCache.payload + && this.handBrakePresetCache.cacheKey === cacheKey + && this.handBrakePresetCache.expiresAt > now + ) { + return this.handBrakePresetCache.payload; + } + + if ( + !forceRefresh + && this.handBrakePresetCache.payload + && this.handBrakePresetCache.cacheKey === cacheKey + ) { + if (!this.handBrakePresetCache.inFlight) { + void this.refreshHandBrakePresetCache(map, cacheKey); + } + return this.handBrakePresetCache.payload; + } + + if (this.handBrakePresetCache.inFlight && this.handBrakePresetCache.cacheKey === cacheKey && !forceRefresh) { + return this.handBrakePresetCache.inFlight; + } + + return this.refreshHandBrakePresetCache(map, cacheKey); + } +} + +const settingsServiceInstance = new SettingsService(); +settingsServiceInstance.DEFAULT_RAW_DIR = DEFAULT_RAW_DIR; +settingsServiceInstance.DEFAULT_MOVIE_DIR = DEFAULT_MOVIE_DIR; +settingsServiceInstance.DEFAULT_CD_DIR = DEFAULT_CD_DIR; +settingsServiceInstance.DEFAULT_AUDIOBOOK_RAW_DIR = DEFAULT_AUDIOBOOK_RAW_DIR; +settingsServiceInstance.DEFAULT_AUDIOBOOK_DIR = DEFAULT_AUDIOBOOK_DIR; +module.exports = settingsServiceInstance; diff --git a/backend/src/services/tempCleanupService.js b/backend/src/services/tempCleanupService.js new file mode 100644 index 0000000..36901b8 --- /dev/null +++ b/backend/src/services/tempCleanupService.js @@ -0,0 +1,157 @@ +const fs = require('fs'); +const path = require('path'); +const logger = require('./logger').child('TEMP_CLEANUP'); +const { tempDir } = require('../config'); + +const TMP_ROOT = tempDir; +const SWEEP_INTERVAL_MS = 60 * 60 * 1000; +const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000; + +const TOP_LEVEL_PREFIXES = [ + 'ripster-merge-', + 'ripster-script-', + 'ripster-export-' +]; + +const MANAGED_UPLOAD_DIRS = [ + 'ripster-converter-uploads', + 'ripster-audiobook-uploads' +]; + +function getEntryAgeMs(stats) { + const referenceTime = Math.max( + Number(stats?.mtimeMs) || 0, + Number(stats?.ctimeMs) || 0, + Number(stats?.birthtimeMs) || 0 + ); + return Date.now() - referenceTime; +} + +function isStale(stats, minAgeMs = STALE_ENTRY_MIN_AGE_MS) { + return getEntryAgeMs(stats) >= minAgeMs; +} + +function removeEntry(targetPath, stats) { + const isDirectory = stats?.isDirectory?.() || false; + fs.rmSync(targetPath, { + recursive: isDirectory, + force: true + }); +} + +class TempCleanupService { + constructor() { + this.interval = null; + } + + async init() { + await this.runSweep('startup'); + + if (!this.interval) { + this.interval = setInterval(() => { + this.runSweep('interval').catch((error) => { + logger.warn('temp:sweep:interval-failed', { error: error?.message || String(error) }); + }); + }, SWEEP_INTERVAL_MS); + this.interval.unref?.(); + } + } + + stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + async runSweep(reason = 'manual') { + fs.mkdirSync(TMP_ROOT, { recursive: true }); + const deleted = []; + const skipped = []; + const failed = []; + + let topLevelEntries = []; + try { + topLevelEntries = fs.readdirSync(TMP_ROOT, { withFileTypes: true }); + } catch (error) { + logger.warn('temp:sweep:readdir-failed', { + reason, + tmpRoot: TMP_ROOT, + error: error?.message || String(error) + }); + return { deleted, skipped, failed }; + } + + for (const entry of topLevelEntries) { + const entryName = String(entry?.name || '').trim(); + if (!entryName || !TOP_LEVEL_PREFIXES.some((prefix) => entryName.startsWith(prefix))) { + continue; + } + const targetPath = path.join(TMP_ROOT, entryName); + try { + const stats = fs.lstatSync(targetPath); + if (!isStale(stats)) { + skipped.push({ path: targetPath, reason: 'fresh-top-level-entry' }); + continue; + } + removeEntry(targetPath, stats); + deleted.push(targetPath); + } catch (error) { + failed.push({ + path: targetPath, + error: error?.message || String(error) + }); + } + } + + for (const dirName of MANAGED_UPLOAD_DIRS) { + const uploadDir = path.join(TMP_ROOT, dirName); + if (!fs.existsSync(uploadDir)) { + continue; + } + + let uploadEntries = []; + try { + uploadEntries = fs.readdirSync(uploadDir, { withFileTypes: true }); + } catch (error) { + failed.push({ + path: uploadDir, + error: error?.message || String(error) + }); + continue; + } + + for (const entry of uploadEntries) { + const targetPath = path.join(uploadDir, entry.name); + try { + const stats = fs.lstatSync(targetPath); + if (!isStale(stats)) { + skipped.push({ path: targetPath, reason: 'fresh-upload-entry' }); + continue; + } + removeEntry(targetPath, stats); + deleted.push(targetPath); + } catch (error) { + failed.push({ + path: targetPath, + error: error?.message || String(error) + }); + } + } + } + + logger.info('temp:sweep:completed', { + reason, + tmpRoot: TMP_ROOT, + deletedCount: deleted.length, + skippedCount: skipped.length, + failedCount: failed.length, + deleted: deleted.slice(0, 50), + failed: failed.slice(0, 20) + }); + + return { deleted, skipped, failed }; + } +} + +module.exports = new TempCleanupService(); diff --git a/backend/src/services/thumbnailService.js b/backend/src/services/thumbnailService.js new file mode 100644 index 0000000..67d2907 --- /dev/null +++ b/backend/src/services/thumbnailService.js @@ -0,0 +1,316 @@ +'use strict'; + +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { dataDir } = require('../config'); +const { getDb } = require('../db/database'); +const logger = require('./logger').child('THUMBNAIL'); + +const THUMBNAILS_DIR = path.join(dataDir, 'thumbnails'); +const CACHE_DIR = path.join(THUMBNAILS_DIR, 'cache'); +const MAX_REDIRECTS = 5; + +function ensureDirs() { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.mkdirSync(THUMBNAILS_DIR, { recursive: true }); +} + +function cacheFilePath(jobId) { + return path.join(CACHE_DIR, `job-${jobId}.jpg`); +} + +function persistentFilePath(jobId) { + return path.join(THUMBNAILS_DIR, `job-${jobId}.jpg`); +} + +function localUrl(jobId) { + return `/api/thumbnails/job-${jobId}.jpg`; +} + +function isLocalUrl(url) { + return typeof url === 'string' && url.startsWith('/api/thumbnails/'); +} + +function resolveLocalThumbnailPath(url) { + const raw = String(url || '').trim(); + const match = raw.match(/^\/api\/thumbnails\/job-(\d+)\.jpg$/i); + if (!match) { + return null; + } + const jobId = Number(match[1]); + if (!Number.isFinite(jobId) || jobId <= 0) { + return null; + } + return persistentFilePath(Math.trunc(jobId)); +} + +function localThumbnailUrlExists(url) { + const targetPath = resolveLocalThumbnailPath(url); + if (!targetPath) { + return false; + } + try { + return fs.existsSync(targetPath); + } catch (_error) { + return false; + } +} + +function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) { + return new Promise((resolve, reject) => { + if (redirectsLeft <= 0) { + return reject(new Error('Zu viele Weiterleitungen beim Bild-Download')); + } + + const proto = url.startsWith('https') ? https : http; + const file = fs.createWriteStream(destPath); + + const cleanup = () => { + try { file.destroy(); } catch (_) {} + try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {} + }; + + proto.get(url, { timeout: 15000 }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + file.close(() => { + try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {} + downloadImage(res.headers.location, destPath, redirectsLeft - 1).then(resolve).catch(reject); + }); + return; + } + + if (res.statusCode !== 200) { + res.resume(); + cleanup(); + return reject(new Error(`HTTP ${res.statusCode} beim Bild-Download`)); + } + + res.pipe(file); + file.on('finish', () => file.close(() => resolve())); + file.on('error', (err) => { cleanup(); reject(err); }); + }).on('error', (err) => { + cleanup(); + reject(err); + }).on('timeout', function () { + this.destroy(); + cleanup(); + reject(new Error('Timeout beim Bild-Download')); + }); + }); +} + +/** + * Lädt das Bild einer extern-URL in den Cache herunter. + * Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start). + * @returns {Promise} lokaler Pfad oder null + */ +async function cacheJobThumbnailDetailed(jobId, posterUrl) { + const normalizedPosterUrl = String(posterUrl || '').trim(); + if (!normalizedPosterUrl || isLocalUrl(normalizedPosterUrl)) { + return { + ok: false, + jobId, + posterUrl: normalizedPosterUrl || null, + cachedPath: null, + error: 'Kein externer Poster-Link vorhanden.' + }; + } + + try { + ensureDirs(); + const dest = cacheFilePath(jobId); + await downloadImage(normalizedPosterUrl, dest); + logger.info('thumbnail:cached', { jobId, posterUrl: normalizedPosterUrl, dest }); + return { + ok: true, + jobId, + posterUrl: normalizedPosterUrl, + cachedPath: dest, + error: null + }; + } catch (err) { + const message = String(err?.message || err || 'Bild-Download fehlgeschlagen'); + logger.warn('thumbnail:cache:failed', { jobId, posterUrl: normalizedPosterUrl, error: message }); + return { + ok: false, + jobId, + posterUrl: normalizedPosterUrl, + cachedPath: null, + error: message + }; + } +} + +async function cacheJobThumbnail(jobId, posterUrl) { + const result = await cacheJobThumbnailDetailed(jobId, posterUrl); + return result?.ok ? result.cachedPath : null; +} + +/** + * Verschiebt das gecachte Bild in den persistenten Ordner. + * Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden. + * Wird nach erfolgreichem Rip aufgerufen. + * @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null + */ +function promoteJobThumbnail(jobId) { + try { + ensureDirs(); + const src = cacheFilePath(jobId); + const dest = persistentFilePath(jobId); + + if (fs.existsSync(src)) { + fs.renameSync(src, dest); + logger.info('thumbnail:promoted', { jobId, dest }); + return localUrl(jobId); + } + + // Falls kein Cache vorhanden, aber persistente Datei schon existiert + if (fs.existsSync(dest)) { + return localUrl(jobId); + } + + logger.warn('thumbnail:promote:no-source', { jobId }); + return null; + } catch (err) { + logger.warn('thumbnail:promote:failed', { jobId, error: err.message }); + return null; + } +} + +/** + * Gibt den Pfad zum persistenten Thumbnail-Ordner zurück (für Static-Serving). + */ +function getThumbnailsDir() { + return THUMBNAILS_DIR; +} + +/** + * Kopiert das persistente Thumbnail von sourceJobId zu targetJobId. + * Wird bei Rip-Neustart genutzt, damit der neue Job ein eigenes Bild hat + * und nicht auf die Datei des alten Jobs angewiesen ist. + * @returns {string|null} neue lokale URL oder null + */ +function copyThumbnail(sourceJobId, targetJobId) { + try { + const src = persistentFilePath(sourceJobId); + if (!fs.existsSync(src)) return null; + ensureDirs(); + const dest = persistentFilePath(targetJobId); + fs.copyFileSync(src, dest); + logger.info('thumbnail:copied', { sourceJobId, targetJobId }); + return localUrl(targetJobId); + } catch (err) { + logger.warn('thumbnail:copy:failed', { sourceJobId, targetJobId, error: err.message }); + return null; + } +} + +/** + * Speichert ein lokal extrahiertes Bild als persistentes Job-Thumbnail. + * @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null + */ +function storeLocalThumbnail(jobId, sourcePath) { + try { + const src = String(sourcePath || '').trim(); + if (!src || !fs.existsSync(src)) { + return null; + } + ensureDirs(); + const dest = persistentFilePath(jobId); + fs.copyFileSync(src, dest); + logger.info('thumbnail:stored-local', { jobId, sourcePath: src, dest }); + return localUrl(jobId); + } catch (err) { + logger.warn('thumbnail:store-local:failed', { jobId, sourcePath, error: err.message }); + return null; + } +} + +/** + * Löscht Cache- und persistente Thumbnail-Datei eines Jobs. + * Wird beim Löschen eines Jobs aufgerufen. + */ +function deleteThumbnail(jobId) { + for (const filePath of [persistentFilePath(jobId), cacheFilePath(jobId)]) { + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch (err) { + logger.warn('thumbnail:delete:failed', { jobId, filePath, error: err.message }); + } + } +} + +/** + * Migriert bestehende Jobs: lädt alle externen poster_url-Bilder herunter + * und speichert sie lokal. Läuft beim Start im Hintergrund, sequenziell + * mit kurzem Delay um externe Server nicht zu überlasten. + */ +async function migrateExistingThumbnails() { + try { + ensureDirs(); + const db = await getDb(); + + // Alle abgeschlossenen Jobs mit externer poster_url, die noch kein lokales Bild haben + const jobs = await db.all( + `SELECT id, poster_url FROM jobs + WHERE rip_successful = 1 + AND poster_url IS NOT NULL + AND poster_url != '' + AND poster_url NOT LIKE '/api/thumbnails/%' + ORDER BY id ASC` + ); + + if (!jobs.length) { + logger.info('thumbnail:migrate:nothing-to-do'); + return; + } + + logger.info('thumbnail:migrate:start', { count: jobs.length }); + let succeeded = 0; + let failed = 0; + + for (const job of jobs) { + // Persistente Datei bereits vorhanden? Dann nur DB aktualisieren. + const dest = persistentFilePath(job.id); + if (fs.existsSync(dest)) { + await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]); + succeeded++; + continue; + } + + try { + await downloadImage(job.poster_url, dest); + await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]); + logger.info('thumbnail:migrate:ok', { jobId: job.id }); + succeeded++; + } catch (err) { + logger.warn('thumbnail:migrate:failed', { jobId: job.id, url: job.poster_url, error: err.message }); + failed++; + } + + // Kurze Pause zwischen Downloads (externe Server schonen) + await new Promise((r) => setTimeout(r, 300)); + } + + logger.info('thumbnail:migrate:done', { succeeded, failed, total: jobs.length }); + } catch (err) { + logger.error('thumbnail:migrate:error', { error: err.message }); + } +} + +module.exports = { + cacheJobThumbnail, + cacheJobThumbnailDetailed, + promoteJobThumbnail, + copyThumbnail, + storeLocalThumbnail, + deleteThumbnail, + getThumbnailsDir, + migrateExistingThumbnails, + isLocalUrl, + resolveLocalThumbnailPath, + localThumbnailUrlExists +}; diff --git a/backend/src/services/tmdbService.js b/backend/src/services/tmdbService.js new file mode 100644 index 0000000..766a1cf --- /dev/null +++ b/backend/src/services/tmdbService.js @@ -0,0 +1,871 @@ +'use strict'; + +const settingsService = require('./settingsService'); +const logger = require('./logger').child('TMDB'); + +const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; +const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p'; +const TMDB_TIMEOUT_MS = 15000; + +class TmdbService { + hasCreditsPayload(details = null) { + const credits = details?.credits && typeof details.credits === 'object' + ? details.credits + : null; + if (!credits) { + return false; + } + const crew = Array.isArray(credits.crew) ? credits.crew : []; + const cast = Array.isArray(credits.cast) ? credits.cast : []; + return crew.length > 0 || cast.length > 0; + } + + mergeCreditsIntoDetails(details = null, credits = null) { + const sourceDetails = details && typeof details === 'object' ? details : {}; + const sourceCredits = credits && typeof credits === 'object' ? credits : null; + if (!sourceCredits) { + return sourceDetails; + } + return { + ...sourceDetails, + credits: { + ...(sourceDetails?.credits && typeof sourceDetails.credits === 'object' ? sourceDetails.credits : {}), + ...(sourceCredits && typeof sourceCredits === 'object' ? sourceCredits : {}) + } + }; + } + + extractDirectorNames(crewValues = []) { + const crew = Array.isArray(crewValues) ? crewValues : []; + const byJob = this.normalizeNameList( + crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'), + { maxItems: 5 } + ); + if (byJob.length > 0) { + return byJob; + } + // Fallback for edge cases where "Director" job is missing in localized/partial payloads. + return this.normalizeNameList( + crew.filter((member) => String(member?.department || '').trim().toLowerCase() === 'directing'), + { maxItems: 5 } + ); + } + + isAbortError(error) { + const name = String(error?.name || '').trim().toLowerCase(); + const message = String(error?.message || '').trim().toLowerCase(); + return name === 'aborterror' || message.includes('aborted'); + } + + classifyRequestError(error) { + if (this.isAbortError(error)) { + return 'timeout'; + } + const statusCode = Number(error?.statusCode || 0) || null; + if (statusCode === 401 || statusCode === 403) { + return 'auth'; + } + if (statusCode >= 500) { + return 'upstream'; + } + if (statusCode >= 400) { + return 'request_failed'; + } + return 'network'; + } + + readFailureCode(rows) { + const value = rows && typeof rows.tmdbFailureCode === 'string' + ? rows.tmdbFailureCode + : ''; + const normalized = String(value || '').trim().toLowerCase(); + return normalized || null; + } + + attachFailureCode(rows, failureCode = null) { + const output = Array.isArray(rows) ? rows : []; + const normalized = String(failureCode || '').trim().toLowerCase(); + if (!normalized) { + return output; + } + try { + Object.defineProperty(output, 'tmdbFailureCode', { + value: normalized, + enumerable: false, + configurable: true + }); + } catch (_error) { + output.tmdbFailureCode = normalized; + } + return output; + } + + normalizeNameList(values = [], options = {}) { + const maxItems = Math.max(1, Number(options.maxItems || 10)); + const source = Array.isArray(values) ? values : []; + const output = []; + const seen = new Set(); + for (const item of source) { + const name = String(item?.name || item || '').trim(); + if (!name) { + continue; + } + const key = name.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(name); + if (output.length >= maxItems) { + break; + } + } + return output; + } + + formatRuntimeLabel(value) { + if (Array.isArray(value)) { + const values = value + .map((entry) => Number(entry)) + .filter((entry) => Number.isFinite(entry) && entry > 0) + .map((entry) => Math.trunc(entry)); + if (values.length === 0) { + return null; + } + const min = Math.min(...values); + const max = Math.max(...values); + return min === max ? `${min} min` : `${min}-${max} min`; + } + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return `${Math.trunc(numeric)} min`; + } + const text = String(value || '').trim(); + return text || null; + } + + async getConfig() { + const settings = await settingsService.getSettingsMap(); + const fallbackLanguages = this.parseLanguageList(settings.dvd_series_fallback_languages); + return { + readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null, + language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE', + fallbackLanguages + }; + } + + async isConfigured() { + const config = await this.getConfig(); + return Boolean(config.readAccessToken); + } + + async resolveLanguage(explicitLanguage = null) { + const config = await this.getConfig(); + return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE'; + } + + parseLanguageList(value) { + const source = Array.isArray(value) + ? value + : String(value || '').split(','); + const normalized = []; + const seen = new Set(); + for (const item of source) { + const lang = String(item || '').trim(); + if (!lang) { + continue; + } + const dedupeKey = lang.toLowerCase(); + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + normalized.push(lang); + } + return normalized; + } + + buildLanguageCandidates(primaryLanguage = null, fallbackLanguages = []) { + const preferred = String(primaryLanguage || '').trim() || 'de-DE'; + const fallback = this.parseLanguageList(fallbackLanguages); + const combined = [preferred, ...fallback]; + return this.parseLanguageList(combined); + } + + async resolveLanguageCandidates(options = {}) { + const config = await this.getConfig(); + const explicitLanguage = String(options.language || '').trim() || null; + const explicitFallbackLanguages = options.fallbackLanguages !== undefined + ? this.parseLanguageList(options.fallbackLanguages) + : null; + const primaryLanguage = explicitLanguage || String(config.language || '').trim() || 'de-DE'; + const fallbackLanguages = explicitFallbackLanguages ?? config.fallbackLanguages; + return this.buildLanguageCandidates(primaryLanguage, fallbackLanguages); + } + + async request(pathName, options = {}) { + const config = await this.getConfig(); + if (!config.readAccessToken) { + return null; + } + + const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const normalizedPath = String(pathName || '').replace(/^\/+/, ''); + const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`); + if (options.query && typeof options.query === 'object') { + for (const [key, value] of Object.entries(options.query)) { + if (value === undefined || value === null || value === '') { + continue; + } + url.searchParams.set(key, String(value)); + } + } + + const response = await fetch(url, { + method: options.method || 'GET', + headers: { + 'accept': 'application/json', + 'Authorization': `Bearer ${config.readAccessToken}`, + 'User-Agent': 'Ripster/1.0' + }, + signal: controller.signal + }); + + if (!response.ok) { + const error = new Error(`TMDb request failed (${response.status})`); + error.statusCode = response.status; + error.url = url.toString(); + throw error; + } + + return response.json(); + } finally { + clearTimeout(timer); + } + } + + buildImageUrl(imagePath, size = 'w342') { + const normalizedPath = String(imagePath || '').trim(); + if (!normalizedPath) { + return null; + } + return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`; + } + + async searchSeries(query, options = {}) { + const normalizedQuery = String(query || '').trim(); + if (!normalizedQuery || !(await this.isConfigured())) { + return []; + } + const languageCandidates = await this.resolveLanguageCandidates(options); + let lastFailureCode = null; + let lastFailureStatusCode = null; + let lastFailureMessage = null; + + for (const language of languageCandidates) { + let data = null; + try { + data = await this.request('/search/tv', { + query: { + query: normalizedQuery, + first_air_date_year: options.year || undefined, + language, + page: options.page || 1, + include_adult: false + } + }); + } catch (error) { + const failureCode = this.classifyRequestError(error); + lastFailureCode = failureCode; + lastFailureStatusCode = Number(error?.statusCode || 0) || null; + lastFailureMessage = error?.message || String(error); + logger.warn('search:failed', { + query: normalizedQuery, + language, + error: lastFailureMessage, + failureCode, + statusCode: lastFailureStatusCode + }); + continue; + } + + const rows = Array.isArray(data?.results) ? data.results : []; + const normalizedRows = rows + .map((row) => ({ + id: Number(row?.id || 0) || null, + title: String(row?.name || row?.original_name || '').trim() || null, + originalTitle: String(row?.original_name || '').trim() || null, + year: Number(String(row?.first_air_date || '').slice(0, 4)) || null, + overview: String(row?.overview || '').trim() || null, + posterPath: String(row?.poster_path || '').trim() || null, + poster: this.buildImageUrl(row?.poster_path, 'w342'), + backdropPath: String(row?.backdrop_path || '').trim() || null, + backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'), + originalLanguage: String(row?.original_language || '').trim() || null, + popularity: Number(row?.popularity || 0) || 0 + })) + .filter((row) => row.id && row.title); + if (normalizedRows.length > 0) { + return this.attachFailureCode(normalizedRows, null); + } + } + if (lastFailureCode) { + logger.warn('search:failed:all-languages', { + query: normalizedQuery, + error: lastFailureMessage, + failureCode: lastFailureCode, + statusCode: lastFailureStatusCode + }); + } + return this.attachFailureCode([], lastFailureCode); + } + + async searchMovies(query, options = {}) { + const normalizedQuery = String(query || '').trim(); + if (!normalizedQuery || !(await this.isConfigured())) { + return []; + } + const languageCandidates = await this.resolveLanguageCandidates(options); + let lastFailureCode = null; + let lastFailureStatusCode = null; + let lastFailureMessage = null; + + for (const language of languageCandidates) { + let data = null; + try { + data = await this.request('/search/movie', { + query: { + query: normalizedQuery, + year: options.year || undefined, + language, + page: options.page || 1, + include_adult: false + } + }); + } catch (error) { + const failureCode = this.classifyRequestError(error); + lastFailureCode = failureCode; + lastFailureStatusCode = Number(error?.statusCode || 0) || null; + lastFailureMessage = error?.message || String(error); + logger.warn('movie:search:failed', { + query: normalizedQuery, + language, + error: lastFailureMessage, + failureCode, + statusCode: lastFailureStatusCode + }); + continue; + } + + const rows = Array.isArray(data?.results) ? data.results : []; + const normalizedRows = rows + .map((row) => ({ + id: Number(row?.id || 0) || null, + title: String(row?.title || row?.original_title || '').trim() || null, + originalTitle: String(row?.original_title || '').trim() || null, + year: Number(String(row?.release_date || '').slice(0, 4)) || null, + overview: String(row?.overview || '').trim() || null, + posterPath: String(row?.poster_path || '').trim() || null, + poster: this.buildImageUrl(row?.poster_path, 'w342'), + backdropPath: String(row?.backdrop_path || '').trim() || null, + backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'), + originalLanguage: String(row?.original_language || '').trim() || null, + popularity: Number(row?.popularity || 0) || 0 + })) + .filter((row) => row.id && row.title); + if (normalizedRows.length > 0) { + return this.attachFailureCode(normalizedRows, null); + } + } + if (lastFailureCode) { + logger.warn('movie:search:failed:all-languages', { + query: normalizedQuery, + error: lastFailureMessage, + failureCode: lastFailureCode, + statusCode: lastFailureStatusCode + }); + } + return this.attachFailureCode([], lastFailureCode); + } + + async getSeriesDetails(seriesId, options = {}) { + const id = Number(seriesId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return null; + } + const languageCandidates = await this.resolveLanguageCandidates(options); + const appendToResponse = Array.isArray(options.appendToResponse) + ? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean) + : []; + let fallbackDetails = null; + for (const language of languageCandidates) { + const details = await this.request(`/tv/${Math.trunc(id)}`, { + query: { + language, + append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined + } + }).catch((error) => { + logger.warn('series:details:failed', { + seriesId: Math.trunc(id), + language, + error: error?.message || String(error) + }); + return null; + }); + if (!details) { + continue; + } + const hasLocalizedPayload = Boolean( + String(details?.name || '').trim() + || String(details?.overview || '').trim() + ); + if (hasLocalizedPayload) { + return details; + } + fallbackDetails = fallbackDetails || details; + } + return fallbackDetails; + } + + async getMovieDetails(movieId, options = {}) { + const id = Number(movieId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return null; + } + const languageCandidates = await this.resolveLanguageCandidates(options); + const appendToResponse = Array.isArray(options.appendToResponse) + ? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean) + : []; + let fallbackDetails = null; + for (const language of languageCandidates) { + const details = await this.request(`/movie/${Math.trunc(id)}`, { + query: { + language, + append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined + } + }).catch((error) => { + logger.warn('movie:details:failed', { + movieId: Math.trunc(id), + language, + error: error?.message || String(error) + }); + return null; + }); + if (!details) { + continue; + } + const hasLocalizedPayload = Boolean( + String(details?.title || '').trim() + || String(details?.overview || '').trim() + ); + if (hasLocalizedPayload) { + return details; + } + fallbackDetails = fallbackDetails || details; + } + return fallbackDetails; + } + + async getMovieCredits(movieId, options = {}) { + const id = Number(movieId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/movie/${Math.trunc(id)}/credits`, { + query: { + language + } + }).catch((error) => { + logger.warn('movie:credits:failed', { + movieId: Math.trunc(id), + error: error?.message || String(error) + }); + return null; + }); + } + + async getMovieDetailsWithCredits(movieId, options = {}) { + const details = await this.getMovieDetails(movieId, options); + if (!details) { + return null; + } + const ensureCredits = options?.ensureCredits === true; + if (!ensureCredits || this.hasCreditsPayload(details)) { + return details; + } + const credits = await this.getMovieCredits(movieId, options); + return this.mergeCreditsIntoDetails(details, credits); + } + + async getEpisodeGroups(seriesId) { + const id = Number(seriesId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return []; + } + const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => { + logger.warn('series:episode-groups:failed', { + seriesId: Math.trunc(id), + error: error?.message || String(error) + }); + return null; + }); + return Array.isArray(response?.results) ? response.results : []; + } + + async getEpisodeGroupDetails(groupId, options = {}) { + const normalizedId = String(groupId || '').trim(); + if (!normalizedId || !(await this.isConfigured())) { + return null; + } + const languageCandidates = await this.resolveLanguageCandidates(options); + let fallbackDetails = null; + for (const language of languageCandidates) { + const details = await this.request(`/tv/episode_group/${normalizedId}`, { + query: { + language + } + }).catch((error) => { + logger.warn('episode-group:details:failed', { + groupId: normalizedId, + language, + error: error?.message || String(error) + }); + return null; + }); + if (!details) { + continue; + } + const hasLocalizedPayload = Boolean(String(details?.name || '').trim() || String(details?.description || '').trim()); + if (hasLocalizedPayload) { + return details; + } + fallbackDetails = fallbackDetails || details; + } + return fallbackDetails; + } + + async getSeasonDetails(seriesId, seasonNumber, options = {}) { + const id = Number(seriesId); + const season = Number(seasonNumber); + if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) { + return null; + } + const languageCandidates = await this.resolveLanguageCandidates(options); + let fallbackDetails = null; + for (const language of languageCandidates) { + const details = await this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, { + query: { + language + } + }).catch(() => null); + if (!details) { + continue; + } + const hasLocalizedPayload = Boolean( + String(details?.name || '').trim() + || String(details?.overview || '').trim() + || (Array.isArray(details?.episodes) && details.episodes.length > 0) + ); + if (hasLocalizedPayload) { + return details; + } + fallbackDetails = fallbackDetails || details; + } + return fallbackDetails; + } + + async getSeasonCredits(seriesId, seasonNumber, options = {}) { + const id = Number(seriesId); + const season = Number(seasonNumber); + if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, { + query: { + language + } + }).catch((error) => { + logger.warn('season:credits:failed', { + seriesId: Math.trunc(id), + seasonNumber: Math.trunc(season), + error: error?.message || String(error) + }); + return null; + }); + } + + buildSeasonSummary(seasonDetails = null) { + const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {}; + const episodes = Array.isArray(details.episodes) ? details.episodes : []; + return { + seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null, + name: String(details.name || '').trim() || null, + overview: String(details.overview || '').trim() || null, + posterPath: String(details.poster_path || '').trim() || null, + poster: this.buildImageUrl(details.poster_path, 'w342'), + episodeCount: episodes.length, + episodes: episodes.map((episode) => ({ + id: Number(episode?.id || 0) || null, + number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null, + seasonNumber: Number(episode?.season_number || details.season_number || 0) || null, + name: String(episode?.name || '').trim() || null, + overview: String(episode?.overview || '').trim() || null, + runtime: Number(episode?.runtime || 0) || null, + airDate: String(episode?.air_date || '').trim() || null, + stillPath: String(episode?.still_path || '').trim() || null, + still: this.buildImageUrl(episode?.still_path, 'w300') + })).filter((episode) => episode.id && episode.number) + }; + } + + buildSeriesDetailsSummary(seriesDetails = null) { + const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {}; + const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : []; + const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : []; + const creators = Array.isArray(details?.created_by) ? details.created_by : []; + const genres = Array.isArray(details?.genres) ? details.genres : []; + const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time); + const directorNames = this.extractDirectorNames(crew); + const creatorNames = this.normalizeNameList(creators, { maxItems: 5 }); + const actorNames = this.normalizeNameList(cast, { maxItems: 10 }); + const genreNames = this.normalizeNameList(genres, { maxItems: 10 }); + const voteAverageRaw = Number(details?.vote_average || 0); + const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0 + ? Number(voteAverageRaw.toFixed(1)) + : null; + const voteCount = Number(details?.vote_count || 0); + const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null; + + return { + director: directorNames.length > 0 + ? directorNames.join(', ') + : (creatorNames.length > 0 ? creatorNames.join(', ') : null), + actors: actorNames.length > 0 ? actorNames.join(', ') : null, + runtime: runtimeLabel, + runtimeLabel, + genre: genreNames.length > 0 ? genreNames.join(', ') : null, + imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null, + voteAverage, + voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null, + rottenTomatoes: null, + imdbId, + tmdbId: Number(details?.id || 0) || null, + firstAirDate: String(details?.first_air_date || '').trim() || null + }; + } + + buildMovieDetailsSummary(movieDetails = null) { + const details = movieDetails && typeof movieDetails === 'object' ? movieDetails : {}; + const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : []; + const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : []; + const genres = Array.isArray(details?.genres) ? details.genres : []; + const directorNames = this.extractDirectorNames(crew); + const actorNames = this.normalizeNameList(cast, { maxItems: 10 }); + const genreNames = this.normalizeNameList(genres, { maxItems: 10 }); + const voteAverageRaw = Number(details?.vote_average || 0); + const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0 + ? Number(voteAverageRaw.toFixed(1)) + : null; + const voteCount = Number(details?.vote_count || 0); + const imdbId = String(details?.external_ids?.imdb_id || details?.imdb_id || '').trim() || null; + const releaseDate = String(details?.release_date || '').trim() || null; + const runtime = this.formatRuntimeLabel(details?.runtime); + + return { + director: directorNames.length > 0 ? directorNames.join(', ') : null, + actors: actorNames.length > 0 ? actorNames.join(', ') : null, + runtime, + runtimeLabel: runtime, + genre: genreNames.length > 0 ? genreNames.join(', ') : null, + imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null, + voteAverage, + voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null, + rottenTomatoes: null, + imdbId, + tmdbId: Number(details?.id || 0) || null, + releaseDate + }; + } + + buildSeriesMetadataCandidate(series = {}, options = {}) { + const season = series?.season && typeof series.season === 'object' + ? series.season + : null; + const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null; + const providerId = seasonNumber + ? `tmdb:${series.id}:season:${seasonNumber}` + : `tmdb:${series.id}`; + + return { + provider: 'tmdb', + providerId, + metadataKind: seasonNumber ? 'season' : 'series', + tmdbId: Number(series?.id || 0) || null, + title: String(series?.title || '').trim() || null, + originalTitle: String(series?.originalTitle || '').trim() || null, + year: Number(series?.year || 0) || null, + overview: String(series?.overview || '').trim() || null, + poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'), + backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'), + seasonNumber, + seasonName: String(season?.name || '').trim() || null, + seasonOverview: String(season?.overview || '').trim() || null, + seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'), + episodeCount: Number(season?.episodeCount || 0) || 0, + episodes: Array.isArray(season?.episodes) ? season.episodes : [] + }; + } + + buildMovieMetadataCandidate(movie = {}, options = {}) { + const detailsSummary = options?.detailsSummary && typeof options.detailsSummary === 'object' + ? options.detailsSummary + : null; + const tmdbId = Number(movie?.id || movie?.tmdbId || 0) || null; + const releaseDate = String(movie?.releaseDate || detailsSummary?.releaseDate || '').trim() || null; + const year = Number(movie?.year || Number(String(releaseDate || '').slice(0, 4)) || 0) || null; + const imdbId = String(movie?.imdbId || detailsSummary?.imdbId || '').trim() || null; + const providerId = tmdbId !== null ? `tmdb:${tmdbId}` : null; + + return { + provider: 'tmdb', + providerId, + metadataKind: 'movie', + workflowKind: 'film', + tmdbId, + imdbId, + title: String(movie?.title || movie?.originalTitle || '').trim() || null, + originalTitle: String(movie?.originalTitle || '').trim() || null, + year, + overview: String(movie?.overview || '').trim() || null, + poster: movie?.poster || this.buildImageUrl(movie?.posterPath, 'w342'), + backdrop: movie?.backdrop || this.buildImageUrl(movie?.backdropPath, 'w780'), + runtime: detailsSummary?.runtime || null, + genre: detailsSummary?.genre || null, + voteAverage: detailsSummary?.voteAverage ?? null + }; + } + + buildSeasonSummariesFromSeriesDetails(seriesDetails = null) { + const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {}; + const seasons = Array.isArray(details.seasons) ? details.seasons : []; + return seasons + .map((season) => ({ + seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null, + name: String(season?.name || '').trim() || null, + overview: String(season?.overview || '').trim() || null, + posterPath: String(season?.poster_path || '').trim() || null, + poster: this.buildImageUrl(season?.poster_path, 'w342'), + episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0, + episodes: [] + })) + .filter((season) => season.seasonNumber !== null && season.episodeCount > 0); + } + + async searchSeriesWithSeasons(query, options = {}) { + const candidates = await this.searchSeries(query, options); + const failureCode = this.readFailureCode(candidates); + if (candidates.length === 0) { + return this.attachFailureCode([], failureCode); + } + + const limit = Math.max(1, Math.min(10, Number(options.limit || 5))); + const selectedCandidates = candidates.slice(0, limit); + + const expanded = await Promise.all(selectedCandidates.map(async (candidate) => { + const details = await this.getSeriesDetails(candidate.id, options); + const seasons = this.buildSeasonSummariesFromSeriesDetails(details); + if (seasons.length === 0) { + return [this.buildSeriesMetadataCandidate(candidate)]; + } + return seasons.map((season) => this.buildSeriesMetadataCandidate({ + ...candidate, + season + }, { + seasonNumber: season.seasonNumber + })); + })); + + const normalized = expanded.flat().filter((item) => item?.tmdbId && item?.title); + return this.attachFailureCode(normalized, failureCode); + } + + async searchSeriesWithSeason(query, seasonNumber, options = {}) { + const normalizedSeason = Number(seasonNumber); + if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) { + return []; + } + + const candidates = await this.searchSeries(query, options); + const failureCode = this.readFailureCode(candidates); + if (candidates.length === 0) { + return this.attachFailureCode([], failureCode); + } + + const limit = Math.max(1, Math.min(10, Number(options.limit || 5))); + const selectedCandidates = candidates.slice(0, limit); + const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => { + const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options); + if (!seasonDetails) { + return { + ...candidate, + season: null + }; + } + return { + ...candidate, + season: this.buildSeasonSummary(seasonDetails) + }; + })); + + const normalized = withSeasons + .filter((candidate) => candidate.season && candidate.season.episodeCount > 0) + .map((candidate) => this.buildSeriesMetadataCandidate(candidate, { + seasonNumber: normalizedSeason + })); + return this.attachFailureCode(normalized, failureCode); + } + + async searchMoviesWithDetails(query, options = {}) { + const candidates = await this.searchMovies(query, options); + const failureCode = this.readFailureCode(candidates); + if (candidates.length === 0) { + return this.attachFailureCode([], failureCode); + } + + const limit = Math.max(1, Math.min(15, Number(options.limit || 8))); + const selectedCandidates = candidates.slice(0, limit); + const language = await this.resolveLanguage(options.language); + + const expanded = await Promise.all(selectedCandidates.map(async (candidate) => { + const details = await this.getMovieDetailsWithCredits(candidate.id, { + language, + appendToResponse: ['credits', 'external_ids'], + ensureCredits: true + }); + const summary = this.buildMovieDetailsSummary(details); + const normalized = this.buildMovieMetadataCandidate({ + ...candidate, + releaseDate: details?.release_date || null, + imdbId: summary?.imdbId || null, + posterPath: details?.poster_path || candidate?.posterPath || null + }, { + detailsSummary: summary + }); + return { + ...normalized, + tmdbDetails: summary && typeof summary === 'object' ? summary : null + }; + })); + + const normalized = expanded.filter((item) => item?.tmdbId && item?.title); + return this.attachFailureCode(normalized, failureCode); + } +} + +module.exports = new TmdbService(); diff --git a/backend/src/services/userPresetDefaultsService.js b/backend/src/services/userPresetDefaultsService.js new file mode 100644 index 0000000..1d0dd6d --- /dev/null +++ b/backend/src/services/userPresetDefaultsService.js @@ -0,0 +1,134 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('USER_PRESET_DEFAULTS'); + +const SLOT_KEYS = Object.freeze([ + 'bluray_movie', + 'bluray_series', + 'dvd_movie', + 'dvd_series' +]); +const SLOT_KEY_SET = new Set(SLOT_KEYS); +const SLOT_ALLOWED_MEDIA_TYPES = Object.freeze({ + bluray_movie: new Set(['bluray', 'all']), + bluray_series: new Set(['bluray', 'all']), + dvd_movie: new Set(['dvd', 'all']), + dvd_series: new Set(['dvd', 'all']) +}); + +function normalizePresetId(value) { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'string' && value.trim() === '') { + return null; + } + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return null; + } + return Math.trunc(numeric); +} + +function buildDefaultsMap(rows = []) { + const map = Object.fromEntries(SLOT_KEYS.map((key) => [key, null])); + for (const row of rows) { + const slotKey = String(row?.slot_key || '').trim(); + if (!SLOT_KEY_SET.has(slotKey)) { + continue; + } + const presetId = normalizePresetId(row?.preset_id); + map[slotKey] = presetId; + } + return map; +} + +async function listDefaults() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT slot_key, preset_id + FROM user_preset_defaults + WHERE slot_key IN (${SLOT_KEYS.map(() => '?').join(', ')}) + ORDER BY slot_key ASC + `, + SLOT_KEYS + ); + return buildDefaultsMap(rows); +} + +async function updateDefaults(payload = {}) { + const defaults = payload && typeof payload === 'object' + ? (payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : payload) + : {}; + const updates = []; + + for (const [slotKey, rawPresetId] of Object.entries(defaults)) { + if (!SLOT_KEY_SET.has(slotKey)) { + const error = new Error(`Ungültiger Slot: ${slotKey}`); + error.statusCode = 400; + throw error; + } + updates.push({ slotKey, presetId: normalizePresetId(rawPresetId) }); + } + + if (updates.length === 0) { + return listDefaults(); + } + + const db = await getDb(); + for (const update of updates) { + if (update.presetId === null) { + continue; + } + const exists = await db.get( + `SELECT id, media_type FROM user_presets WHERE id = ? LIMIT 1`, + [update.presetId] + ); + if (!exists) { + const error = new Error(`Preset ${update.presetId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + const mediaType = String(exists.media_type || '').trim().toLowerCase(); + const allowedMediaTypes = SLOT_ALLOWED_MEDIA_TYPES[update.slotKey] || new Set(['all']); + if (!allowedMediaTypes.has(mediaType)) { + const error = new Error( + `Preset ${update.presetId} (${mediaType || 'unbekannt'}) ist für ${update.slotKey} nicht zulässig.` + ); + error.statusCode = 400; + throw error; + } + } + + await db.exec('BEGIN'); + try { + for (const update of updates) { + await db.run( + ` + INSERT INTO user_preset_defaults (slot_key, preset_id) + VALUES (?, ?) + ON CONFLICT(slot_key) DO UPDATE SET preset_id = excluded.preset_id + `, + [update.slotKey, update.presetId] + ); + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + logger.info('update', { + updates: updates.map((entry) => ({ + slotKey: entry.slotKey, + presetId: entry.presetId + })) + }); + return listDefaults(); +} + +module.exports = { + SLOT_KEYS, + listDefaults, + updateDefaults +}; diff --git a/backend/src/services/userPresetService.js b/backend/src/services/userPresetService.js new file mode 100644 index 0000000..a944ea8 --- /dev/null +++ b/backend/src/services/userPresetService.js @@ -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 +}; diff --git a/backend/src/services/websocketService.js b/backend/src/services/websocketService.js new file mode 100644 index 0000000..317bc32 --- /dev/null +++ b/backend/src/services/websocketService.js @@ -0,0 +1,121 @@ +const { WebSocketServer } = require('ws'); +const logger = require('./logger').child('WS'); + +class WebSocketService { + constructor() { + this.wss = null; + this.clients = new Set(); + } + + _removeClient(socket, logLevel = 'info', event = 'client:removed') { + if (!socket) { + return; + } + const deleted = this.clients.delete(socket); + if (!deleted) { + return; + } + logger[logLevel](event, { clients: this.clients.size }); + } + + init(httpServer) { + if (this.wss) { + return; + } + + this.wss = new WebSocketServer({ server: httpServer, path: '/ws' }); + + this.wss.on('connection', (socket) => { + this.clients.add(socket); + logger.info('client:connected', { clients: this.clients.size }); + + try { + socket.send( + JSON.stringify({ + type: 'WS_CONNECTED', + payload: { connectedAt: new Date().toISOString() } + }) + ); + } catch (error) { + logger.warn('client:connected:initial-send-failed', { + clients: this.clients.size, + error: error?.message || String(error) + }); + this._removeClient(socket, 'warn', 'client:connected:removed-after-send-failure'); + return; + } + + socket.on('close', () => { + this._removeClient(socket, 'info', 'client:closed'); + }); + + socket.on('error', (error) => { + logger.warn('client:error', { + clients: this.clients.size, + error: error?.message || String(error) + }); + this._removeClient(socket, 'warn', 'client:error:removed'); + }); + }); + } + + broadcast(type, payload) { + if (!this.wss) { + return; + } + + logger.debug('broadcast', { + type, + clients: this.clients.size, + payloadKeys: payload && typeof payload === 'object' ? Object.keys(payload) : [] + }); + + const message = JSON.stringify({ + type, + payload, + timestamp: new Date().toISOString() + }); + + for (const client of this.clients) { + if (!client || client.readyState !== client.OPEN) { + if (client && client.readyState === client.CLOSED) { + this._removeClient(client, 'info', 'client:pruned-closed'); + } + continue; + } + + try { + client.send(message, (error) => { + if (!error) { + return; + } + logger.warn('broadcast:send-failed', { + type, + clients: this.clients.size, + error: error?.message || String(error) + }); + this._removeClient(client, 'warn', 'client:removed-after-send-failure'); + try { + client.terminate(); + } catch (_error) { + // noop + } + }); + } catch (error) { + logger.warn('broadcast:send-threw', { + type, + clients: this.clients.size, + error: error?.message || String(error) + }); + this._removeClient(client, 'warn', 'client:removed-after-send-throw'); + try { + client.terminate(); + } catch (_terminateError) { + // noop + } + } + } + } +} + +module.exports = new WebSocketService(); diff --git a/backend/src/utils/commandLine.js b/backend/src/utils/commandLine.js new file mode 100644 index 0000000..b728964 --- /dev/null +++ b/backend/src/utils/commandLine.js @@ -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 +}; diff --git a/backend/src/utils/encodePlan.js b/backend/src/utils/encodePlan.js new file mode 100644 index 0000000..cf38ead --- /dev/null +++ b/backend/src/utils/encodePlan.js @@ -0,0 +1,1635 @@ +const path = require('path'); +const { splitArgs } = require('./commandLine'); + +const DEFAULT_AUDIO_COPY_MASK = ['aac', 'ac3', 'eac3', 'truehd', 'dts', 'dtshd', 'mp3', 'flac']; +const DEFAULT_AUDIO_FALLBACK = 'av_aac'; +const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({ + low: 1, + medium: 2, + high: 3 +}); +const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35; +const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35; +const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220; +const FORCED_SUBTITLE_MIN_EVENT_GAP = 12; +const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024; +const ISO2_TO_3_LANGUAGE = { + de: 'deu', + en: 'eng', + fr: 'fra', + es: 'spa', + it: 'ita', + tr: 'tur', + pt: 'por', + ru: 'rus', + pl: 'pol', + nl: 'nld', + sv: 'swe', + no: 'nor', + da: 'dan', + fi: 'fin', + cs: 'ces', + hu: 'hun', + ro: 'ron', + uk: 'ukr', + ja: 'jpn', + jp: 'jpn', + ko: 'kor', + zh: 'zho', + ar: 'ara' +}; + +function clampNumber(value, fallback = 0) { + const num = Number(value); + if (Number.isFinite(num)) { + return num; + } + return fallback; +} + +function normalizeLanguage(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw || raw === 'und' || raw === 'unknown') { + return 'und'; + } + if (raw.length === 2 && ISO2_TO_3_LANGUAGE[raw]) { + return ISO2_TO_3_LANGUAGE[raw]; + } + if (raw.length === 3) { + return raw; + } + if (raw.startsWith('de')) { + return 'deu'; + } + if (raw.startsWith('en')) { + return 'eng'; + } + if (raw.startsWith('fr')) { + return 'fra'; + } + if (raw.startsWith('es')) { + return 'spa'; + } + if (raw.startsWith('it')) { + return 'ita'; + } + if (raw.length === 2) { + return raw; + } + return raw.slice(0, 3); +} + +function normalizeSelectionLanguage(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'any' || raw === 'none') { + return raw; + } + return normalizeLanguage(raw); +} + +function parseDurationSeconds(raw) { + if (raw === null || raw === undefined) { + return 0; + } + + const numeric = Number(raw); + if (Number.isFinite(numeric) && numeric > 0) { + if (numeric > 10000) { + return Math.round(numeric / 1000); + } + return Math.round(numeric); + } + + const text = String(raw).trim(); + if (!text) { + return 0; + } + + let seconds = 0; + const hourMatch = text.match(/(\d+(?:\.\d+)?)\s*h/i); + const minuteMatch = text.match(/(\d+(?:\.\d+)?)\s*mn?/i); + const secondMatch = text.match(/(\d+(?:\.\d+)?)\s*s/i); + + if (hourMatch || minuteMatch || secondMatch) { + seconds += hourMatch ? Number(hourMatch[1]) * 3600 : 0; + seconds += minuteMatch ? Number(minuteMatch[1]) * 60 : 0; + seconds += secondMatch ? Number(secondMatch[1]) : 0; + return Math.round(seconds); + } + + const colonMatch = text.match(/(\d{1,2}):(\d{2}):(\d{2})/); + if (colonMatch) { + const h = Number(colonMatch[1]); + const m = Number(colonMatch[2]); + const s = Number(colonMatch[3]); + return (h * 3600) + (m * 60) + s; + } + + return 0; +} + +function pickTrackId(track, fallbackIndex) { + const rawId = track?.ID ?? track?.ID_String ?? track?.StreamOrder ?? track?.StreamOrder_String; + if (rawId === undefined || rawId === null || rawId === '') { + return fallbackIndex + 1; + } + + const match = String(rawId).match(/\d+/); + if (!match) { + return fallbackIndex + 1; + } + + return Number(match[0]); +} + +function mapAudioFormatToCopyCodec(format) { + const raw = String(format || '').toLowerCase(); + if (!raw) { + return null; + } + if (raw.includes('e-ac-3') || raw.includes('eac3') || raw.includes('dd+')) { + return 'eac3'; + } + if (raw.includes('ac-3') || raw.includes('ac3') || raw.includes('dolby digital')) { + return 'ac3'; + } + if (raw.includes('truehd')) { + return 'truehd'; + } + if (raw.includes('dts-hd') || raw.includes('dtshd')) { + return 'dtshd'; + } + if (raw.includes('dca')) { + return 'dts'; + } + if (raw.includes('dts')) { + return 'dts'; + } + if (raw.includes('aac')) { + return 'aac'; + } + if (raw.includes('flac')) { + return 'flac'; + } + if (raw.includes('mp3') || raw.includes('mpeg audio')) { + return 'mp3'; + } + if (raw.includes('opus')) { + return 'opus'; + } + if (raw.includes('pcm') || raw.includes('lpcm')) { + return 'lpcm'; + } + return null; +} + +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 parseMakemkvTitleIdFromFileName(fileName) { + const match = String(fileName || '').match(/_t(\d{1,3})\./i); + if (!match) { + return null; + } + const value = Number(match[1]); + if (!Number.isFinite(value) || value < 0) { + return null; + } + return value; +} + +function emptyPlaylistMatch() { + return { + playlistId: null, + playlistFile: null, + recommended: false, + evaluationLabel: null, + segmentCommand: null, + segmentFiles: [] + }; +} + +function resolvePlaylistMatchByPlaylistId(analysis, rawPlaylistId) { + const playlistId = normalizePlaylistId(rawPlaylistId); + if (!analysis || !playlistId) { + return emptyPlaylistMatch(); + } + + const recommendation = analysis.recommendation || null; + const recommended = normalizePlaylistId(recommendation?.playlistId) === playlistId; + + const evaluated = (Array.isArray(analysis.evaluatedCandidates) ? analysis.evaluatedCandidates : []) + .find((item) => normalizePlaylistId(item?.playlistId) === playlistId) || null; + + const segmentMap = (analysis.playlistSegments && typeof analysis.playlistSegments === 'object') + ? analysis.playlistSegments + : {}; + const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; + const segmentFiles = Array.isArray(segmentEntry?.segmentFiles) + ? segmentEntry.segmentFiles.filter((item) => String(item || '').trim().length > 0) + : []; + + return { + playlistId, + playlistFile: `${playlistId}.mpls`, + recommended, + evaluationLabel: evaluated?.evaluationLabel || (recommended ? 'wahrscheinlich korrekt (Heuristik)' : null), + segmentCommand: segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, + segmentFiles + }; +} + +function findPlaylistMatchForTitle(playlistAnalysis, makemkvTitleId) { + const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null; + if (!analysis || makemkvTitleId === null || makemkvTitleId === undefined) { + return emptyPlaylistMatch(); + } + + const titles = Array.isArray(analysis.titles) ? analysis.titles : []; + const mapping = titles.find((item) => Number(item?.titleId) === Number(makemkvTitleId)) || null; + return resolvePlaylistMatchByPlaylistId(analysis, mapping?.playlistId || null); +} + +function parseMediaInfoFile(mediaInfoJson, fileInfo, index) { + const tracks = Array.isArray(mediaInfoJson?.media?.track) ? mediaInfoJson.media.track : []; + const general = tracks.find((item) => String(item?.['@type'] || '').toLowerCase() === 'general') || {}; + const durationSeconds = parseDurationSeconds(general?.Duration || general?.Duration_String3 || general?.Duration_String); + const durationMinutes = Number((durationSeconds / 60).toFixed(2)); + const fileName = path.basename(fileInfo.path); + + const audioTracks = tracks + .filter((item) => String(item?.['@type'] || '').toLowerCase() === 'audio') + .map((item, idx) => ({ + id: idx + 1, + sourceTrackId: pickTrackId(item, idx), + language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'), + languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und', + title: item?.Title || null, + format: item?.Format || null, + codecToken: mapAudioFormatToCopyCodec(item?.Format || null), + channels: item?.Channels || item?.Channel_s_ || null + })); + + const subtitleTracksRaw = tracks + .filter((item) => { + const type = String(item?.['@type'] || '').toLowerCase(); + return type === 'text' || type === 'subtitle'; + }) + .map((item, idx) => ({ + id: idx + 1, + sourceTrackId: pickTrackId(item, idx), + language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'), + languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und', + title: item?.Title || null, + format: item?.Format || null, + defaultFlag: parseBooleanFlag(item?.Default ?? item?.Default_String ?? item?.IsDefault ?? item?.isDefault), + forcedFlag: parseBooleanFlagNullable(item?.Forced ?? item?.Forced_String ?? item?.IsForced ?? item?.isForced), + sdhFlag: parseSubtitleSdhFlag(item), + eventCount: parseSubtitleEventCount(item), + streamSizeBytes: parseSubtitleStreamSizeBytes(item) + })); + const subtitleTracks = annotateSubtitleTracks(subtitleTracksRaw); + + const videoTracks = tracks + .filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video') + .map((item, idx) => ({ + id: idx + 1, + sourceTrackId: pickTrackId(item, idx), + format: item?.Format || null, + codecId: item?.CodecID || null, + width: item?.Width || null, + height: item?.Height || null, + frameRate: item?.FrameRate || null + })); + + return { + id: index + 1, + filePath: fileInfo.path, + fileName, + makemkvTitleId: parseMakemkvTitleIdFromFileName(fileName), + sizeBytes: clampNumber(fileInfo.size, 0), + durationSeconds, + durationMinutes, + audioTracks, + subtitleTracks, + videoTracks + }; +} + +function parseArgValue(args, index) { + const token = args[index]; + if (!token) { + return { value: null, consumed: 0 }; + } + + if (token.includes('=')) { + return { + value: token.slice(token.indexOf('=') + 1), + consumed: 0 + }; + } + + if (index + 1 < args.length && !String(args[index + 1]).startsWith('-')) { + return { + value: args[index + 1], + consumed: 1 + }; + } + + return { value: null, consumed: 0 }; +} + +function parseList(raw, mapper = normalizeSelectionLanguage) { + return String(raw || '') + .split(',') + .map((item) => mapper(item)) + .filter(Boolean); +} + +function parseTrackIdList(raw) { + return String(raw || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)); +} + +function parseBooleanFlag(value) { + return parseBooleanFlagNullable(value) === true; +} + +function parseBooleanFlagNullable(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value === 1; + } + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'yes' || raw === 'true' || raw === '1') { + return true; + } + if (raw === 'no' || raw === 'false' || raw === '0') { + return false; + } + return null; +} + +function parseSubtitleForcedFlag(track) { + if (!track || typeof track !== 'object') { + return null; + } + const candidates = [ + track?.forcedFlag, + track?.forced, + track?.Forced, + track?.isForced, + track?.IsForced, + track?.forced_only, + track?.forcedOnly, + track?.Attributes?.Forced + ]; + for (const candidate of candidates) { + const parsed = parseBooleanFlagNullable(candidate); + if (parsed === true || parsed === false) { + return parsed; + } + } + return null; +} + +function parseSubtitleSdhFlag(track) { + if (!track || typeof track !== 'object') { + return null; + } + const candidates = [ + track?.sdhFlag, + track?.hearingImpaired, + track?.HearingImpaired, + track?.isHearingImpaired, + track?.IsHearingImpaired, + track?.Hearing_Impaired, + track?.closedCaptions, + track?.ClosedCaptions, + track?.Attributes?.HearingImpaired, + track?.Attributes?.ClosedCaptions + ]; + for (const candidate of candidates) { + const parsed = parseBooleanFlagNullable(candidate); + if (parsed === true || parsed === false) { + return parsed; + } + } + + const serviceKind = String(track?.serviceKind || track?.ServiceKind || '').trim().toLowerCase(); + if (!serviceKind) { + return null; + } + if (serviceKind.includes('sdh') || serviceKind.includes('cc') || serviceKind.includes('hearing')) { + return true; + } + return null; +} + +function parseSubtitleEventCount(track) { + const candidates = [ + track?.CountOfEvents, + track?.countOfEvents, + track?.EventCount, + track?.eventCount, + track?.ElementCount, + track?.elementCount + ]; + for (const candidate of candidates) { + const numeric = Number(candidate); + if (Number.isFinite(numeric) && numeric >= 0) { + return Math.trunc(numeric); + } + } + return null; +} + +function parseSubtitleStreamSizeBytes(track) { + const numericCandidates = [ + track?.StreamSize, + track?.streamSize, + track?.StreamSize_Original, + track?.streamSizeOriginal, + track?.Bytes, + track?.bytes + ]; + for (const candidate of numericCandidates) { + const numeric = Number(candidate); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.trunc(numeric); + } + } + + const textCandidates = [ + track?.StreamSize_String, + track?.streamSizeString, + track?.StreamSize_Original_String, + track?.streamSizeOriginalString, + track?.Size_String, + track?.sizeString + ]; + for (const candidate of textCandidates) { + const text = String(candidate || '').trim(); + if (!text) { + continue; + } + const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i); + if (!match) { + continue; + } + const value = Number(String(match[1]).replace(',', '.')); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const unit = String(match[2] || 'b').toLowerCase(); + 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.trunc(value * factor)); + } + return null; +} + +function normalizeSubtitleConfidence(raw) { + const value = String(raw || '').trim().toLowerCase(); + if (value === 'high' || value === 'medium' || value === 'low') { + return value; + } + return 'low'; +} + +function subtitleConfidenceScore(raw) { + return SUBTITLE_CONFIDENCE_SCORES[normalizeSubtitleConfidence(raw)] || 0; +} + +function collectSubtitleText(track) { + return [ + track?.title, + track?.description, + track?.name, + track?.format, + track?.label + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); +} + +function isLikelyBitmapSubtitleFormat(track) { + const text = [ + track?.format, + track?.codec, + track?.codecName, + track?.title, + track?.description, + track?.name, + track?.label + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); + if (!text) { + return false; + } + return ( + /\bpgs\b/.test(text) + || /\bhdmv\b/.test(text) + || /\bsup\b/.test(text) + || /\bvobsub\b/.test(text) + || /\bdvd[-_\s]?sub/.test(text) + || /\bdvb[-_\s]?sub/.test(text) + ); +} + +function isLikelySdhSubtitleTrack(track) { + const text = collectSubtitleText(track); + if (!text) { + return false; + } + return ( + /\bsdh\b/.test(text) + || /\bcc\b/.test(text) + || /\bhoh\b/.test(text) + || /\bcaptions?\b/.test(text) + || /hard[-\s]?of[-\s]?hearing/.test(text) + || /hearing\s+impaired/.test(text) + || /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text) + ); +} + +function isLikelyForcedSubtitleTrack(track) { + const text = collectSubtitleText(track); + if (!text) { + return false; + } + if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) { + return false; + } + return ( + /\bforced(?:\s+only)?\b/.test(text) + || /nur\s+erzwungen/.test(text) + || /\berzwungen\b/.test(text) + ); +} + +function compareSubtitleTracksByForcedHeuristic(a, b) { + const aSdh = Number(Boolean(a?.sdhLikely)); + const bSdh = Number(Boolean(b?.sdhLikely)); + if (aSdh !== bSdh) { + return aSdh - bSdh; + } + + const aDefault = Number(Boolean(a?.defaultFlag)); + const bDefault = Number(Boolean(b?.defaultFlag)); + if (aDefault !== bDefault) { + return aDefault - bDefault; + } + + const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1; + const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1; + if (aEventKnown !== bEventKnown) { + return aEventKnown - bEventKnown; + } + if (aEventKnown === 0 && a.eventCount !== b.eventCount) { + return a.eventCount - b.eventCount; + } + + const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1; + const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1; + if (aSizeKnown !== bSizeKnown) { + return aSizeKnown - bSizeKnown; + } + if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) { + return a.streamSizeBytes - b.streamSizeBytes; + } + + const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER; + const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER; + if (aTrackId !== bTrackId) { + return aTrackId - bTrackId; + } + return a.originalIndex - b.originalIndex; +} + +function compareSubtitleTracksForDedup(a, b) { + const aSdh = Number(Boolean(a?.sdhLikely)); + const bSdh = Number(Boolean(b?.sdhLikely)); + if (aSdh !== bSdh) { + return aSdh - bSdh; + } + + const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); + if (defaultDiff !== 0) { + return defaultDiff; + } + const confidenceDiff = subtitleConfidenceScore(b?.sourceConfidence) - subtitleConfidenceScore(a?.sourceConfidence); + if (confidenceDiff !== 0) { + return confidenceDiff; + } + const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER; + const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER; + if (aTrackId !== bTrackId) { + return aTrackId - bTrackId; + } + return a.originalIndex - b.originalIndex; +} + +function isHeuristicForcedSubtitleCandidate(languageEntries, candidate) { + if (!candidate) { + return false; + } + if (!isLikelyBitmapSubtitleFormat(candidate)) { + return false; + } + const comparable = (Array.isArray(languageEntries) ? languageEntries : []) + .filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry)); + if (comparable.length === 0) { + return false; + } + + const candidateEventCount = Number(candidate?.eventCount); + const comparableEventCounts = comparable + .map((entry) => Number(entry?.eventCount)) + .filter((value) => Number.isFinite(value) && value > 0); + const maxComparableEventCount = comparableEventCounts.length > 0 + ? Math.max(...comparableEventCounts) + : null; + const hasEventSignal = Number.isFinite(candidateEventCount) + && candidateEventCount >= 0 + && Number.isFinite(maxComparableEventCount) + && maxComparableEventCount > 0 + && candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT + && (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD + && (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP; + + const candidateStreamSize = Number(candidate?.streamSizeBytes); + const comparableSizes = comparable + .map((entry) => Number(entry?.streamSizeBytes)) + .filter((value) => Number.isFinite(value) && value > 0); + const maxComparableSize = comparableSizes.length > 0 + ? Math.max(...comparableSizes) + : null; + const hasSizeSignal = Number.isFinite(candidateStreamSize) + && candidateStreamSize > 0 + && Number.isFinite(maxComparableSize) + && maxComparableSize > 0 + && (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD + && (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES; + + const signalCount = Number(hasEventSignal) + Number(hasSizeSignal); + if (signalCount === 0) { + return false; + } + + if (candidate.defaultFlag && signalCount < 2) { + return false; + } + return true; +} + +function annotateSubtitleTracks(subtitleTracks) { + const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : []; + if (tracks.length === 0) { + return []; + } + + const entries = tracks.map((track, index) => ({ + ...track, + language: normalizeLanguage(track?.language || track?.languageLabel || 'und'), + defaultFlag: Boolean(track?.defaultFlag), + forcedFlag: parseSubtitleForcedFlag(track), + forcedTrack: false, + sourceConfidence: null, + confidenceSource: 'heuristic', + duplicate: false, + selected: false, + subtitleType: 'full', + forcedAvailable: false, + forcedSourceTrackIds: [], + sdhLikely: (parseSubtitleSdhFlag(track) === true) || Boolean(track?.sdhLikely) || isLikelySdhSubtitleTrack(track), + originalIndex: index + })); + + const byLanguage = new Map(); + for (const entry of entries) { + if (!byLanguage.has(entry.language)) { + byLanguage.set(entry.language, []); + } + byLanguage.get(entry.language).push(entry); + } + + for (const languageEntries of byLanguage.values()) { + for (const entry of languageEntries) { + const forcedByFlag = entry.forcedFlag === true; + const forcedBlockedByFlag = entry.forcedFlag === false; + const forcedByTitle = !entry.sdhLikely && !forcedBlockedByFlag && isLikelyForcedSubtitleTrack(entry); + + if (forcedByFlag) { + entry.forcedTrack = true; + entry.sourceConfidence = 'high'; + entry.confidenceSource = 'explicit_flag'; + } else if (forcedByTitle) { + entry.forcedTrack = true; + entry.sourceConfidence = 'medium'; + entry.confidenceSource = 'title'; + } + } + + if (!languageEntries.some((entry) => entry.forcedTrack)) { + const candidates = languageEntries + .filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false) + .sort(compareSubtitleTracksByForcedHeuristic); + const forcedCandidate = candidates[0] || null; + if (forcedCandidate && isHeuristicForcedSubtitleCandidate(languageEntries, forcedCandidate)) { + forcedCandidate.forcedTrack = true; + forcedCandidate.sourceConfidence = 'low'; + forcedCandidate.confidenceSource = 'heuristic'; + } + } + + for (const entry of languageEntries) { + if (!entry.forcedTrack) { + continue; + } + entry.sourceConfidence = normalizeSubtitleConfidence(entry.sourceConfidence || 'low'); + } + + const forcedEntries = languageEntries.filter((entry) => entry.forcedTrack); + const fullEntries = languageEntries.filter((entry) => !entry.forcedTrack && !entry.sdhLikely); + const sdhEntries = languageEntries.filter((entry) => !entry.forcedTrack && entry.sdhLikely); + const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null; + const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null; + const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null; + const forcedSourceTrackIds = forcedEntries + .map((entry) => Number(entry?.sourceTrackId ?? entry?.id)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + const forcedAvailable = Boolean(forcedWinner); + + for (const entry of languageEntries) { + const typeWinner = entry.forcedTrack + ? forcedWinner + : (entry.sdhLikely ? sdhWinner : fullWinner); + entry.duplicate = Boolean(typeWinner && typeWinner !== entry); + if (entry.forcedTrack) { + entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); + } else if (entry.sdhLikely) { + entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate); + } else { + entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); + } + entry.subtitleType = entry.forcedTrack ? 'forced' : 'full'; + entry.forcedAvailable = forcedAvailable; + entry.forcedSourceTrackIds = forcedSourceTrackIds; + const fullHasForced = !entry.forcedTrack && Boolean( + entry.fullHasForced + ?? entry.subtitleFullHasForced + ?? entry.hasForcedVariant + ); + const explicitForcedOnly = entry.isForcedOnly ?? entry.forcedOnly ?? entry.subtitlePreviewForcedOnly; + entry.isForcedOnly = typeof explicitForcedOnly === 'boolean' + ? explicitForcedOnly + : (entry.forcedTrack && !fullHasForced); + entry.fullHasForced = fullHasForced; + } + } + + return entries + .sort((a, b) => a.originalIndex - b.originalIndex) + .map((entry) => { + const { originalIndex, ...rest } = entry; + return rest; + }); +} + +function parseEncoderList(raw) { + return String(raw || '') + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function parseCopyMaskList(raw) { + return String(raw || '') + .split(',') + .map((item) => String(item || '').trim().toLowerCase()) + .map((item) => item.replace(/^copy:/, '')) + .filter(Boolean); +} + +function normalizeTrackSelectionMode(raw, trackType) { + const value = String(raw || '').trim().toLowerCase(); + if (value === 'all') { + return 'all'; + } + if (value === 'first') { + return 'first'; + } + if (value === 'none') { + return 'none'; + } + if (value === 'language') { + return 'language'; + } + return trackType === 'audio' ? 'first' : 'none'; +} + +function normalizeBurnBehavior(raw) { + const value = String(raw || '').trim().toLowerCase(); + if (!value || value === 'none') { + return 'none'; + } + if (value === 'foreign' || value === 'foreign_first') { + return 'first'; + } + if (value === 'first') { + return 'first'; + } + return 'none'; +} + +function hasConfiguredLanguageSelection(rawValue) { + return parseList(rawValue, normalizeSelectionLanguage) + .filter((item) => item !== 'none' && item !== 'any') + .length > 0; +} + +function hasExplicitPresetTrackSelection(profile = {}, trackType = 'audio') { + const source = String(profile?.source || '').trim().toLowerCase(); + if (source !== 'preset-export') { + return false; + } + + if (trackType === 'audio') { + const behavior = String(profile?.audioTrackSelectionBehavior || '').trim().toLowerCase(); + const languages = Array.isArray(profile?.audioLanguages) ? profile.audioLanguages : []; + return behavior === 'all' || behavior === 'language' || behavior === 'none' || languages.length > 0; + } + + const behavior = String(profile?.subtitleTrackSelectionBehavior || '').trim().toLowerCase(); + const languages = Array.isArray(profile?.subtitleLanguages) ? profile.subtitleLanguages : []; + return behavior === 'all' || behavior === 'language' || behavior === 'first' || languages.length > 0; +} + +function buildBaseTrackSelectors(settings, presetProfile = null) { + const profile = presetProfile && typeof presetProfile === 'object' ? presetProfile : {}; + const audioLanguages = Array.isArray(profile.audioLanguages) + ? profile.audioLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean) + : []; + const subtitleLanguages = Array.isArray(profile.subtitleLanguages) + ? profile.subtitleLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean) + : []; + const configuredAudioLanguages = parseList(settings?.handbrake_review_audio_languages, normalizeSelectionLanguage) + .filter((item) => item !== 'none' && item !== 'any'); + const configuredSubtitleLanguages = parseList(settings?.handbrake_review_subtitle_languages, normalizeSelectionLanguage) + .filter((item) => item !== 'none' && item !== 'any'); + const useConfiguredAudioLanguages = configuredAudioLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'audio'); + const useConfiguredSubtitleLanguages = configuredSubtitleLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'subtitle'); + const effectiveAudioLanguages = useConfiguredAudioLanguages ? configuredAudioLanguages : audioLanguages; + const effectiveSubtitleLanguages = useConfiguredSubtitleLanguages ? configuredSubtitleLanguages : subtitleLanguages; + const audioEncoders = Array.isArray(profile.audioEncoders) + ? profile.audioEncoders.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) + : []; + + const rawCopyMask = Array.isArray(profile.audioCopyMask) + ? profile.audioCopyMask + : []; + + const normalizedCopyMask = rawCopyMask + .map((item) => String(item || '').trim().toLowerCase()) + .map((item) => item.replace(/^copy:/, '')) + .filter(Boolean); + + const baseAudioMode = normalizeTrackSelectionMode(profile.audioTrackSelectionBehavior, 'audio'); + const baseSubtitleMode = normalizeTrackSelectionMode(profile.subtitleTrackSelectionBehavior, 'subtitle'); + const effectiveAudioMode = useConfiguredAudioLanguages && !hasConfiguredLanguageSelection(profile?.audioLanguages) + ? 'language' + : baseAudioMode; + const effectiveSubtitleMode = useConfiguredSubtitleLanguages && !hasConfiguredLanguageSelection(profile?.subtitleLanguages) + ? 'language' + : baseSubtitleMode; + const audioSelectionSource = useConfiguredAudioLanguages + ? 'settings' + : (profile.source === 'preset-export' ? 'preset' : 'default'); + const subtitleSelectionSource = useConfiguredSubtitleLanguages + ? 'settings' + : (profile.source === 'preset-export' ? 'preset' : 'default'); + + return { + preset: settings?.handbrake_preset || null, + extraArgs: settings?.handbrake_extra_args || '', + presetProfileSource: profile.source || 'fallback', + presetProfileMessage: profile.message || null, + audio: { + mode: effectiveAudioMode, + languages: effectiveAudioLanguages.filter((item) => item !== 'none'), + explicitIds: [], + firstOnly: effectiveAudioMode === 'first', + selectionSource: audioSelectionSource, + encoders: audioEncoders, + encoderSource: audioEncoders.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default', + copyMask: normalizedCopyMask.length > 0 ? normalizedCopyMask : [...DEFAULT_AUDIO_COPY_MASK], + copyMaskSource: normalizedCopyMask.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default', + fallbackEncoder: String(profile.audioFallback || DEFAULT_AUDIO_FALLBACK).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK, + fallbackSource: profile.audioFallback ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default' + }, + subtitle: { + mode: effectiveSubtitleMode, + languages: effectiveSubtitleLanguages.filter((item) => item !== 'none'), + explicitIds: [], + firstOnly: effectiveSubtitleMode === 'first', + selectionSource: subtitleSelectionSource, + // Do not auto-burn subtitle tracks from exported preset metadata. + // Burn-in should only be activated via explicit CLI args/selection. + burnBehavior: 'none', + burnedTrackId: null, + defaultTrackId: null, + forcedTrackId: null, + forcedOnly: false + } + }; +} + +function applyArgOverrides(selectors, args) { + const audio = selectors.audio; + const subtitle = selectors.subtitle; + + for (let i = 0; i < args.length; i += 1) { + const token = args[i]; + + if (token === '--all-audio') { + audio.mode = 'all'; + audio.firstOnly = false; + audio.selectionSource = 'args'; + continue; + } + + if (token === '--first-audio') { + audio.firstOnly = true; + if (audio.mode !== 'explicit' && audio.mode !== 'language') { + audio.mode = 'first'; + } + audio.selectionSource = 'args'; + continue; + } + + if (token === '--audio' || token.startsWith('--audio=') || token === '-a' || token.startsWith('-a=')) { + const parsed = parseArgValue(args, i); + const raw = String(parsed.value || '').trim().toLowerCase(); + if (raw === 'none') { + audio.mode = 'none'; + audio.explicitIds = []; + } else { + audio.explicitIds = parseTrackIdList(parsed.value); + audio.mode = 'explicit'; + } + audio.firstOnly = false; + audio.selectionSource = 'args'; + i += parsed.consumed; + continue; + } + + if (token === '--audio-lang-list' || token.startsWith('--audio-lang-list=')) { + const parsed = parseArgValue(args, i); + const langs = parseList(parsed.value, normalizeSelectionLanguage).filter((item) => item !== 'none'); + if (langs.includes('any')) { + audio.mode = 'all'; + audio.languages = []; + } else { + audio.mode = 'language'; + audio.languages = langs; + } + audio.selectionSource = 'args'; + i += parsed.consumed; + continue; + } + + if (token === '--aencoder' || token.startsWith('--aencoder=') || token === '-E' || token.startsWith('-E=')) { + const parsed = parseArgValue(args, i); + const encoders = parseEncoderList(parsed.value); + if (encoders.length > 0) { + audio.encoders = encoders; + audio.encoderSource = 'args'; + } + i += parsed.consumed; + continue; + } + + if (token === '--audio-copy-mask' || token.startsWith('--audio-copy-mask=')) { + const parsed = parseArgValue(args, i); + audio.copyMask = parseCopyMaskList(parsed.value); + audio.copyMaskSource = 'args'; + i += parsed.consumed; + continue; + } + + if (token === '--audio-fallback' || token.startsWith('--audio-fallback=')) { + const parsed = parseArgValue(args, i); + const fallback = String(parsed.value || '').trim().toLowerCase(); + if (fallback) { + audio.fallbackEncoder = fallback; + audio.fallbackSource = 'args'; + } + i += parsed.consumed; + continue; + } + + if (token === '--all-subtitles') { + subtitle.mode = 'all'; + subtitle.firstOnly = false; + subtitle.selectionSource = 'args'; + continue; + } + + if (token === '--first-subtitle') { + subtitle.firstOnly = true; + if (subtitle.mode !== 'explicit' && subtitle.mode !== 'language') { + subtitle.mode = 'first'; + } + subtitle.selectionSource = 'args'; + continue; + } + + if (token === '--subtitle' || token.startsWith('--subtitle=') || token === '-s' || token.startsWith('-s=')) { + const parsed = parseArgValue(args, i); + const raw = String(parsed.value || '').trim().toLowerCase(); + if (raw === 'none') { + subtitle.mode = 'none'; + subtitle.explicitIds = []; + } else { + subtitle.explicitIds = parseTrackIdList(parsed.value); + subtitle.mode = 'explicit'; + } + subtitle.firstOnly = false; + subtitle.selectionSource = 'args'; + i += parsed.consumed; + continue; + } + + if (token === '--subtitle-lang-list' || token.startsWith('--subtitle-lang-list=')) { + const parsed = parseArgValue(args, i); + const langs = parseList(parsed.value, normalizeSelectionLanguage).filter((item) => item !== 'none'); + if (langs.includes('any')) { + subtitle.mode = 'all'; + subtitle.languages = []; + } else { + subtitle.mode = 'language'; + subtitle.languages = langs; + } + subtitle.selectionSource = 'args'; + i += parsed.consumed; + continue; + } + + if (token === '--subtitle-burned' || token.startsWith('--subtitle-burned=')) { + const parsed = parseArgValue(args, i); + const specificTrackId = parsed.value ? Number(parsed.value) : null; + if (Number.isFinite(specificTrackId) && specificTrackId > 0) { + subtitle.burnedTrackId = specificTrackId; + } else { + subtitle.burnBehavior = 'first'; + } + i += parsed.consumed; + continue; + } + + if (token === '--subtitle-default' || token.startsWith('--subtitle-default=')) { + const parsed = parseArgValue(args, i); + const specificTrackId = parsed.value ? Number(parsed.value) : null; + if (Number.isFinite(specificTrackId) && specificTrackId > 0) { + subtitle.defaultTrackId = specificTrackId; + } + i += parsed.consumed; + continue; + } + + if (token === '--subtitle-forced' || token.startsWith('--subtitle-forced=')) { + subtitle.forcedOnly = true; + const parsed = parseArgValue(args, i); + const specificTrackId = parsed.value ? Number(parsed.value) : null; + if (Number.isFinite(specificTrackId) && specificTrackId > 0) { + subtitle.forcedTrackId = specificTrackId; + } + i += parsed.consumed; + } + } +} + +function buildTrackSelectors(settings, presetProfile) { + const selectors = buildBaseTrackSelectors(settings || {}, presetProfile || null); + const args = splitArgs(settings?.handbrake_extra_args || ''); + applyArgOverrides(selectors, args); + + if (selectors.audio.mode === 'language' && selectors.audio.languages.length === 0) { + selectors.audio.mode = selectors.audio.firstOnly ? 'first' : 'all'; + } + + if (selectors.subtitle.mode === 'language' && selectors.subtitle.languages.length === 0) { + selectors.subtitle.mode = selectors.subtitle.firstOnly ? 'first' : 'none'; + } + + return selectors; +} + +function selectTrackIds(tracks, selector, trackType) { + const available = Array.isArray(tracks) ? tracks : []; + const selectable = trackType === 'subtitle' + ? available.filter((track) => !Boolean(track?.duplicate)) + : available; + if (selectable.length === 0) { + return []; + } + + if (selector.mode === 'none') { + return []; + } + + if (selector.mode === 'all') { + if (selector.firstOnly) { + return [selectable[0].id]; + } + return selectable.map((track) => track.id); + } + + if (selector.mode === 'explicit') { + const explicit = selectable + .filter((track) => selector.explicitIds.includes(track.id)) + .map((track) => track.id); + if (selector.firstOnly) { + return explicit.length > 0 ? [explicit[0]] : []; + } + return explicit; + } + + if (selector.mode === 'language') { + const matches = selectable.filter((track) => selector.languages.includes(track.language)); + if (selector.firstOnly) { + return matches.length > 0 ? [matches[0].id] : []; + } + return matches.map((track) => track.id); + } + + if (selector.mode === 'first') { + return [selectable[0].id]; + } + + if (trackType === 'audio') { + return [selectable[0].id]; + } + + return []; +} + +function resolveAudioEncoderAction(track, encoderToken, copyMask, fallbackEncoder) { + const normalizedToken = String(encoderToken || '').trim().toLowerCase(); + const sourceCodec = track?.codecToken || null; + + if (!normalizedToken || normalizedToken === 'preset-default') { + return { + type: 'preset-default', + encoder: 'preset-default', + label: 'Preset-Default (HandBrake)' + }; + } + + if (normalizedToken.startsWith('copy')) { + const explicitCopyCodec = normalizedToken.includes(':') + ? normalizedToken.split(':').slice(1).join(':').trim().toLowerCase() + : null; + + const normalizedMask = Array.isArray(copyMask) ? copyMask : []; + let canCopy = false; + let effectiveCodec = sourceCodec; + if (explicitCopyCodec) { + canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec); + } else if (sourceCodec && normalizedMask.length > 0) { + canCopy = normalizedMask.includes(sourceCodec); + // DTS-HD MA contains an embedded DTS core track. When dtshd is not in + // the copy mask but dts is, HandBrake will extract and copy the DTS core. + if (!canCopy && sourceCodec === 'dtshd' && normalizedMask.includes('dts')) { + canCopy = true; + effectiveCodec = 'dts'; + } + } + + if (canCopy) { + return { + type: 'copy', + encoder: normalizedToken, + label: `Copy (${effectiveCodec || track?.format || 'Quelle'})` + }; + } + + const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK; + return { + type: 'fallback', + encoder: fallback, + label: `Fallback Transcode (${fallback})` + }; + } + + return { + type: 'transcode', + encoder: normalizedToken, + label: `Transcode (${normalizedToken})` + }; +} + +function computeAudioTrackActions(track, selectedIndex, selector) { + const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : []; + + let encoderPlan = []; + if (selector.encoderSource === 'args' && availableEncoders.length > 0) { + const chosen = availableEncoders[Math.min(selectedIndex, availableEncoders.length - 1)]; + encoderPlan = [chosen]; + } else if (availableEncoders.length > 0) { + encoderPlan = [...availableEncoders]; + } else { + encoderPlan = ['preset-default']; + } + + const actions = encoderPlan.map((encoderToken) => resolveAudioEncoderAction( + track, + encoderToken, + selector.copyMask, + selector.fallbackEncoder + )); + + return { + actions, + summary: actions.map((item) => item.label).join(' + ') + }; +} + +function refreshAudioTrackActionsForPlanTitles(titles, settings = {}, presetProfile = null) { + const sourceTitles = Array.isArray(titles) ? titles : []; + if (sourceTitles.length === 0) { + return sourceTitles; + } + + const selectors = buildTrackSelectors(settings || {}, presetProfile || null); + const audioSelector = selectors?.audio && typeof selectors.audio === 'object' + ? selectors.audio + : {}; + + return sourceTitles.map((title) => { + const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : []; + if (audioTracks.length === 0) { + return title; + } + + const selectedTracks = audioTracks.filter((track) => Boolean(track?.selectedForEncode)); + const selectedIndexById = new Map( + selectedTracks + .map((track, index) => { + const trackId = Number(track?.id); + if (!Number.isFinite(trackId)) { + return null; + } + return [Math.trunc(trackId), index]; + }) + .filter(Boolean) + ); + + const nextAudioTracks = audioTracks.map((track) => { + if (!track?.selectedForEncode) { + return { + ...track, + encodeActions: [], + encodeActionSummary: 'Nicht übernommen' + }; + } + const numericTrackId = Number(track?.id); + const normalizedTrackId = Number.isFinite(numericTrackId) ? Math.trunc(numericTrackId) : null; + const selectedIndex = (normalizedTrackId !== null && selectedIndexById.has(normalizedTrackId)) + ? selectedIndexById.get(normalizedTrackId) + : 0; + const actions = computeAudioTrackActions(track, selectedIndex, audioSelector); + return { + ...track, + encodePreviewActions: actions.actions, + encodePreviewSummary: actions.summary, + encodeActions: actions.actions, + encodeActionSummary: actions.summary + }; + }); + + return { + ...title, + audioTracks: nextAudioTracks + }; + }); +} + +function computeSubtitleFlags(trackId, selectedTrackIds, selector) { + const selected = selectedTrackIds.includes(trackId); + if (!selected) { + return { + burned: false, + forced: false, + forcedOnly: false, + default: false, + flags: [] + }; + } + + const firstSelectedId = selectedTrackIds[0] || null; + const burned = selector.burnedTrackId + ? trackId === selector.burnedTrackId + : selector.burnBehavior === 'first' && trackId === firstSelectedId; + + const forced = selector.forcedTrackId + ? trackId === selector.forcedTrackId + : false; + + const forcedOnly = Boolean(selector.forcedOnly); + + const isDefault = selector.defaultTrackId + ? trackId === selector.defaultTrackId + : false; + + const flags = []; + if (burned) { + flags.push('burned'); + } + if (forced) { + flags.push('forced'); + } + if (forcedOnly) { + flags.push('forced-only'); + } + if (isDefault) { + flags.push('default'); + } + + return { + burned, + forced, + forcedOnly, + default: isDefault, + flags + }; +} + +function buildMediainfoReview({ + mediaFiles, + mediaInfoByPath, + settings, + presetProfile, + playlistAnalysis = null, + preferredEncodeTitleId = null, + selectedPlaylistId = null, + selectedMakemkvTitleId = null +}) { + const minLengthMinutes = clampNumber(settings?.makemkv_min_length_minutes, 0); + const minDurationSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); + const trackSelectors = buildTrackSelectors(settings || {}, presetProfile || null); + const lockedPlaylistId = normalizePlaylistId(selectedPlaylistId); + const manualSelectionMakemkvTitle = Number(selectedMakemkvTitleId); + const selectedPlaylistMatch = lockedPlaylistId + ? resolvePlaylistMatchByPlaylistId(playlistAnalysis, lockedPlaylistId) + : null; + const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired && !lockedPlaylistId); + + const titles = (mediaFiles || []).map((file, index) => { + const parsed = parseMediaInfoFile(mediaInfoByPath[file.path] || {}, file, index); + let playlistMatch = findPlaylistMatchForTitle(playlistAnalysis, parsed.makemkvTitleId); + if (lockedPlaylistId) { + const hasMappedPlaylist = Boolean(normalizePlaylistId(playlistMatch?.playlistId)); + if (!hasMappedPlaylist || selectedPlaylistMatch?.playlistId) { + playlistMatch = selectedPlaylistMatch || { + ...emptyPlaylistMatch(), + playlistId: lockedPlaylistId, + playlistFile: `${lockedPlaylistId}.mpls`, + segmentCommand: `strings BDMV/PLAYLIST/${lockedPlaylistId}.mpls | grep m2ts` + }; + } + } + return { + ...parsed, + selectedByMinLength: parsed.durationSeconds >= minDurationSeconds, + playlistMatch + }; + }); + + const selectedTitleIds = titles + .filter((title) => title.selectedByMinLength) + .map((title) => title.id); + + const candidateTitles = titles.filter((title) => selectedTitleIds.includes(title.id)); + const lockedCandidates = lockedPlaylistId + ? candidateTitles.filter((item) => normalizePlaylistId(item?.playlistMatch?.playlistId) === lockedPlaylistId) + : []; + const preferredTitleId = Number(preferredEncodeTitleId); + const preferredTitle = Number.isFinite(preferredTitleId) && preferredTitleId >= 0 + ? candidateTitles.find((item) => Number(item.makemkvTitleId) === preferredTitleId) || null + : null; + const preferredByManualSelection = Number.isFinite(manualSelectionMakemkvTitle) && manualSelectionMakemkvTitle >= 0 + ? candidateTitles.find((item) => Number(item.makemkvTitleId) === manualSelectionMakemkvTitle) || null + : null; + + let encodeInputTitle = null; + if (preferredByManualSelection && (!lockedPlaylistId || lockedCandidates.includes(preferredByManualSelection))) { + encodeInputTitle = preferredByManualSelection; + } else if (preferredTitle && (!lockedPlaylistId || lockedCandidates.includes(preferredTitle))) { + encodeInputTitle = preferredTitle; + } else if (lockedPlaylistId && lockedCandidates.length > 0) { + encodeInputTitle = lockedCandidates.reduce((best, current) => ( + !best || current.sizeBytes > best.sizeBytes ? current : best + ), null); + } else if (!playlistDecisionRequired) { + encodeInputTitle = candidateTitles.reduce((best, current) => ( + !best || current.sizeBytes > best.sizeBytes ? current : best + ), null); + } + + let normalizedTitles = titles.map((title) => { + const isEncodeInput = encodeInputTitle ? title.id === encodeInputTitle.id : false; + const selectedAudioIds = selectTrackIds(title.audioTracks, trackSelectors.audio, 'audio'); + const selectedSubtitleIds = selectTrackIds(title.subtitleTracks, trackSelectors.subtitle, 'subtitle'); + + const audioIndexById = new Map(selectedAudioIds.map((id, index) => [id, index])); + + const normalizedAudio = title.audioTracks.map((track) => { + const selectedByRule = selectedAudioIds.includes(track.id); + if (!selectedByRule) { + return { + ...track, + selectedByRule: false, + encodePreviewActions: [], + encodePreviewSummary: 'Nicht übernommen' + }; + } + + const selectedIndex = audioIndexById.get(track.id) || 0; + const actions = computeAudioTrackActions(track, selectedIndex, trackSelectors.audio); + return { + ...track, + selectedByRule: true, + encodePreviewActions: actions.actions, + encodePreviewSummary: actions.summary + }; + }); + + const normalizedSubtitle = title.subtitleTracks.map((track) => { + const selectedByRule = selectedSubtitleIds.includes(track.id); + const subtitleFlags = computeSubtitleFlags(track.id, selectedSubtitleIds, trackSelectors.subtitle); + const inferredForced = Boolean( + track?.forcedTrack + || String(track?.subtitleType || '').trim().toLowerCase() === 'forced' + ); + const inferredForcedOnly = Boolean( + track?.isForcedOnly + ?? track?.forcedOnly + ?? track?.subtitlePreviewForcedOnly + ?? inferredForced + ); + const inferredDefault = Boolean(track?.defaultFlag); + const subtitlePreviewFlags = []; + if (subtitleFlags.burned) { + subtitlePreviewFlags.push('burned'); + } + if (subtitleFlags.forced || inferredForced) { + subtitlePreviewFlags.push('forced'); + } + if (subtitleFlags.forcedOnly || inferredForcedOnly) { + subtitlePreviewFlags.push('forced-only'); + } + if (subtitleFlags.default || inferredDefault) { + subtitlePreviewFlags.push('default'); + } + const subtitlePreviewSummary = !selectedByRule + ? 'Nicht übernommen' + : (subtitlePreviewFlags.length > 0 + ? `Übernehmen (${subtitlePreviewFlags.join(', ')})` + : 'Übernehmen'); + + return { + ...track, + selectedByRule, + subtitlePreviewSummary, + subtitlePreviewFlags: selectedByRule ? subtitlePreviewFlags : [], + subtitlePreviewBurnIn: subtitleFlags.burned, + subtitlePreviewForced: selectedByRule ? (subtitleFlags.forced || inferredForced) : false, + subtitlePreviewForcedOnly: selectedByRule ? (subtitleFlags.forcedOnly || inferredForcedOnly) : false, + subtitlePreviewDefaultTrack: selectedByRule ? (subtitleFlags.default || inferredDefault) : false + }; + }); + + return { + ...title, + selectedForEncode: isEncodeInput, + encodeInput: isEncodeInput, + eligibleForEncode: title.selectedByMinLength, + playlistId: title.playlistMatch?.playlistId || null, + playlistFile: title.playlistMatch?.playlistFile || null, + playlistRecommended: Boolean(title.playlistMatch?.recommended), + playlistEvaluationLabel: title.playlistMatch?.evaluationLabel || null, + playlistSegmentCommand: title.playlistMatch?.segmentCommand || null, + playlistSegmentFiles: Array.isArray(title.playlistMatch?.segmentFiles) ? title.playlistMatch.segmentFiles : [], + audioTracks: normalizedAudio.map((track) => { + const selectedForEncode = isEncodeInput && track.selectedByRule; + return { + ...track, + selectedForEncode, + encodeActions: selectedForEncode ? track.encodePreviewActions : [], + encodeActionSummary: selectedForEncode ? track.encodePreviewSummary : 'Nicht übernommen' + }; + }), + subtitleTracks: normalizedSubtitle.map((track) => { + const selectedForEncode = isEncodeInput && track.selectedByRule; + return { + ...track, + selectedForEncode, + burnIn: selectedForEncode ? track.subtitlePreviewBurnIn : false, + forced: selectedForEncode ? track.subtitlePreviewForced : false, + forcedOnly: selectedForEncode ? track.subtitlePreviewForcedOnly : false, + defaultTrack: selectedForEncode ? track.subtitlePreviewDefaultTrack : false, + flags: selectedForEncode ? track.subtitlePreviewFlags : [], + subtitleActionSummary: selectedForEncode ? track.subtitlePreviewSummary : 'Nicht übernommen' + }; + }) + }; + }); + + if (lockedPlaylistId && encodeInputTitle) { + normalizedTitles = normalizedTitles.filter((item) => item.id === encodeInputTitle.id); + } + + const encodeInputPath = encodeInputTitle ? encodeInputTitle.filePath : null; + + const notes = [ + `Preset: ${trackSelectors.preset || '-'}`, + `Extra Args: ${trackSelectors.extraArgs || '(keine)'}`, + `Preset-Quelle: ${trackSelectors.presetProfileSource}`, + 'Preset-Defaults werden als Basis genutzt. HB_ARGS überschreibt diese, sobald Optionen gesetzt sind.' + ]; + + if (trackSelectors.presetProfileMessage) { + notes.push(`Preset-Hinweis: ${trackSelectors.presetProfileMessage}`); + } + if (lockedPlaylistId) { + notes.push(`Manuelle Playlist-Auswahl aktiv: ${lockedPlaylistId}.mpls`); + } + + const recommendedPlaylistId = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId || null); + const recommendedMakemkvTitleId = Number(playlistAnalysis?.recommendation?.titleId); + const recommendedReviewTitle = normalizedTitles.find((item) => item.playlistId === recommendedPlaylistId) + || (Number.isFinite(recommendedMakemkvTitleId) + ? normalizedTitles.find((item) => Number(item.makemkvTitleId) === recommendedMakemkvTitleId) + : null); + + return { + generatedAt: new Date().toISOString(), + minLengthMinutes, + selectors: trackSelectors, + playlistDecisionRequired, + playlistRecommendation: recommendedPlaylistId + ? { + playlistId: recommendedPlaylistId, + playlistFile: `${recommendedPlaylistId}.mpls`, + makemkvTitleId: Number.isFinite(recommendedMakemkvTitleId) ? recommendedMakemkvTitleId : null, + reviewTitleId: recommendedReviewTitle?.id || null, + reason: playlistAnalysis?.recommendation?.reason || null + } + : null, + titles: normalizedTitles, + selectedTitleIds, + encodeInputTitleId: encodeInputTitle?.id || null, + encodeInputPath, + titleSelectionRequired: Boolean(playlistDecisionRequired && !encodeInputPath), + notes + }; +} + +module.exports = { + parseDurationSeconds, + buildMediainfoReview, + refreshAudioTrackActionsForPlanTitles +}; diff --git a/backend/src/utils/errorMeta.js b/backend/src/utils/errorMeta.js new file mode 100644 index 0000000..62f5517 --- /dev/null +++ b/backend/src/utils/errorMeta.js @@ -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 +}; diff --git a/backend/src/utils/files.js b/backend/src/utils/files.js new file mode 100644 index 0000000..18832ef --- /dev/null +++ b/backend/src/utils/files.js @@ -0,0 +1,146 @@ +const fs = require('fs'); +const path = require('path'); + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function transliterateForFilename(input) { + return String(input || '') + // German — must come before NFD to get ae/oe/ue instead of just a/o/u + .replace(/ä/g, 'ae').replace(/Ä/g, 'Ae') + .replace(/ö/g, 'oe').replace(/Ö/g, 'Oe') + .replace(/ü/g, 'ue').replace(/Ü/g, 'Ue') + .replace(/ß/g, 'ss') + // Danish / Norwegian / Swedish + .replace(/å/g, 'a').replace(/Å/g, 'A') + .replace(/æ/g, 'ae').replace(/Æ/g, 'Ae') + .replace(/ø/g, 'o').replace(/Ø/g, 'O') + // Icelandic + .replace(/ð/g, 'd').replace(/Ð/g, 'D') + .replace(/þ/g, 'th').replace(/Þ/g, 'Th') + // NFD decomposition + strip combining diacritical marks (handles é→e, ñ→n, ç→c, etc.) + .normalize('NFD') + .replace(/\p{M}+/gu, '') + // Drop any remaining non-ASCII characters + .replace(/[^\x00-\x7F]/g, ''); +} + +function sanitizeFileName(input) { + return transliterateForFilename(String(input || 'untitled')) + .replace(/[^a-zA-Z0-9 ()[\]_+-]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + +function sanitizeFileNameWithExtension(input) { + const str = String(input || 'untitled'); + const ext = path.extname(str); + const base = ext ? str.slice(0, -ext.length) : str; + const cleanBase = sanitizeFileName(base); + const cleanExt = ext.toLowerCase().replace(/[^a-z0-9.]/g, ''); + return (cleanBase || 'untitled') + cleanExt; +} + +function renderTemplate(template, values) { + const rawSource = String(template || '${title} (${year})'); + const parseToken = (rawToken) => { + const token = String(rawToken || '').trim(); + if (!token) { + return { format: '', key: '' }; + } + const separatorIndex = token.indexOf(':'); + if (separatorIndex <= 0) { + return { format: '', key: token }; + } + return { + format: token.slice(0, separatorIndex).trim(), + key: token.slice(separatorIndex + 1).trim() + }; + }; + const applyFormat = (value, format) => { + const normalizedFormat = String(format || '').trim().toLowerCase(); + if (!normalizedFormat) { + return String(value); + } + // {0:key} => number with leading zero (width 2), e.g. 2 -> 02 + if (normalizedFormat === '0') { + const raw = String(value); + const match = raw.match(/^(-?)(\d+)$/); + if (match) { + const sign = match[1] || ''; + const digits = String(match[2] || ''); + return `${sign}${digits.padStart(2, '0')}`; + } + } + return String(value); + }; + const resolveToken = (rawKey) => { + const { format, key } = parseToken(rawKey); + const val = values[key]; + if (val === undefined || val === null || val === '') { + return 'unknown'; + } + return applyFormat(val, format); + }; + + const source = ( + /[{}]/.test(rawSource) + ? rawSource + : rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}') + ); + + // Support both ${key} and legacy {key} placeholders (+ recovered bare tokens). + return source + .replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key)) + .replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key)); +} + +function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) { + const files = findMediaFiles(dirPath, extensions); + if (files.length === 0) { + return null; + } + + return files.reduce((largest, file) => (largest === null || file.size > largest.size ? file : largest), null); +} + +function findMediaFiles(dirPath, extensions = ['.mkv', '.mp4']) { + const results = []; + + function walk(current) { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const abs = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(abs); + } else { + const ext = path.extname(entry.name).toLowerCase(); + if (!extensions.includes(ext)) { + continue; + } + + const stat = fs.statSync(abs); + results.push({ + path: abs, + size: stat.size + }); + } + } + } + + walk(dirPath); + results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path)); + return results; +} + +module.exports = { + ensureDir, + transliterateForFilename, + sanitizeFileName, + sanitizeFileNameWithExtension, + renderTemplate, + findLargestMediaFile, + findMediaFiles +}; diff --git a/backend/src/utils/playlistAnalysis.js b/backend/src/utils/playlistAnalysis.js new file mode 100644 index 0000000..63c5092 --- /dev/null +++ b/backend/src/utils/playlistAnalysis.js @@ -0,0 +1,1522 @@ +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; +const BRANCHING_OVERLAP_THRESHOLD = 0.6; +const BRANCHING_AUDIO_SIMILARITY_THRESHOLD = 0.75; + +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 extractPlaylistEqualityMapping(line) { + const raw = String(line || ''); + if (!raw) { + return null; + } + + // Robot output often ends with explicit args: + // ..., "00100.mpls","00091.mpls" + if (/^MSG:3309,/i.test(raw)) { + const quoted = []; + const regex = /"([^"]*)"/g; + let match = regex.exec(raw); + while (match) { + quoted.push(String(match[1] || '').trim()); + match = regex.exec(raw); + } + if (quoted.length >= 2) { + const aliasId = normalizePlaylistId(quoted[quoted.length - 2]); + const canonicalId = normalizePlaylistId(quoted[quoted.length - 1]); + if (aliasId && canonicalId && aliasId !== canonicalId) { + return { + aliasId, + canonicalId + }; + } + } + } + + // Text fallback: + // "Title 00100.mpls is equal to title 00091.mpls and was skipped" + const textMatch = raw.match(/title\s+(\d{5}\.mpls)\s+is equal to title\s+(\d{5}\.mpls)/i); + if (textMatch) { + const aliasId = normalizePlaylistId(textMatch[1]); + const canonicalId = normalizePlaylistId(textMatch[2]); + if (aliasId && canonicalId && aliasId !== canonicalId) { + return { + aliasId, + canonicalId + }; + } + } + + return null; +} + +function buildPlaylistAliasMap(lines) { + const aliasMap = new Map(); + for (const line of lines || []) { + const mapping = extractPlaylistEqualityMapping(line); + if (!mapping) { + continue; + } + const canonicalId = normalizePlaylistId(mapping.canonicalId); + const aliasId = normalizePlaylistId(mapping.aliasId); + if (!canonicalId || !aliasId || canonicalId === aliasId) { + continue; + } + if (!aliasMap.has(canonicalId)) { + aliasMap.set(canonicalId, new Set()); + } + aliasMap.get(canonicalId).add(aliasId); + } + + const normalized = {}; + for (const [canonicalId, aliasSet] of aliasMap.entries()) { + const aliases = Array.from(aliasSet) + .map((value) => normalizePlaylistId(value)) + .filter(Boolean) + .filter((value) => value !== canonicalId) + .sort(); + if (aliases.length > 0) { + normalized[canonicalId] = aliases; + } + } + return normalized; +} + +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 clamp01(value) { + const numeric = Number(value || 0); + if (!Number.isFinite(numeric)) { + return 0; + } + return Math.min(1, Math.max(0, numeric)); +} + +function normalizeTrackSignaturePart(value, fallback = 'na') { + const text = String(value || '').trim().toLowerCase(); + return text || fallback; +} + +function buildAudioSignatureTokens(title) { + const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : []; + if (audioTracks.length === 0) { + const count = Number(title?.audioTrackCount || 0); + return count > 0 ? [`count:${count}`] : []; + } + return uniqueOrdered(audioTracks.map((track) => ( + `${normalizeTrackSignaturePart(track?.language || track?.languageLabel || 'und', 'und')}` + + `|${normalizeTrackSignaturePart(track?.format)}` + + `|${normalizeTrackSignaturePart(track?.channels)}` + ))); +} + +function computeTokenJaccardSimilarity(leftTokens, rightTokens) { + const left = new Set((Array.isArray(leftTokens) ? leftTokens : []).map((value) => String(value || '').trim()).filter(Boolean)); + const right = new Set((Array.isArray(rightTokens) ? rightTokens : []).map((value) => String(value || '').trim()).filter(Boolean)); + if (left.size === 0 && right.size === 0) { + return 1; + } + if (left.size === 0 || right.size === 0) { + return 0; + } + let shared = 0; + for (const token of left) { + if (right.has(token)) { + shared += 1; + } + } + const unionSize = new Set([...left, ...right]).size; + return unionSize > 0 ? Number((shared / unionSize).toFixed(4)) : 0; +} + +function computeSegmentOverlapInfo(leftSegments, rightSegments) { + const leftNumbers = Array.isArray(leftSegments) + ? leftSegments.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value)) + : []; + const rightNumbers = Array.isArray(rightSegments) + ? rightSegments.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value)) + : []; + const leftSet = new Set(leftNumbers); + const rightSet = new Set(rightNumbers); + if (leftSet.size === 0 || rightSet.size === 0) { + return { + sharedCount: 0, + ratioToSmaller: 0, + ratioToLarger: 0, + jaccard: 0 + }; + } + let sharedCount = 0; + for (const value of leftSet) { + if (rightSet.has(value)) { + sharedCount += 1; + } + } + const smaller = Math.max(1, Math.min(leftSet.size, rightSet.size)); + const larger = Math.max(1, Math.max(leftSet.size, rightSet.size)); + const unionSize = new Set([...leftSet, ...rightSet]).size; + return { + sharedCount, + ratioToSmaller: Number((sharedCount / smaller).toFixed(4)), + ratioToLarger: Number((sharedCount / larger).toFixed(4)), + jaccard: unionSize > 0 ? Number((sharedCount / unionSize).toFixed(4)) : 0 + }; +} + +function buildDisjointSet(size) { + const parent = Array.from({ length: Math.max(0, Number(size || 0)) }, (_, index) => index); + const rank = Array.from({ length: parent.length }, () => 0); + const find = (value) => { + if (parent[value] !== value) { + parent[value] = find(parent[value]); + } + return parent[value]; + }; + const union = (left, right) => { + const rootLeft = find(left); + const rootRight = find(right); + if (rootLeft === rootRight) { + return; + } + if (rank[rootLeft] < rank[rootRight]) { + parent[rootLeft] = rootRight; + return; + } + if (rank[rootLeft] > rank[rootRight]) { + parent[rootRight] = rootLeft; + return; + } + parent[rootRight] = rootLeft; + rank[rootLeft] += 1; + }; + return { find, union }; +} + +function longestCommonSubsequence(leftValues, rightValues) { + const left = Array.isArray(leftValues) ? leftValues : []; + const right = Array.isArray(rightValues) ? rightValues : []; + if (left.length === 0 || right.length === 0) { + return []; + } + const dp = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0)); + for (let i = left.length - 1; i >= 0; i -= 1) { + for (let j = right.length - 1; j >= 0; j -= 1) { + if (left[i] === right[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + const output = []; + let i = 0; + let j = 0; + while (i < left.length && j < right.length) { + if (left[i] === right[j]) { + output.push(left[i]); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i += 1; + } else { + j += 1; + } + } + return output; +} + +function buildFallbackCoreSequence(clusterTitles) { + const titles = Array.isArray(clusterTitles) ? clusterTitles : []; + const minimumFrequency = Math.max(2, Math.ceil(titles.length * 0.75)); + const frequencyMap = new Map(); + const positionMap = new Map(); + for (const title of titles) { + const numbers = Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : []; + const seenInTitle = new Set(); + numbers.forEach((segment, index) => { + if (!Number.isFinite(segment)) { + return; + } + const normalized = Math.trunc(segment); + if (!frequencyMap.has(normalized)) { + frequencyMap.set(normalized, 0); + positionMap.set(normalized, []); + } + if (!seenInTitle.has(normalized)) { + frequencyMap.set(normalized, Number(frequencyMap.get(normalized) || 0) + 1); + seenInTitle.add(normalized); + } + positionMap.get(normalized).push(index); + }); + } + + return Array.from(frequencyMap.entries()) + .filter(([, frequency]) => Number(frequency || 0) >= minimumFrequency) + .map(([segment]) => { + const positions = positionMap.get(segment) || []; + const averagePosition = positions.length > 0 + ? positions.reduce((sum, value) => sum + Number(value || 0), 0) / positions.length + : Number.MAX_SAFE_INTEGER; + return { + segment, + averagePosition + }; + }) + .sort((a, b) => a.averagePosition - b.averagePosition || a.segment - b.segment) + .map((entry) => entry.segment); +} + +function buildClusterCoreSequence(clusterTitles) { + const titles = Array.isArray(clusterTitles) ? clusterTitles : []; + const sequences = titles + .map((title) => Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : []) + .filter((sequence) => sequence.length > 0); + if (sequences.length === 0) { + return []; + } + let coreSequence = sequences[0].slice(); + for (let index = 1; index < sequences.length; index += 1) { + coreSequence = longestCommonSubsequence(coreSequence, sequences[index]); + if (coreSequence.length === 0) { + break; + } + } + if (coreSequence.length >= Math.max(2, Math.floor(sequences[0].length / 3))) { + return coreSequence; + } + return buildFallbackCoreSequence(clusterTitles); +} + +function makeVariantBoundaryKey(beforeSegment, afterSegment) { + const before = Number.isFinite(Number(beforeSegment)) ? String(Math.trunc(Number(beforeSegment))).padStart(5, '0') : 'START'; + const after = Number.isFinite(Number(afterSegment)) ? String(Math.trunc(Number(afterSegment))).padStart(5, '0') : 'END'; + return `${before}->${after}`; +} + +function formatVariantSpanLabel(span) { + if (!span || !Array.isArray(span.segments) || span.segments.length === 0) { + return null; + } + const before = Number.isFinite(Number(span.beforeSegment)) + ? String(Math.trunc(Number(span.beforeSegment))).padStart(5, '0') + : 'START'; + const after = Number.isFinite(Number(span.afterSegment)) + ? String(Math.trunc(Number(span.afterSegment))).padStart(5, '0') + : 'END'; + const segmentList = span.segments + .map((segment) => String(Math.trunc(Number(segment))).padStart(5, '0')) + .join(', '); + return `${before}->${after}: [${segmentList}]`; +} + +function extractVariantSpansForTitle(segmentNumbers, coreSequence) { + const numbers = Array.isArray(segmentNumbers) + ? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value)) + : []; + const core = Array.isArray(coreSequence) + ? coreSequence.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value)) + : []; + if (numbers.length === 0 || core.length === 0) { + return []; + } + + const coreIndexBySegment = new Map(core.map((segment, index) => [segment, index])); + const spans = []; + let matchedCoreIndex = -1; + let currentSpan = []; + + const flushCurrentSpan = (afterSegment = null) => { + if (currentSpan.length === 0) { + return; + } + spans.push({ + beforeSegment: matchedCoreIndex >= 0 ? core[matchedCoreIndex] : null, + afterSegment, + segments: currentSpan.slice(), + boundaryKey: makeVariantBoundaryKey( + matchedCoreIndex >= 0 ? core[matchedCoreIndex] : null, + afterSegment + ) + }); + currentSpan = []; + }; + + for (const segment of numbers) { + const coreIndex = coreIndexBySegment.has(segment) ? coreIndexBySegment.get(segment) : -1; + const expectedCoreIndex = matchedCoreIndex + 1; + if (coreIndex === expectedCoreIndex) { + flushCurrentSpan(segment); + matchedCoreIndex = coreIndex; + continue; + } + if (coreIndex > expectedCoreIndex) { + flushCurrentSpan(core[expectedCoreIndex] ?? segment); + matchedCoreIndex = coreIndex; + continue; + } + currentSpan.push(segment); + } + + flushCurrentSpan(matchedCoreIndex + 1 < core.length ? core[matchedCoreIndex + 1] : null); + return spans; +} + +function buildClusterCommonRuns(coreSequence, titleSpanRows) { + const core = Array.isArray(coreSequence) + ? coreSequence.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value)) + : []; + if (core.length === 0) { + return []; + } + const boundaries = new Set(); + for (const row of titleSpanRows || []) { + for (const span of row?.variantSpans || []) { + if (!Number.isFinite(Number(span?.beforeSegment)) || !Number.isFinite(Number(span?.afterSegment))) { + continue; + } + boundaries.add(makeVariantBoundaryKey(span.beforeSegment, span.afterSegment)); + } + } + const runs = []; + let startIndex = 0; + for (let index = 0; index < core.length - 1; index += 1) { + const boundaryKey = makeVariantBoundaryKey(core[index], core[index + 1]); + if (!boundaries.has(boundaryKey)) { + continue; + } + const runSegments = core.slice(startIndex, index + 1); + if (runSegments.length > 0) { + runs.push({ + startSegment: runSegments[0], + endSegment: runSegments[runSegments.length - 1], + length: runSegments.length, + segments: runSegments + }); + } + startIndex = index + 1; + } + const tailSegments = core.slice(startIndex); + if (tailSegments.length > 0) { + runs.push({ + startSegment: tailSegments[0], + endSegment: tailSegments[tailSegments.length - 1], + length: tailSegments.length, + segments: tailSegments + }); + } + return runs; +} + +function computeLegacySegmentMetrics(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 computeStandaloneSegmentMetrics(title) { + const legacyMetrics = computeLegacySegmentMetrics(title?.segmentNumbers); + const transitions = Math.max(1, legacyMetrics.segmentCount - 1); + const directRatio = legacyMetrics.directSequenceSteps / transitions; + const nonBackwardRatio = 1 - (legacyMetrics.backwardJumps / transitions); + const boundedJumpRatio = 1 - (legacyMetrics.largeJumps / transitions); + const antiAlternatingRatio = 1 - legacyMetrics.alternatingRatio; + const correctedCoherence = clamp01( + (directRatio * 0.18) + + (clamp01(nonBackwardRatio) * 0.34) + + (clamp01(boundedJumpRatio) * 0.34) + + (clamp01(antiAlternatingRatio) * 0.14) + ); + return { + ...legacyMetrics, + clusterId: null, + clusterSize: 1, + averageSegmentOverlap: 0, + commonRuns: [], + commonRunCount: 0, + sharedCoreCount: 0, + sharedCoreRatio: 0, + sharedAdjacencyRatio: 0, + variantSpans: [], + variantSpanCount: 0, + variantSegmentCount: 0, + singleSegmentVariantSpans: 0, + multiSegmentVariantSpans: 0, + variantComplexity: 0, + legacySequenceCoherence: legacyMetrics.sequenceCoherence, + legacyStructureScore: legacyMetrics.score, + correctedCoherence: Number(correctedCoherence.toFixed(4)), + sequenceCoherence: Number(correctedCoherence.toFixed(4)), + score: Number((correctedCoherence * 35).toFixed(4)), + coherenceMode: 'standalone', + positionReason: 'Einzelkandidat oder kein Branching-Cluster erkannt.' + }; +} + +function buildClusterContexts(titles, options = {}) { + const rows = (Array.isArray(titles) ? titles : []).map((title, index) => ({ + ...title, + _clusterIndex: index, + _audioSignatureTokens: buildAudioSignatureTokens(title) + })); + if (rows.length === 0) { + return []; + } + const durationTolerance = Math.max( + 0, + Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS)) + ); + const dsu = buildDisjointSet(rows.length); + for (let leftIndex = 0; leftIndex < rows.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < rows.length; rightIndex += 1) { + const left = rows[leftIndex]; + const right = rows[rightIndex]; + const durationClose = Math.abs(Number(left?.durationSeconds || 0) - Number(right?.durationSeconds || 0)) <= durationTolerance; + if (!durationClose) { + continue; + } + const audioSimilarity = computeTokenJaccardSimilarity(left._audioSignatureTokens, right._audioSignatureTokens); + if (audioSimilarity < BRANCHING_AUDIO_SIMILARITY_THRESHOLD) { + continue; + } + const overlapInfo = computeSegmentOverlapInfo(left?.segmentNumbers, right?.segmentNumbers); + if (overlapInfo.ratioToSmaller < BRANCHING_OVERLAP_THRESHOLD) { + continue; + } + dsu.union(leftIndex, rightIndex); + } + } + + const grouped = new Map(); + rows.forEach((row, index) => { + const root = dsu.find(index); + if (!grouped.has(root)) { + grouped.set(root, []); + } + grouped.get(root).push(row); + }); + + return Array.from(grouped.values()) + .filter((clusterRows) => clusterRows.length > 1) + .sort((left, right) => right.length - left.length) + .map((clusterRows, clusterIndex) => { + const clusterId = `branching-${clusterIndex + 1}`; + const pairwiseOverlap = {}; + for (const row of clusterRows) { + const playlistId = normalizePlaylistId(row?.playlistId); + if (!playlistId) { + continue; + } + pairwiseOverlap[playlistId] = {}; + } + for (let i = 0; i < clusterRows.length; i += 1) { + for (let j = i + 1; j < clusterRows.length; j += 1) { + const left = clusterRows[i]; + const right = clusterRows[j]; + const leftPlaylistId = normalizePlaylistId(left?.playlistId); + const rightPlaylistId = normalizePlaylistId(right?.playlistId); + const overlapInfo = computeSegmentOverlapInfo(left?.segmentNumbers, right?.segmentNumbers); + if (leftPlaylistId && rightPlaylistId) { + pairwiseOverlap[leftPlaylistId][rightPlaylistId] = overlapInfo; + pairwiseOverlap[rightPlaylistId][leftPlaylistId] = overlapInfo; + } + } + } + + const coreSequence = buildClusterCoreSequence(clusterRows); + const titleSpanRows = clusterRows.map((row) => { + const variantSpans = extractVariantSpansForTitle(row?.segmentNumbers, coreSequence); + const variantSegmentCount = variantSpans.reduce((sum, span) => sum + span.segments.length, 0); + const singleSegmentVariantSpans = variantSpans.filter((span) => span.segments.length === 1).length; + const multiSegmentVariantSpans = variantSpans.filter((span) => span.segments.length > 1).length; + const variantComplexity = variantSpans.reduce((sum, span) => sum + (span.segments.length ** 2), 0); + const playlistId = normalizePlaylistId(row?.playlistId); + const overlapEntries = playlistId && pairwiseOverlap[playlistId] + ? Object.entries(pairwiseOverlap[playlistId]).map(([otherPlaylistId, info]) => ({ + otherPlaylistId, + ratioToSmaller: Number(info?.ratioToSmaller || 0), + sharedCount: Number(info?.sharedCount || 0) + })) + : []; + const averageSegmentOverlap = overlapEntries.length > 0 + ? overlapEntries.reduce((sum, entry) => sum + Number(entry.ratioToSmaller || 0), 0) / overlapEntries.length + : 0; + return { + row, + variantSpans, + variantSegmentCount, + singleSegmentVariantSpans, + multiSegmentVariantSpans, + variantComplexity, + overlapEntries, + averageSegmentOverlap: Number(averageSegmentOverlap.toFixed(4)) + }; + }); + + const commonRuns = buildClusterCommonRuns(coreSequence, titleSpanRows); + const maxVariantComplexity = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantComplexity || 0)), 0); + const maxVariantSegmentCount = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantSegmentCount || 0)), 0); + const maxVariantSpanCount = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantSpans.length || 0)), 0); + const legacyStructureScores = titleSpanRows.map((row) => Number(computeLegacySegmentMetrics(row.row?.segmentNumbers).score || 0)); + const minLegacyStructureScore = legacyStructureScores.length > 0 ? Math.min(...legacyStructureScores) : 0; + const maxLegacyStructureScore = legacyStructureScores.length > 0 ? Math.max(...legacyStructureScores) : 0; + + const titlesWithMetrics = titleSpanRows.map((spanRow, index) => { + const legacyMetrics = computeLegacySegmentMetrics(spanRow.row?.segmentNumbers); + const sharedCoreCount = coreSequence.length; + const segmentCount = Math.max(1, Number(legacyMetrics.segmentCount || 0)); + const sharedCoreRatio = sharedCoreCount > 0 ? sharedCoreCount / segmentCount : 0; + const variantSpanCount = spanRow.variantSpans.length; + const variantComplexityNorm = maxVariantComplexity > 0 + ? Number(spanRow.variantComplexity || 0) / maxVariantComplexity + : 0; + const variantSegmentNorm = maxVariantSegmentCount > 0 + ? Number(spanRow.variantSegmentCount || 0) / maxVariantSegmentCount + : 0; + const variantSpanNorm = maxVariantSpanCount > 0 + ? Number(variantSpanCount || 0) / maxVariantSpanCount + : 0; + const singleSegmentVariantRatio = variantSpanCount > 0 + ? spanRow.singleSegmentVariantSpans / variantSpanCount + : 1; + const multiSegmentPenalty = variantSpanCount > 0 + ? spanRow.multiSegmentVariantSpans / variantSpanCount + : 0; + const legacyRange = maxLegacyStructureScore - minLegacyStructureScore; + const legacyTieHint = legacyRange > 0 + ? clamp01((Number(legacyMetrics.score || 0) - minLegacyStructureScore) / legacyRange) + : 0; + const correctedCoherence = clamp01( + (sharedCoreRatio * 0.38) + + (spanRow.averageSegmentOverlap * 0.12) + + (singleSegmentVariantRatio * 0.22) + + ((1 - variantComplexityNorm) * 0.16) + + ((1 - variantSegmentNorm) * 0.08) + + ((1 - variantSpanNorm) * 0.04) + + (legacyTieHint * 0.02) + - (multiSegmentPenalty * 0.08) + ); + + let positionReason = 'nahe Branching-Variante'; + if (spanRow.multiSegmentVariantSpans === 0 && spanRow.variantSegmentCount <= Math.max(1, variantSpanCount)) { + positionReason = 'strukturell einfachster Basis-/Referenzpfad im Cluster'; + } else if (spanRow.multiSegmentVariantSpans > 0) { + positionReason = 'Variante mit zusätzlichen Mehrsegment-Branches'; + } + + return { + ...spanRow.row, + ...legacyMetrics, + clusterId, + clusterSize: clusterRows.length, + averageSegmentOverlap: spanRow.averageSegmentOverlap, + overlapEntries: spanRow.overlapEntries, + commonRuns, + commonRunCount: commonRuns.length, + sharedCoreCount, + sharedCoreRatio: Number(sharedCoreRatio.toFixed(4)), + sharedAdjacencyRatio: coreSequence.length > 1 ? 1 : 0, + variantSpans: spanRow.variantSpans, + variantSpanCount, + variantSegmentCount: spanRow.variantSegmentCount, + singleSegmentVariantSpans: spanRow.singleSegmentVariantSpans, + multiSegmentVariantSpans: spanRow.multiSegmentVariantSpans, + variantComplexity: spanRow.variantComplexity, + legacySequenceCoherence: legacyMetrics.sequenceCoherence, + legacyStructureScore: legacyMetrics.score, + correctedCoherence: Number(correctedCoherence.toFixed(4)), + sequenceCoherence: Number(correctedCoherence.toFixed(4)), + score: Number((((correctedCoherence * 60) + Math.min(18, (clusterRows.length - 1) * 9))).toFixed(4)), + coherenceMode: 'cluster_relative', + positionReason, + _legacyTieHint: legacyTieHint, + _clusterSortIndex: index + }; + }); + + const variantBoundaries = uniqueOrdered( + titleSpanRows + .flatMap((row) => row.variantSpans.map((span) => formatVariantSpanLabel(span))) + .filter(Boolean) + ); + + return { + id: clusterId, + size: clusterRows.length, + coreSequence, + commonRuns, + variantBoundaries, + pairwiseOverlap, + titles: titlesWithMetrics + }; + }); +} + +function computeSegmentMetrics(title, clusterMetricsByPlaylist = null) { + const playlistId = normalizePlaylistId(title?.playlistId); + if (playlistId && clusterMetricsByPlaylist && clusterMetricsByPlaylist[playlistId]) { + return clusterMetricsByPlaylist[playlistId]; + } + return computeStandaloneSegmentMetrics(title); +} + +function buildEvaluationLabel(metrics) { + if (!metrics || metrics.segmentCount === 0) { + return 'Keine Segmentliste aus TINFO:26 verfügbar'; + } + if (String(metrics?.coherenceMode || '') === 'cluster_relative' && Number(metrics?.clusterSize || 0) > 1) { + if (metrics.multiSegmentVariantSpans === 0 && metrics.variantSegmentCount <= Math.max(1, Number(metrics.variantSpanCount || 0))) { + return 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster'; + } + if (metrics.correctedCoherence >= 0.6) { + return 'nahe Branching-Variante mit plausibler Segmentstruktur'; + } + return 'komplexere Branching-Variante - Sichtprüfung empfohlen'; + } + if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) { + return 'Auffällige Segmentstruktur - Sichtprüfung empfohlen'; + } + if (metrics.correctedCoherence < 0.45) { + return 'unklare Segmentstruktur - Sichtprüfung empfohlen'; + } + return 'wahrscheinlich konsistente Segmentstruktur'; +} + +function normalizeRelativeScore(value, maxValue) { + const numericValue = Number(value || 0); + const numericMax = Number(maxValue || 0); + if (!Number.isFinite(numericValue) || numericValue <= 0) { + return 0; + } + if (!Number.isFinite(numericMax) || numericMax <= 0) { + return 0; + } + return Math.min(1, Math.max(0, numericValue / numericMax)); +} + +function scoreCandidates(groupTitles, options = {}) { + const titles = Array.isArray(groupTitles) ? groupTitles : []; + if (titles.length === 0) { + return []; + } + + const maxDurationSeconds = titles.reduce( + (max, title) => Math.max(max, Number(title?.durationSeconds || 0)), + 0 + ); + const maxAudioTrackCount = titles.reduce( + (max, title) => Math.max(max, Number(title?.audioTrackCount || 0)), + 0 + ); + const maxChapterCount = titles.reduce( + (max, title) => Math.max(max, Number(title?.chapters || 0)), + 0 + ); + const clusterContexts = buildClusterContexts(titles, options); + const clusterMetricsByPlaylist = {}; + const clusterSummaryRows = []; + for (const clusterContext of clusterContexts) { + const playlistIds = []; + for (const metrics of clusterContext.titles || []) { + const playlistId = normalizePlaylistId(metrics?.playlistId); + if (playlistId) { + clusterMetricsByPlaylist[playlistId] = metrics; + playlistIds.push(playlistId); + } + } + clusterSummaryRows.push({ + id: clusterContext.id, + size: clusterContext.size, + playlistIds: uniqueOrdered(playlistIds), + commonRuns: clusterContext.commonRuns, + variantBoundaries: clusterContext.variantBoundaries + }); + } + + return titles + .map((title) => { + const metrics = computeSegmentMetrics(title, clusterMetricsByPlaylist); + const structureScore = Number(metrics.score || 0); + const durationScore = normalizeRelativeScore(title.durationSeconds, maxDurationSeconds) * 20; + const audioScore = normalizeRelativeScore(title.audioTrackCount, maxAudioTrackCount) * 4; + const chapterScore = normalizeRelativeScore(title.chapters, maxChapterCount) * 6; + const totalScore = structureScore + durationScore + audioScore + chapterScore; + const overlapSummary = Array.isArray(metrics?.overlapEntries) + ? metrics.overlapEntries + .map((entry) => `${entry.otherPlaylistId}:${Number(entry.ratioToSmaller || 0).toFixed(3)}`) + .join(', ') + : ''; + const commonRunsSummary = Array.isArray(metrics?.commonRuns) + ? metrics.commonRuns + .map((run) => `${String(run.startSegment).padStart(5, '0')}-${String(run.endSegment).padStart(5, '0')}`) + .join(' | ') + : ''; + const variantSummary = Array.isArray(metrics?.variantSpans) + ? metrics.variantSpans + .map((span) => formatVariantSpanLabel(span)) + .filter(Boolean) + .join(' | ') + : ''; + const reasons = [ + `structure_score=${structureScore.toFixed(2)}`, + `duration_score=${durationScore.toFixed(2)}`, + `audio_score=${audioScore.toFixed(2)}`, + `chapter_score=${chapterScore.toFixed(2)}`, + `legacy_structure_score=${Number(metrics.legacyStructureScore || 0).toFixed(2)}`, + `legacy_sequence_coherence=${Number(metrics.legacySequenceCoherence || 0).toFixed(3)}`, + `corrected_coherence=${Number(metrics.correctedCoherence || 0).toFixed(3)}`, + `cluster=${metrics.clusterId || 'standalone'}${metrics.clusterSize > 1 ? `(${metrics.clusterSize})` : ''}`, + `avg_segment_overlap=${Number(metrics.averageSegmentOverlap || 0).toFixed(3)}`, + `core_coverage=${Number(metrics.sharedCoreRatio || 0).toFixed(3)}`, + `variant_spans=${Number(metrics.variantSpanCount || 0)}`, + `variant_segments=${Number(metrics.variantSegmentCount || 0)}`, + `single_segment_spans=${Number(metrics.singleSegmentVariantSpans || 0)}`, + `multi_segment_spans=${Number(metrics.multiSegmentVariantSpans || 0)}`, + `common_runs=${commonRunsSummary || '-'}`, + `variant_boundaries=${variantSummary || '-'}`, + `segment_overlap_map=${overlapSummary || '-'}`, + `position_reason=${metrics.positionReason || '-'}`, + `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(totalScore.toFixed(4)), + reasons, + structuralMetrics: metrics, + evaluationLabel: buildEvaluationLabel(metrics), + correctedCoherence: Number(metrics.correctedCoherence || 0), + legacySequenceCoherence: Number(metrics.legacySequenceCoherence || 0), + legacyStructureScore: Number(metrics.legacyStructureScore || 0), + clusterId: metrics.clusterId || null, + clusterSize: Number(metrics.clusterSize || 1), + averageSegmentOverlap: Number(metrics.averageSegmentOverlap || 0), + commonRuns: Array.isArray(metrics.commonRuns) ? metrics.commonRuns : [], + variantSpans: Array.isArray(metrics.variantSpans) ? metrics.variantSpans : [], + overlapEntries: Array.isArray(metrics.overlapEntries) ? metrics.overlapEntries : [], + positionReason: metrics.positionReason || null + }; + }) + .sort((a, b) => + b.score - a.score + || b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence + || b.legacyStructureScore - a.legacyStructureScore + || b.durationSeconds - a.durationSeconds + || b.sizeBytes - a.sizeBytes + || a.titleId - b.titleId + ) + .map((item, index) => ({ + ...item, + recommended: index === 0, + clusterSummary: clusterSummaryRows.find((row) => row.id === item.clusterId) || null + })); +} + +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 playlistAliasMap = buildPlaylistAliasMap(lines); + const parsedTitlesWithAliases = parsedTitles.map((item) => { + const playlistId = normalizePlaylistId(item?.playlistId); + const aliasesRaw = playlistId && Array.isArray(playlistAliasMap?.[playlistId]) + ? playlistAliasMap[playlistId] + : []; + const playlistAliases = uniqueOrdered(aliasesRaw.map((value) => normalizePlaylistId(value)).filter(Boolean)); + return { + ...item, + playlistAliases + }; + }); + 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 = parsedTitlesWithAliases + .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, { durationSimilaritySeconds }) + : []; + const recommendation = evaluatedCandidates[0] || null; + const rankedCandidatePlaylists = uniqueOrdered( + evaluatedCandidates.map((item) => normalizePlaylistId(item?.playlistId)).filter(Boolean) + ); + const candidatePlaylists = manualDecisionRequired + ? uniqueOrdered([...rankedCandidatePlaylists, ...candidatePlaylistsAll]) + : []; + const playlistSegments = buildPlaylistSegmentMap(decisionPool); + const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitlesWithAliases); + const branchingClusters = uniqueOrdered( + evaluatedCandidates + .map((item) => item?.clusterSummary) + .filter((value) => value && typeof value === 'object') + .map((value) => JSON.stringify(value)) + ).map((value) => JSON.parse(value)); + + return { + generatedAt: new Date().toISOString(), + reportedTitleCount, + minLengthMinutes: Number(minLengthMinutes || 0), + minLengthSeconds: minSeconds, + durationSimilaritySeconds, + titles: parsedTitlesWithAliases, + 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, + playlistAliasMap, + 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, + branchingClusters, + 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(parsedTitlesWithAliases) + ].slice(0, 60) + }; +} + +module.exports = { + normalizePlaylistId, + analyzePlaylistObfuscation +}; diff --git a/backend/src/utils/progressParsers.js b/backend/src/utils/progressParsers.js new file mode 100644 index 0000000..ccea89e --- /dev/null +++ b/backend/src/utils/progressParsers.js @@ -0,0 +1,126 @@ +function clampPercent(value) { + if (Number.isNaN(value) || value === Infinity || value === -Infinity) { + return null; + } + + return Math.max(0, Math.min(100, Number(value.toFixed(2)))); +} + +function parseGenericPercent(line) { + const match = line.match(/(\d{1,3}(?:\.\d+)?)\s?%/); + if (!match) { + return null; + } + + return clampPercent(Number(match[1])); +} + +function parseEta(line) { + const etaMatch = line.match(/ETA\s+([0-9:.hms-]+)/i); + if (!etaMatch) { + return null; + } + + const value = etaMatch[1].trim(); + if (!value || value.includes('--')) { + return null; + } + + return value.replace(/[),.;]+$/, ''); +} + +function parseMakeMkvProgress(line) { + const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/); + if (prgv) { + // Format: PRGV:current,total,max (official makemkv docs) + // current = per-file progress, total = overall progress across all files + const total = Number(prgv[2]); + const max = Number(prgv[3]); + + if (max > 0) { + return { percent: clampPercent((total / max) * 100), eta: null }; + } + } + + const percent = parseGenericPercent(line); + if (percent !== null) { + return { percent, eta: null }; + } + + return null; +} + +function parseHandBrakeProgress(line) { + const normalized = String(line || '').replace(/\s+/g, ' ').trim(); + const match = normalized.match(/Encoding:\s*(?:task\s+\d+\s+of\s+\d+,\s*)?(\d+(?:\.\d+)?)\s?%/i); + if (match) { + return { + percent: clampPercent(Number(match[1])), + eta: parseEta(normalized) + }; + } + + return null; +} + +function parseCdParanoiaProgress(line) { + // cdparanoia writes progress to stderr with \r overwrites. + // Formats seen in the wild: + // "Ripping track 1 of 12 progress: ( 34.21%)" + // "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" + const normalized = String(line || '').replace(/\s+/g, ' ').trim(); + + const progressMatch = normalized.match(/progress:\s*\(\s*(\d+(?:\.\d+)?)\s*%\s*\)/i); + if (progressMatch) { + const trackMatch = normalized.match(/track\s+(\d+)\s+of\s+(\d+)/i); + const currentTrack = trackMatch ? Number(trackMatch[1]) : null; + const totalTracks = trackMatch ? Number(trackMatch[2]) : null; + return { + percent: clampPercent(Number(progressMatch[1])), + currentTrack, + totalTracks, + eta: null + }; + } + + // "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" style – no clear percent here + // Fall back to generic percent match + const percent = parseGenericPercent(normalized); + if (percent !== null) { + return { percent, currentTrack: null, totalTracks: null, eta: null }; + } + + return null; +} + +function parseMkvmergeProgress(line) { + const normalized = String(line || '').replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + // Common mkvmerge output examples: + // "Progress: 42%" + // "#GUI#progress 42%" + const explicitMatch = normalized.match(/(?:^|\s)(?:progress:?|#GUI#progress)\s*(\d{1,3}(?:\.\d+)?)\s*%/i); + if (explicitMatch) { + return { + percent: clampPercent(Number(explicitMatch[1])), + eta: null + }; + } + + const percent = parseGenericPercent(normalized); + if (percent !== null) { + return { percent, eta: null }; + } + + return null; +} + +module.exports = { + parseMakeMkvProgress, + parseHandBrakeProgress, + parseCdParanoiaProgress, + parseMkvmergeProgress +}; diff --git a/backend/src/utils/serverTime.js b/backend/src/utils/serverTime.js new file mode 100644 index 0000000..f2fd97f --- /dev/null +++ b/backend/src/utils/serverTime.js @@ -0,0 +1,23 @@ +function padNumber(value, length = 2) { + return String(value).padStart(length, '0'); +} + +function formatTimezoneOffset(offsetMinutes) { + const sign = offsetMinutes >= 0 ? '+' : '-'; + const absoluteMinutes = Math.abs(offsetMinutes); + const hours = Math.floor(absoluteMinutes / 60); + const minutes = absoluteMinutes % 60; + return `${sign}${padNumber(hours)}:${padNumber(minutes)}`; +} + +function getServerTimestamp(input = new Date()) { + const date = input instanceof Date ? input : new Date(input); + + return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}` + + `T${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}` + + `.${padNumber(date.getMilliseconds(), 3)}${formatTimezoneOffset(-date.getTimezoneOffset())}`; +} + +module.exports = { + getServerTimestamp +}; diff --git a/backend/src/utils/validators.js b/backend/src/utils/validators.js new file mode 100644 index 0000000..1382482 --- /dev/null +++ b/backend/src/utils/validators.js @@ -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 +}; diff --git a/backend/tests/playlistAnalysis.branching.test.js b/backend/tests/playlistAnalysis.branching.test.js new file mode 100644 index 0000000..b22b697 --- /dev/null +++ b/backend/tests/playlistAnalysis.branching.test.js @@ -0,0 +1,100 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { analyzePlaylistObfuscation } = require('../src/utils/playlistAnalysis'); + +function buildTitleLines({ + titleId, + playlistId, + duration = '01:34:41', + chapters = 20, + audioTrackCount = 3, + subtitleTrackCount = 2, + segmentNumbers = [] +}) { + const lines = [ + `MSG:3016,0,0,"${playlistId}.mpls","${titleId}"`, + `TINFO:${titleId},16,0,"${playlistId}.mpls"`, + `TINFO:${titleId},9,0,"${duration}"`, + `TINFO:${titleId},8,0,"${chapters}"`, + `TINFO:${titleId},26,0,"${segmentNumbers.join(', ')}"` + ]; + + for (let index = 0; index < audioTrackCount; index += 1) { + lines.push(`SINFO:${titleId},${index},1,0,"Audio"`); + lines.push(`SINFO:${titleId},${index},3,0,"eng"`); + lines.push(`SINFO:${titleId},${index},4,0,"English"`); + lines.push(`SINFO:${titleId},${index},6,0,"DTS-HD MA"`); + lines.push(`SINFO:${titleId},${index},40,0,"5.1 ch"`); + } + + for (let index = 0; index < subtitleTrackCount; index += 1) { + const streamIndex = audioTrackCount + index; + lines.push(`SINFO:${titleId},${streamIndex},1,0,"Subtitle"`); + lines.push(`SINFO:${titleId},${streamIndex},3,0,"eng"`); + lines.push(`SINFO:${titleId},${streamIndex},4,0,"English"`); + lines.push(`SINFO:${titleId},${streamIndex},6,0,"PGS"`); + } + + return lines; +} + +test('branching cluster prefers the structurally simplest reference playlist', () => { + const lines = [ + ...buildTitleLines({ + titleId: 0, + playlistId: '00802', + segmentNumbers: [ + 866, 868, 829, 848, 830, 849, 831, 850, 832, 851, + 833, 852, 834, 853, 835, 854, 869, 870, 872, 855, + 837, 856, 838, 857, 839, 858, 840, 859, 841, 860, + 842, 861, 843, 862, 844, 863, 845, 864, 846 + ] + }), + ...buildTitleLines({ + titleId: 1, + playlistId: '00800', + segmentNumbers: [ + 847, 829, 848, 830, 849, 831, 850, 832, 851, 833, + 852, 834, 853, 835, 854, 836, 855, 837, 856, 838, + 857, 839, 858, 840, 859, 841, 860, 842, 861, 843, + 862, 844, 863, 845, 864, 846 + ] + }), + ...buildTitleLines({ + titleId: 3, + playlistId: '00804', + segmentNumbers: [ + 867, 868, 829, 848, 830, 849, 831, 850, 832, 851, + 833, 852, 834, 853, 835, 854, 869, 871, 872, 855, + 837, 856, 838, 857, 839, 858, 840, 859, 841, 860, + 842, 861, 843, 862, 844, 863, 845, 864, 846 + ] + }) + ]; + + const analysis = analyzePlaylistObfuscation(lines, 60, { + durationSimilaritySeconds: 90 + }); + + assert.equal(analysis?.recommendation?.playlistId, '00800'); + assert.deepEqual( + (analysis?.evaluatedCandidates || []).map((item) => item.playlistId), + ['00800', '00804', '00802'] + ); + assert.deepEqual(analysis?.candidatePlaylists || [], ['00800', '00804', '00802']); + + const preferred = (analysis?.evaluatedCandidates || [])[0]; + assert.equal(preferred?.recommended, true); + assert.equal(preferred?.evaluationLabel, 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster'); + + const variantSummary = (preferred?.variantSpans || []).map((span) => ({ + before: span.beforeSegment, + after: span.afterSegment, + segments: span.segments + })); + assert.deepEqual(variantSummary, [ + { before: null, after: 829, segments: [847] }, + { before: 854, after: 855, segments: [836] } + ]); +}); diff --git a/bin/HandBrakeCLI b/bin/HandBrakeCLI new file mode 100755 index 0000000..8077fbc Binary files /dev/null and b/bin/HandBrakeCLI differ diff --git a/db/database.sqlite b/db/database.sqlite new file mode 100644 index 0000000..e69de29 diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..84fe6f3 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,883 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE settings_schema ( + key TEXT PRIMARY KEY, + category TEXT NOT NULL, + label TEXT NOT NULL, + type TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 0, + description TEXT, + default_value TEXT, + options_json TEXT, + validation_json TEXT, + order_index INTEGER NOT NULL DEFAULT 0, + depends_on TEXT DEFAULT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE settings_values ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (key) REFERENCES settings_schema(key) ON DELETE CASCADE +); + +CREATE TABLE jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_job_id INTEGER, + title TEXT, + year INTEGER, + imdb_id TEXT, + poster_url TEXT, + omdb_json TEXT, + selected_from_omdb INTEGER DEFAULT 0, + migrate_tmdb INTEGER NOT NULL DEFAULT 0, + start_time TEXT, + end_time TEXT, + status TEXT NOT NULL, + output_path TEXT, + disc_device TEXT, + error_message TEXT, + detected_title TEXT, + last_state TEXT, + raw_path TEXT, + rip_successful INTEGER NOT NULL DEFAULT 0, + makemkv_info_json TEXT, + handbrake_info_json TEXT, + mediainfo_info_json TEXT, + encode_plan_json TEXT, + encode_input_path TEXT, + encode_review_confirmed INTEGER DEFAULT 0, + aax_checksum TEXT, + media_type TEXT, + job_kind TEXT, + is_multipart_movie INTEGER NOT NULL DEFAULT 0, + disc_number INTEGER, + metadata_fingerprint TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL +); + +CREATE INDEX idx_jobs_status ON jobs(status); +CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC); +CREATE INDEX idx_jobs_parent_job_id ON jobs(parent_job_id); +CREATE INDEX idx_jobs_job_kind ON jobs(job_kind); +CREATE INDEX idx_jobs_disc_number ON jobs(disc_number); +CREATE INDEX idx_jobs_metadata_fingerprint ON jobs(metadata_fingerprint); + +CREATE TABLE job_lineage_artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + source_job_id INTEGER, + media_type TEXT, + raw_path TEXT, + output_path TEXT, + reason TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_job_lineage_artifacts_job_id ON job_lineage_artifacts(job_id); +CREATE INDEX idx_job_lineage_artifacts_source_job_id ON job_lineage_artifacts(source_job_id); + +CREATE TABLE job_output_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + output_path TEXT NOT NULL, + label TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_job_output_folders_job_id ON job_output_folders(job_id); +CREATE UNIQUE INDEX idx_job_output_folders_unique ON job_output_folders(job_id, output_path); + +CREATE TABLE scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + script_body TEXT NOT NULL, + is_favorite INTEGER NOT NULL DEFAULT 0, + order_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_scripts_name ON scripts(name); +CREATE INDEX idx_scripts_order_index ON scripts(order_index, id); + +CREATE TABLE script_chains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + is_favorite INTEGER NOT NULL DEFAULT 0, + order_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_script_chains_name ON script_chains(name); +CREATE INDEX idx_script_chains_order_index ON script_chains(order_index, id); + +CREATE TABLE script_chain_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chain_id INTEGER NOT NULL, + position INTEGER NOT NULL, + step_type TEXT NOT NULL, + script_id INTEGER, + wait_seconds INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (chain_id) REFERENCES script_chains(id) ON DELETE CASCADE, + FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL +); + +CREATE INDEX idx_script_chain_steps_chain ON script_chain_steps(chain_id, position); + +CREATE TABLE hardware_metrics_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at TEXT NOT NULL, + day_key TEXT NOT NULL, + cpu_usage_percent REAL, + cpu_temperature_c REAL, + ram_usage_percent REAL, + ram_used_bytes INTEGER, + ram_total_bytes INTEGER, + gpu_usage_percent REAL, + gpu_temperature_c REAL, + vram_used_bytes INTEGER, + vram_total_bytes INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_hardware_metrics_history_captured_at ON hardware_metrics_history(captured_at); +CREATE INDEX idx_hardware_metrics_history_day_key ON hardware_metrics_history(day_key); + +CREATE TABLE pipeline_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + state TEXT NOT NULL, + active_job_id INTEGER, + progress REAL DEFAULT 0, + eta TEXT, + status_text TEXT, + context_json TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (active_job_id) REFERENCES jobs(id) +); + +CREATE TABLE cron_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + cron_expression TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + pushover_enabled INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT, + last_run_status TEXT, + next_run_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_cron_jobs_enabled ON cron_jobs(enabled); + +CREATE TABLE cron_run_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cron_job_id INTEGER NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + output TEXT, + error_message TEXT, + FOREIGN KEY (cron_job_id) REFERENCES cron_jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_cron_run_logs_job ON cron_run_logs(cron_job_id, id DESC); + +CREATE TABLE user_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + media_type TEXT NOT NULL DEFAULT 'all', + handbrake_preset TEXT, + extra_args TEXT, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_user_presets_media_type ON user_presets(media_type); + +CREATE TABLE user_preset_defaults ( + slot_key TEXT PRIMARY KEY, + preset_id INTEGER, + FOREIGN KEY (preset_id) REFERENCES user_presets(id) ON DELETE SET NULL +); + +CREATE INDEX idx_user_preset_defaults_preset_id ON user_preset_defaults(preset_id); + +CREATE TABLE IF NOT EXISTS aax_activation_bytes ( + checksum TEXT PRIMARY KEY, + activation_bytes TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('bluray_movie', NULL); +INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('bluray_series', NULL); +INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('dvd_movie', NULL); +INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('dvd_series', NULL); + +CREATE TABLE IF NOT EXISTS user_prefs ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE converter_scan_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rel_path TEXT NOT NULL UNIQUE, + entry_type TEXT NOT NULL, + file_size INTEGER, + detected_media_type TEXT, + detected_format TEXT, + job_id INTEGER, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE SET NULL +); + +CREATE INDEX idx_converter_scan_entries_rel_path ON converter_scan_entries(rel_path); +CREATE INDEX idx_converter_scan_entries_job_id ON converter_scan_entries(job_id); + +CREATE TABLE dvd_series_disc_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + provider_series_name TEXT, + season_number INTEGER, + season_type TEXT NOT NULL DEFAULT 'dvd', + disc_label TEXT, + disc_serial TEXT, + disc_number INTEGER, + language TEXT, + disc_signature TEXT NOT NULL, + fingerprint_json TEXT, + episode_map_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature); +CREATE INDEX idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number); + +CREATE TABLE dvd_series_title_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_job_id INTEGER NOT NULL, + disc_profile_id INTEGER, + title_index INTEGER NOT NULL, + title_kind TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 1, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + season_number INTEGER, + episode_start INTEGER, + episode_end INTEGER, + episode_ids_json TEXT, + mapping_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE, + FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL +); + +CREATE INDEX idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index); +CREATE INDEX idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number); + +-- ============================================================================= +-- Default Settings Seed +-- ============================================================================= + +-- Pfade – Eigentümer für alternative Verzeichnisse (inline in DynamicSettingsForm gerendert) +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL); + +-- Laufwerk +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_mode', 'auto'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('drive_device', 'Laufwerk', 'Device Pfad', 'path', 0, 'Nur für expliziten Modus, z.B. /dev/sr0.', '/dev/sr0', '[]', '{}', 20); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_device', '/dev/sr0'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_source_index', 'Laufwerk', 'MakeMKV Source Index', 'number', 1, 'Disc Index im Auto-Modus.', '0', '[]', '{"min":0,"max":20}', 30); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_source_index', '0'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('disc_auto_detection_enabled', 'Laufwerk', 'Automatische Disk-Erkennung', 'boolean', 1, 'Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt.', 'true', '[]', '{}', 35); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_auto_detection_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('disc_poll_interval_ms', 'Laufwerk', 'Polling Intervall (ms)', 'number', 1, 'Intervall für Disk-Erkennung.', '4000', '[]', '{"min":1000,"max":60000}', 40); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_poll_interval_ms', '4000'); + +-- Pfade +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]'); + +-- Monitoring +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('hardware_monitoring_enabled', 'Monitoring', 'Hardware Monitoring aktiviert', 'boolean', 1, 'Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates).', 'true', '[]', '{}', 130); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('hardware_monitoring_interval_ms', 'Monitoring', 'Hardware Monitoring Intervall (ms)', 'number', 1, 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.', '5000', '[]', '{"min":1000,"max":60000}', 140); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_interval_ms', '5000'); + +-- Logging +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true'); + +-- Tools +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_command', 'makemkvcon'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_registration_key', 'Tools', 'MakeMKV Key', 'string', 0, 'Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden.', NULL, '[]', '{}', 202); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_registration_key', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('mediainfo_command', 'Tools', 'Mediainfo Kommando', 'string', 1, 'Pfad oder Befehl für mediainfo.', 'mediainfo', '[]', '{"minLength":1}', 205); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_command', 'mediainfo'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_min_length_minutes', 'Tools', 'Minimale Titellänge (Minuten)', 'number', 1, 'Filtert kurze Titel beim Rip.', '60', '[]', '{"min":1,"max":1000}', 210); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_min_length_minutes', '60'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_command', 'Tools', 'HandBrake Kommando', 'string', 1, 'Pfad oder Befehl für HandBrakeCLI.', 'HandBrakeCLI', '[]', '{"minLength":1}', 215); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_command', 'HandBrakeCLI'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_max_parallel_cd_encodes', 'Tools', 'Max. parallele Audio CD Jobs', 'number', 1, 'Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit.', '2', '[]', '{"min":1,"max":12}', 226); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_cd_encodes', '2'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_max_total_encodes', 'Tools', 'Max. Encodes gesamt (medienunabhängig)', 'number', 1, 'Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits.', '3', '[]', '{"min":1,"max":24}', 227); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_total_encodes', '3'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_cd_bypasses_queue', 'Tools', 'Audio CDs: Queue-Reihenfolge überspringen', 'boolean', 1, 'Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin.', 'false', '[]', '{}', 228); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_cd_bypasses_queue', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('script_test_timeout_ms', 'Tools', 'Script-Test Timeout (ms)', 'number', 1, 'Timeout fuer Script-Tests in den Settings. 0 = kein Timeout.', '0', '[]', '{"min":0,"max":86400000}', 229); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('script_test_timeout_ms', '0'); + +-- Migration: Label für bestehende Installationen aktualisieren +UPDATE settings_schema SET label = 'Max. parallele Film/Video Encodes', description = 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.' WHERE key = 'pipeline_max_parallel_jobs' AND label = 'Parallele Jobs'; + +-- Tools – Blu-ray +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('mediainfo_extra_args_bluray', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (Blu-ray).', NULL, '[]', '{}', 300); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_rip_mode_bluray', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).', 'backup', '[{"label":"Backup","value":"backup"},{"label":"MKV","value":"mkv"}]', '{}', 305); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_bluray', 'backup'); +UPDATE settings_schema SET default_value = 'backup', description = 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).' WHERE key = 'makemkv_rip_mode_bluray'; +UPDATE settings_values SET value = 'backup' WHERE key = 'makemkv_rip_mode_bluray'; + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_analyze_extra_args_bluray', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (Blu-ray).', NULL, '[]', '{}', 310); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_analyze_extra_args_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_rip_extra_args_bluray', 'Tools', 'MakeMKV Rip Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Rip (Blu-ray).', NULL, '[]', '{}', 315); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_extra_args_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_preset_bluray', 'Tools', 'HandBrake Preset', 'string', 0, 'Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet.', 'H.264 MKV 1080p30', '[]', '{}', 320); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_preset_bluray', 'H.264 MKV 1080p30'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_extra_args_bluray', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (Blu-ray).', NULL, '[]', '{}', 325); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_audio_languages_bluray_movie', 'Tools', 'Review Audio-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 326); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_movie', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_subtitle_languages_bluray_movie', 'Tools', 'Review UT-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 327); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_movie', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_audio_languages_bluray_series', 'Tools', 'Review Audio-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 328); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_subtitle_languages_bluray_series', 'Tools', 'Review UT-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 329); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (Blu-ray).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 330); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv'); + +UPDATE settings_values +SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_bluray') +WHERE key IN ('handbrake_review_audio_languages_bluray_movie', 'handbrake_review_audio_languages_bluray_series') + AND (value IS NULL OR TRIM(value) = '') + AND EXISTS ( + SELECT 1 + FROM settings_values legacy + WHERE legacy.key = 'handbrake_review_audio_languages_bluray' + AND legacy.value IS NOT NULL + AND TRIM(legacy.value) <> '' + ); + +UPDATE settings_values +SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray') +WHERE key IN ('handbrake_review_subtitle_languages_bluray_movie', 'handbrake_review_subtitle_languages_bluray_series') + AND (value IS NULL OR TRIM(value) = '') + AND EXISTS ( + SELECT 1 + FROM settings_values legacy + WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray' + AND legacy.value IS NOT NULL + AND TRIM(legacy.value) <> '' + ); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'); + +-- Tools – DVD +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('mediainfo_extra_args_dvd', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (DVD).', NULL, '[]', '{}', 500); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_rip_mode_dvd', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'backup: vollständige Disc-Struktur im RAW-Ordner (einzig gültige Option für DVDs).', 'backup', '[{"label":"Backup","value":"backup"}]', '{}', 505); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'backup'); +UPDATE settings_schema SET default_value = 'backup', description = 'backup: vollständige Disc-Struktur im RAW-Ordner (einzig gültige Option für DVDs).', options_json = '[{"label":"Backup","value":"backup"}]' WHERE key = 'makemkv_rip_mode_dvd'; +UPDATE settings_values SET value = 'backup' WHERE key = 'makemkv_rip_mode_dvd'; + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_analyze_extra_args_dvd', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (DVD).', NULL, '[]', '{}', 510); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_analyze_extra_args_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_rip_extra_args_dvd', 'Tools', 'MakeMKV Rip Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Rip (DVD).', NULL, '[]', '{}', 515); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_extra_args_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_preset_dvd', 'Tools', 'HandBrake Preset', 'string', 0, 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.', 'H.264 MKV 480p30', '[]', '{}', 520); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_preset_dvd', 'H.264 MKV 480p30'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_extra_args_dvd', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (DVD).', NULL, '[]', '{}', 525); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_audio_languages_dvd_movie', 'Tools', 'Review Audio-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 526); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_movie', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_subtitle_languages_dvd_movie', 'Tools', 'Review UT-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 527); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_movie', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_audio_languages_dvd_series', 'Tools', 'Review Audio-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 528); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_subtitle_languages_dvd_series', 'Tools', 'Review UT-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 529); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (DVD).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 530); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv'); + +UPDATE settings_values +SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_dvd') +WHERE key IN ('handbrake_review_audio_languages_dvd_movie', 'handbrake_review_audio_languages_dvd_series') + AND (value IS NULL OR TRIM(value) = '') + AND EXISTS ( + SELECT 1 + FROM settings_values legacy + WHERE legacy.key = 'handbrake_review_audio_languages_dvd' + AND legacy.value IS NOT NULL + AND TRIM(legacy.value) <> '' + ); + +UPDATE settings_values +SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd') +WHERE key IN ('handbrake_review_subtitle_languages_dvd_movie', 'handbrake_review_subtitle_languages_dvd_series') + AND (value IS NULL OR TRIM(value) = '') + AND EXISTS ( + SELECT 1 + FROM settings_values legacy + WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd' + AND legacy.value IS NOT NULL + AND TRIM(legacy.value) <> '' + ); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'); + +-- Tools – CD +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', 'cdparanoia'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ( + 'cd_output_template', + 'Pfade', + 'CD Output Template', + 'string', + 1, + 'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', + '{artist} - {album} ({year})/{trackNr} {artist} - {title}', + '[]', + '{"minLength":1}', + 235 +); +INSERT OR IGNORE INTO settings_values (key, value) +VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}'); + +-- Tools – Audiobook +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})'); + +-- Pfade – CD +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD RAW-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL); + +-- Pfade – Audiobook +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL); + +-- Metadaten +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_api_key', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group'); + + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false'); + +-- Benachrichtigungen +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_token', 'Benachrichtigungen', 'PushOver Token', 'string', 0, 'Application Token für PushOver.', NULL, '[]', '{}', 510); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_token', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_user', 'Benachrichtigungen', 'PushOver User', 'string', 0, 'User-Key für PushOver.', NULL, '[]', '{}', 520); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_user', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_device', 'Benachrichtigungen', 'PushOver Device (optional)', 'string', 0, 'Optionales Ziel-Device in PushOver.', NULL, '[]', '{}', 530); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_device', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_title_prefix', 'Benachrichtigungen', 'PushOver Titel-Präfix', 'string', 1, 'Prefix im PushOver Titel.', 'Ripster', '[]', '{"minLength":1}', 540); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_title_prefix', 'Ripster'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_priority', 'Benachrichtigungen', 'PushOver Priority', 'number', 1, 'Priorität -2 bis 2.', '0', '[]', '{"min":-2,"max":2}', 550); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_priority', '0'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_timeout_ms', 'Benachrichtigungen', 'PushOver Timeout (ms)', 'number', 1, 'HTTP Timeout für PushOver Requests.', '7000', '[]', '{"min":1000,"max":60000}', 560); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_timeout_ms', '7000'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei manueller Auswahl senden', 'boolean', 1, 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).', 'true', '[]', '{}', 570); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_metadata_ready', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_rip_started', 'Benachrichtigungen', 'Bei Rip-Start senden', 'boolean', 1, 'Sendet beim Start des MakeMKV-Rips.', 'true', '[]', '{}', 580); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_rip_started', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_encoding_started', 'Benachrichtigungen', 'Bei Encode-Start senden', 'boolean', 1, 'Sendet beim Start von HandBrake.', 'true', '[]', '{}', 590); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_encoding_started', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_job_finished', 'Benachrichtigungen', 'Bei Erfolg senden', 'boolean', 1, 'Sendet bei erfolgreich abgeschlossenem Job.', 'true', '[]', '{}', 600); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_finished', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_job_error', 'Benachrichtigungen', 'Bei Fehler senden', 'boolean', 1, 'Sendet bei Fehlern in der Pipeline.', 'true', '[]', '{}', 610); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_error', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_job_cancelled', 'Benachrichtigungen', 'Bei Abbruch senden', 'boolean', 1, 'Sendet wenn Job manuell abgebrochen wurde.', 'true', '[]', '{}', 620); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_cancelled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_reencode_started', 'Benachrichtigungen', 'Bei Re-Encode Start senden', 'boolean', 1, 'Sendet beim Start von RAW Re-Encode.', 'true', '[]', '{}', 630); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reencode_started', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_reencode_finished', 'Benachrichtigungen', 'Bei Re-Encode Erfolg senden', 'boolean', 1, 'Sendet bei erfolgreichem RAW Re-Encode.', 'true', '[]', '{}', 640); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reencode_finished', 'true'); + +-- ============================================================================= +-- Converter Settings +-- ============================================================================= + +-- Converter – Pfade und Templates (Kategorie: Pfade) +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_raw_dir', 'Pfade', 'Converter Raw-Ordner', 'path', 0, 'Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw).', NULL, '[]', '{}', 800); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_movie_dir', 'Pfade', 'Converter Ausgabe (Video)', 'path', 0, 'Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies).', NULL, '[]', '{}', 801); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_audio_dir', 'Pfade', 'Converter Ausgabe (Audio)', 'path', 0, 'Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio).', NULL, '[]', '{}', 802); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_output_template_video', 'Pfade', 'Output-Template (Video)', 'string', 1, 'Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}.', '{title}', '[]', '{"minLength":1}', 810); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', '{title}'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_output_template_audio', 'Pfade', 'Output-Template (Audio)', 'string', 1, 'Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}.', '{artist} - {title}', '[]', '{"minLength":1}', 811); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', '{artist} - {title}'); + +-- Migration: Converter-Pfad-Settings in Kategorie 'Pfade' verschieben (falls bereits in DB vorhanden) +UPDATE settings_schema SET category = 'Pfade' WHERE key IN ('converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio') AND category = 'Converter'; + +-- Converter – Scan +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_scan_extensions', 'Converter', 'Erlaubte Datei-Endungen*', 'string', 1, 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus', '[]', '{"minLength":1}', 820); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_scan_extensions', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus'); +UPDATE settings_schema +SET + label = 'Erlaubte Datei-Endungen*', + description = 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', + default_value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus' +WHERE key = 'converter_scan_extensions'; +UPDATE settings_values +SET value = TRIM( + REPLACE( + REPLACE( + REPLACE(',' || LOWER(COALESCE(value, '')) || ',', ',aac,', ','), + ',,', ',' + ), + ',,', ',' + ), + ',' +) +WHERE key = 'converter_scan_extensions'; +UPDATE settings_values +SET value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus' +WHERE key = 'converter_scan_extensions' AND (value IS NULL OR TRIM(value) = ''); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_polling_enabled', 'Converter', 'Auto-Scan (Polling)', 'boolean', 1, 'Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen.', 'false', '[]', '{}', 830); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_enabled', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_polling_interval', 'Converter', 'Polling-Intervall (Sekunden)', 'number', 1, 'Intervall für automatischen Scan des Converter-Ordners.', '300', '[]', '{"min":30,"max":86400}', 840); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_interval', '300'); diff --git a/docs/api/converter.md b/docs/api/converter.md new file mode 100644 index 0000000..8900a07 --- /dev/null +++ b/docs/api/converter.md @@ -0,0 +1,290 @@ +# Converter API + +Endpunkte für den Datei-Converter: Datei-Explorer, Upload, Job-Verwaltung. + +Basis-Pfad: `/api/converter` + +--- + +## Scan & Explorer + +### `GET /api/converter/tree` + +Vollständiger Verzeichnisbaum des `converter_raw_dir` (FS-basiert, keine DB). + +**Response:** + +```json +{ + "tree": [ + { + "name": "filme", + "relPath": "filme", + "isDir": true, + "children": [ + { + "name": "film.mkv", + "relPath": "filme/film.mkv", + "isDir": false, + "size": 10485760 + } + ] + } + ] +} +``` + +--- + +### `GET /api/converter/browse?parent=relPath` + +DB-basierter Datei-Explorer. Gibt Einträge für einen Unterordner zurück. + +**Query-Parameter:** + +| Parameter | Typ | Beschreibung | +|---|---|---| +| `parent` | string | Relativer Pfad zum Unterordner (leer = Root) | + +**Response:** + +```json +{ + "entries": [ + { + "id": 1, + "rel_path": "filme/film.mkv", + "is_dir": false, + "size": 10485760, + "job_id": null + } + ], + "rawDir": "/data/output/converter-raw" +} +``` + +--- + +### `POST /api/converter/scan` + +Manuellen Scan des `converter_raw_dir` auslösen. Aktualisiert `converter_scan_entries` in der DB. + +**Response:** + +```json +{ "result": { "added": 3, "removed": 1 } } +``` + +--- + +## Jobs erstellen + +### `POST /api/converter/jobs/from-selection` + +Jobs aus im Datei-Explorer ausgewählten Dateien erstellen. + +**Body:** + +```json +{ + "relPaths": ["filme/film.mkv", "musik/track.flac"], + "audioMode": "individual" +} +``` + +| Feld | Typ | Beschreibung | +|---|---|---| +| `relPaths` | string[] | Relative Pfade der ausgewählten Dateien | +| `audioMode` | string | `individual` (ein Job pro Datei) oder `shared` (ein gemeinsamer Job) | + +**Response:** + +```json +{ "jobs": [{ "id": 42, "status": "READY_TO_START" }] } +``` + +--- + +### `POST /api/converter/create-jobs` + +Jobs aus DB-Scan-Einträgen erstellen. + +**Body:** + +```json +{ + "entries": [ + { "relPath": "filme/film.mkv", "converterMediaType": "video" } + ] +} +``` + +--- + +### `POST /api/converter/upload` + +Dateien hochladen (Multipart, max. 50 Dateien). + +**Form-Felder:** + +| Feld | Typ | Beschreibung | +|---|---|---| +| `files` | File[] | Hochzuladende Dateien | +| `folderName` | string | (Optional) Ziel-Unterordner | + +**Response:** + +```json +{ + "folders": [{ "folderRelPath": "upload-2026-03-30", "fileCount": 2 }] +} +``` + +--- + +## Job-Verwaltung + +### `GET /api/converter/jobs` + +Alle Converter-Jobs zurückgeben. + +**Response:** + +```json +{ "jobs": [{ "id": 42, "status": "READY_TO_START", "title": "film.mkv" }] } +``` + +--- + +### `GET /api/converter/jobs/:jobId` + +Einzelnen Converter-Job abrufen. + +--- + +### `POST /api/converter/jobs/:jobId/config` + +Konfigurationsentwurf für einen Job speichern (persistiert in `encode_plan_json`). + +**Body:** Partielles Konfig-Objekt (Ausgabeformat, Presets, Metadaten, Track-Auswahl). + +--- + +### `POST /api/converter/jobs/:jobId/assign-files` + +Dateien einem bestehenden (noch nicht gestarteten) Job hinzufügen. + +**Body:** + +```json +{ "relPaths": ["musik/track2.flac"] } +``` + +--- + +### `POST /api/converter/jobs/:jobId/remove-file` + +Datei aus einem Job entfernen (per relativer Pfad). + +**Body:** + +```json +{ "relPath": "musik/track2.flac" } +``` + +--- + +### `POST /api/converter/jobs/:jobId/remove-input` + +Datei aus einem Job entfernen (per absolutem Eingabepfad). + +**Body:** + +```json +{ "inputPath": "/data/output/converter-raw/musik/track2.flac" } +``` + +--- + +### `POST /api/converter/jobs/:jobId/start` + +Job mit finaler Konfiguration starten. + +**Body:** + +```json +{ + "converterMediaType": "video", + "outputFormat": "mkv", + "userPreset": "H.264 MKV 1080p30", + "trackSelection": {}, + "handBrakeTitleId": 1, + "audioFormatOptions": {} +} +``` + +--- + +### `POST /api/converter/jobs/:jobId/cancel` + +Laufenden Job abbrechen. + +--- + +### `DELETE /api/converter/jobs/:jobId` + +Job aus der DB löschen. + +--- + +## Datei-Operationen + +Alle Datei-Operationen arbeiten direkt auf dem Dateisystem (ohne DB-Aktualisierung). Ein anschließender Scan-Aufruf synchronisiert die DB. + +### `DELETE /api/converter/files` + +Datei oder Ordner löschen. + +**Body:** + +```json +{ "relPath": "filme/film.mkv" } +``` + +--- + +### `POST /api/converter/files/rename` + +Datei oder Ordner umbenennen. + +**Body:** + +```json +{ "relPath": "filme/film.mkv", "newName": "film-neu.mkv" } +``` + +--- + +### `POST /api/converter/files/move` + +Datei oder Ordner verschieben. + +**Body:** + +```json +{ "relPath": "filme/film.mkv", "targetParentRelPath": "archiv" } +``` + +`targetParentRelPath = ""` verschiebt in das Root-Verzeichnis. + +--- + +### `POST /api/converter/files/folder` + +Neuen Ordner anlegen. + +**Body:** + +```json +{ "parentRelPath": "filme", "name": "neu" } +``` diff --git a/docs/api/crons.md b/docs/api/crons.md new file mode 100644 index 0000000..df22460 --- /dev/null +++ b/docs/api/crons.md @@ -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`) diff --git a/docs/api/downloads.md b/docs/api/downloads.md new file mode 100644 index 0000000..7144bb0 --- /dev/null +++ b/docs/api/downloads.md @@ -0,0 +1,143 @@ +# Downloads API + +Endpunkte für die Download-Queue. Ausgabedateien aus der Job-Historie können als ZIP heruntergeladen werden. + +Basis-Pfad: `/api/downloads` + +--- + +## `GET /api/downloads` + +Liste aller Download-Einträge plus Zusammenfassung. + +**Response:** + +```json +{ + "items": [ + { + "id": "dl-42-raw", + "jobId": 42, + "target": "raw", + "status": "ready", + "archiveName": "Job_42_raw.zip", + "outputPath": null, + "createdAt": "2026-03-30T10:00:00.000Z", + "updatedAt": "2026-03-30T10:01:00.000Z", + "errorMessage": null + } + ], + "summary": { + "total": 3, + "pending": 1, + "processing": 0, + "ready": 1, + "failed": 1 + } +} +``` + +**Status-Werte:** + +| Status | Beschreibung | +|---|---| +| `pending` | In der Queue, noch nicht gestartet | +| `processing` | ZIP wird gerade erstellt | +| `ready` | ZIP fertig, Download verfügbar | +| `failed` | Fehler bei der Erstellung | + +--- + +## `GET /api/downloads/summary` + +Nur die Zusammenfassung der Download-Queue (ohne Item-Liste). + +**Response:** + +```json +{ + "summary": { + "total": 3, + "pending": 1, + "processing": 0, + "ready": 1, + "failed": 1 + } +} +``` + +--- + +## `POST /api/downloads/history/:jobId` + +Job-Ausgabe in die Download-Queue einreihen. + +**Parameter:** + +| Parameter | Typ | Beschreibung | +|---|---|---| +| `jobId` | number | ID des Jobs in der Historie | + +**Body:** + +```json +{ + "target": "raw", + "outputPath": null +} +``` + +| Feld | Typ | Default | Beschreibung | +|---|---|---|---| +| `target` | string | `raw` | `raw` (RAW-Dateien) oder `movie` (Ausgabedateien) | +| `outputPath` | string | `null` | Optionaler expliziter Ausgabepfad | + +**Response (201 Created):** + +```json +{ + "created": true, + "id": "dl-42-raw", + "status": "pending", + "summary": { "total": 1, "pending": 1, "processing": 0, "ready": 0, "failed": 0 } +} +``` + +Ist der Eintrag bereits vorhanden, wird `201` durch `200` ersetzt und `created: false` zurückgegeben. + +--- + +## `GET /api/downloads/:id/file` + +Fertige ZIP-Datei herunterladen. + +**Parameter:** + +| Parameter | Typ | Beschreibung | +|---|---|---| +| `id` | string | Download-ID (z. B. `dl-42-raw`) | + +**Response:** Datei-Download (`Content-Disposition: attachment`). + +Schlägt fehl mit `404`, wenn kein Download-Eintrag mit dieser ID existiert oder die Datei noch nicht bereit ist. + +--- + +## `DELETE /api/downloads/:id` + +Download-Eintrag löschen (auch fertige ZIP-Dateien werden vom Dateisystem entfernt). + +**Response:** + +```json +{ + "ok": true, + "summary": { "total": 2, "pending": 0, "processing": 0, "ready": 2, "failed": 0 } +} +``` + +--- + +## WebSocket + +Statusänderungen werden über `DOWNLOADS_UPDATED` in Echtzeit gemeldet. Vollständige Dokumentation: [WebSocket Events](websocket.md#downloads_updated). diff --git a/docs/api/history.md b/docs/api/history.md new file mode 100644 index 0000000..eb4a380 --- /dev/null +++ b/docs/api/history.md @@ -0,0 +1,229 @@ +# History API + +Endpunkte für Job-Historie, Metadaten-Nachpflege, Orphan-Import und Löschoperationen. + +--- + +## GET /api/history + +Liefert Jobs mit optionalen Filtern. + +**Query-Parameter:** + +| Parameter | Typ | Beschreibung | +|----------|-----|-------------| +| `status` | string | Einzelstatus-Filter (legacy-kompatibel). | +| `statuses` | string | Mehrfachstatus als CSV, z. B. `FINISHED,ERROR`. | +| `search` | string | Suche in Titel-/IMDb-Feldern. | +| `limit` | number | Maximale Anzahl Ergebnisse. | +| `lite` | bool | Ohne Dateisystem-Checks (schneller). | +| `includeChildren` | bool | Child-Jobs (z. B. Serien-/Multipart-Kinder) explizit einbeziehen. | + +**Beispiel:** + +```text +GET /api/history?statuses=FINISHED,ERROR&search=Inception&limit=50&lite=1 +``` + +**Response (Beispiel):** + +```json +{ + "jobs": [ + { + "id": 42, + "status": "FINISHED", + "title": "Inception", + "job_kind": "bluray", + "raw_path": "/mnt/raw/Inception - RAW - job-42", + "output_path": "/mnt/movies/Inception (2010)/Inception (2010).mkv", + "mediaType": "bluray", + "ripSuccessful": true, + "encodeSuccess": true, + "created_at": "2026-03-10T08:00:00.000Z", + "updated_at": "2026-03-10T10:00:00.000Z" + } + ] +} +``` + +--- + +## GET /api/history/:id + +Liefert Job-Detail. + +**Query-Parameter:** + +| Parameter | Typ | Standard | Beschreibung | +|----------|-----|---------|-------------| +| `includeLogs` | bool | `false` | Prozesslog laden. | +| `includeLiveLog` | bool | `false` | Live-/Tail-Log laden. | +| `includeAllLogs` | bool | `false` | vollständiges Log statt Tail. | +| `logTailLines` | number | `800` | Tail-Länge falls nicht `includeAllLogs`. | +| `lite` | bool | `false` | Detailantwort ohne FS-Checks. | + +**Response (Beispiel):** + +```json +{ + "job": { + "id": 42, + "status": "FINISHED", + "makemkvInfo": {}, + "mediainfoInfo": {}, + "handbrakeInfo": {}, + "encodePlan": {}, + "log": "...", + "log_count": 1, + "logMeta": { + "loaded": true, + "total": 800, + "returned": 800, + "truncated": true + } + } +} +``` + +--- + +## GET /api/history/orphan-raw + +Sucht RAW-Ordner ohne zugehörigen Job. + +## POST /api/history/orphan-raw/import + +Importiert einen RAW-Ordner als Job und triggert optional die Analyse des Imports. + +**Request:** + +```json +{ "rawPath": "/mnt/raw/Inception (2010) [tt1375666] - RAW - job-99" } +``` + +**Response (Beispiel):** + +```json +{ + "job": { "id": 77, "status": "FINISHED" }, + "activation": { "started": true }, + "activationError": null +} +``` + +--- + +## POST /api/history/:id/metadata/assign + +Weist TMDb-Metadaten nachträglich zu oder aktualisiert bestehende Film-/Serien-Metadaten eines Jobs. + +## POST /api/history/:id/cd/assign + +Weist CD-Metadaten (z. B. MusicBrainz) nachträglich zu. + +## POST /api/history/:id/error/ack + +Quittiert einen Fehlerzustand im Historieneintrag. + +--- + +## GET /api/history/:id/delete-preview + +Liefert eine Vorschau der löschbaren Pfade (inkl. Scope auf verknüpfte Jobs). + +**Query-Parameter:** + +| Parameter | Typ | Standard | Beschreibung | +|----------|-----|---------|-------------| +| `includeRelated` | bool | `true` | Verknüpfte Jobs in die Vorschau einbeziehen. | + +**Response (Beispiel):** + +```json +{ + "preview": { + "jobId": 42, + "relatedJobs": [{ "id": 42 }, { "id": 73 }], + "pathCandidates": { + "raw": [{ "path": "/mnt/raw/...", "exists": true, "isDirectory": true }], + "movie": [{ "path": "/mnt/movies/...", "exists": true, "isDirectory": true }] + } + } +} +``` + +--- + +## POST /api/history/:id/delete-files + +Löscht Dateien eines Jobs, behält DB-Eintrag. + +**Request:** + +```json +{ + "target": "both", + "includeRelated": true, + "selectedRawPaths": ["/mnt/raw/Inception - RAW - Disc1"], + "selectedMoviePaths": ["/mnt/movies/Inception (2010)"] +} +``` + +`target`: `raw` | `movie` | `both` + +`selectedRawPaths` / `selectedMoviePaths` sind optional und begrenzen die Löschung auf ausgewählte Pfade aus der Vorschau. + +--- + +## POST /api/history/:id/delete + +Löscht Job aus DB; optional auch Dateien. + +**Request:** + +```json +{ + "target": "both", + "includeRelated": true, + "resetDriveState": false, + "keepDetectedDevice": true, + "preserveRawForImportJobs": false, + "selectedRawPaths": ["/mnt/raw/Inception - RAW - Disc1"], + "selectedMoviePaths": ["/mnt/movies/Inception (2010)"] +} +``` + +`target`: `none` | `raw` | `movie` | `both` + +**Response (Beispiel):** + +```json +{ + "deleted": true, + "jobId": 42, + "fileTarget": "both", + "fileSummary": { + "target": "both", + "raw": { "filesDeleted": 10 }, + "movie": { "filesDeleted": 1 } + }, + "uiReset": { + "reset": true, + "state": "IDLE" + }, + "safeguards": { + "containsOrphanRawImportJob": false, + "resetDriveStateApplied": false, + "keepDetectedDeviceApplied": true + } +} +``` + +--- + +## Hinweise + +- Ein aktiver Pipeline-Job kann nicht gelöscht werden (`409`). +- Löschoperationen sind irreversibel. +- Bei Serien-/Multipart-Containern steuern `includeRelated` und Pfadselektionen den genauen Scope. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..8896e31 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,93 @@ +# Anhang: API-Referenz + +REST- und WebSocket-Schnittstellen für Integration, Automatisierung und Debugging. + +## Basis-URL + +```text +http://localhost:3001 +``` + +API-Prefix: `/api` + +## API-Gruppen + +
+ +- :material-heart-pulse: **Health** + + --- + + Service-Liveness. + + `GET /api/health` + +- :material-pipe: **Pipeline API** + + --- + + Analyse, Start/Retry/Cancel, Queue, Re-Encode. + + [:octicons-arrow-right-24: Pipeline API](pipeline.md) + +- :material-cog: **Settings API** + + --- + + Einstellungen, Skripte/Ketten, User-Presets. + + [:octicons-arrow-right-24: Settings API](settings.md) + +- :material-history: **History API** + + --- + + Job-Historie, Orphan-Import, Löschoperationen. + + [:octicons-arrow-right-24: History API](history.md) + +- :material-clock-outline: **Cron API** + + --- + + Zeitgesteuerte Skript-/Kettenausführung. + + [:octicons-arrow-right-24: Cron API](crons.md) + +- :material-swap-horizontal: **Converter API** + + --- + + Datei-Explorer, Upload, Job-Verwaltung für den Converter. + + [:octicons-arrow-right-24: Converter API](converter.md) + +- :material-download: **Downloads API** + + --- + + Download-Queue für Ausgabedateien aus der Job-Historie. + + [:octicons-arrow-right-24: Downloads API](downloads.md) + +- :material-lightning-bolt: **Runtime Activities API** + + --- + + Laufende und abgeschlossene Aktivitäten (Skripte, Ketten, Cron, Tasks). + + [:octicons-arrow-right-24: Runtime Activities](runtime-activities.md) + +- :material-websocket: **WebSocket Events** + + --- + + Pipeline-, Queue-, Disk-, Settings-, Cron-, Converter- und Download-Events. + + [:octicons-arrow-right-24: WebSocket](websocket.md) + +
+ +## Hinweis + +Ripster hat keine eingebaute Authentifizierung und ist für lokalen, geschützten Betrieb gedacht. diff --git a/docs/api/pipeline.md b/docs/api/pipeline.md new file mode 100644 index 0000000..b691346 --- /dev/null +++ b/docs/api/pipeline.md @@ -0,0 +1,534 @@ +# Pipeline API + +Endpunkte zur Steuerung des Pipeline-Workflows. + +--- + +## GET /api/pipeline/state + +Liefert aktuellen Pipeline- und Hardware-Monitoring-Snapshot. + +**Response (Beispiel):** + +```json +{ + "pipeline": { + "state": "READY_TO_ENCODE", + "activeJobId": 42, + "progress": 0, + "eta": null, + "statusText": "Mediainfo bestätigt - Encode manuell starten", + "context": { + "jobId": 42 + }, + "jobProgress": { + "42": { + "state": "MEDIAINFO_CHECK", + "progress": 68.5, + "eta": null, + "statusText": "MEDIAINFO_CHECK 68.50%" + } + }, + "queue": { + "maxParallelJobs": 1, + "runningCount": 1, + "queuedCount": 2, + "runningJobs": [], + "queuedJobs": [] + } + }, + "hardwareMonitoring": { + "enabled": true, + "intervalMs": 5000, + "updatedAt": "2026-03-10T09:00:00.000Z", + "sample": { + "cpu": {}, + "memory": {}, + "gpu": {}, + "storage": {} + }, + "error": null + } +} +``` + +--- + +## POST /api/pipeline/analyze + +Startet Disc-Analyse und legt Job an. + +**Response:** + +```json +{ + "result": { + "jobId": 42, + "detectedTitle": "INCEPTION", + "omdbCandidates": [] + } +} +``` + +--- + +## POST /api/pipeline/rescan-disc + +Erzwingt erneute Laufwerksprüfung. + +**Response (Beispiel):** + +```json +{ + "result": { + "present": true, + "changed": true, + "emitted": "discInserted", + "device": { + "path": "/dev/sr0", + "discLabel": "INCEPTION", + "mediaProfile": "bluray" + } + } +} +``` + +--- + +## POST /api/pipeline/rescan-drive + +Erzwingt Rescan für ein konkretes Laufwerk. + +**Request:** + +```json +{ "devicePath": "/dev/sr0" } +``` + +--- + +## GET /api/pipeline/tmdb/movie/search?q= + +TMDb-Filmsuche. + +**Response:** + +```json +{ + "results": [ + { + "imdbId": "tt1375666", + "title": "Inception", + "year": "2010", + "type": "movie", + "poster": "https://..." + } + ] +} +``` + +--- + +## GET /api/pipeline/tmdb/series/search?q=&season= + +TMDb-Seriensuche (für Serien-Workflow bei DVD/Blu-ray). + +--- + +## GET /api/pipeline/cd/drives + +Liefert Snapshot der aktuell bekannten CD-Laufwerke. + +--- + +## GET /api/pipeline/cd/musicbrainz/search?q= + +MusicBrainz-Suche für CD-Metadaten. + +## GET /api/pipeline/cd/musicbrainz/release/:mbId + +Lädt Release-Details zu einer MusicBrainz-ID. + +## POST /api/pipeline/cd/select-metadata + +Übernimmt CD-Metadaten für einen Job. + +**Request (Beispiel):** + +```json +{ + "jobId": 91, + "title": "Album", + "artist": "Artist", + "year": 2020, + "mbId": "f5093c06-23e3-404f-aeaa-40f72885ee3a", + "coverUrl": "https://...", + "tracks": [] +} +``` + +## POST /api/pipeline/cd/start/:jobId + +Startet/queued CD-Rip mit Format-/Script-/Chain-Konfiguration. + +--- + +## POST /api/pipeline/audiobook/upload + +Upload von AAX-Datei (Multipart/FormData). + +**FormData-Felder:** + +- `file` (Pflicht) +- `format` (optional) +- `startImmediately` (optional) + +## GET /api/pipeline/audiobook/pending-activation + +Zeigt AAX-Jobs mit fehlenden Activation-Bytes. + +## POST /api/pipeline/audiobook/start/:jobId + +Startet Audiobook-Job mit optionaler Konfiguration. + +## GET /api/pipeline/audiobook/jobs + +Liefert Audiobook-Jobliste. + +## GET /api/pipeline/audiobook/output-tree + +Read-only Baumansicht des Audiobook-Ausgabeordners. + +--- + +## POST /api/pipeline/select-metadata + +Setzt Metadaten (und optional Playlist) für einen Job. + +**Request:** + +```json +{ + "jobId": 42, + "title": "Inception", + "year": 2010, + "imdbId": "tt1375666", + "poster": "https://...", + "fromOmdb": true, + "selectedPlaylist": "00800" +} +``` + +Wichtige optionale Felder: + +- `selectedHandBrakeTitleId` / `selectedHandBrakeTitleIds`: Titel-Auswahl für Review/MediaInfo. +- `metadataProvider`: `omdb` oder `tmdb`. +- `workflowKind`: bei Disc-Jobs typischerweise `film` oder `series`. +- `discNumber`: Pflicht für Serien-Disc-Zuordnung (`workflowKind=series`). +- `duplicateAction`: Duplikatverhalten bei Film-Metadaten (`allow_new` oder `multipart_movie`). +- `existingJobId`: optionaler Referenzjob für `multipart_movie`. +- `existingDiscNumber`: Disc-Nummer des bestehenden Jobs für `multipart_movie`. + +**Response:** + +```json +{ "job": { "id": 42, "status": "READY_TO_START" } } +``` + +**Konflikt-Response (Beispiel, HTTP 409):** + +```json +{ + "message": "Metadaten bereits in der Historie gefunden. Bitte Auswahl übernehmen oder Multipart movie wählen.", + "details": [ + { + "code": "METADATA_DUPLICATE_FOUND", + "mediaProfile": "bluray", + "existingJob": { + "id": 17, + "title": "Inception", + "year": 2010, + "status": "FINISHED", + "jobKind": "bluray", + "discNumber": 1, + "isMultipartMovie": false + } + } + ] +} +``` + +**Relevante Fehlercodes (`POST /api/pipeline/select-metadata`):** + +| Code | Bedeutung | +|---|---| +| `METADATA_DUPLICATE_FOUND` | Film-Metadaten existieren bereits in der Historie (Konfliktdialog im UI). | +| `MULTIPART_DISC_REQUIRED` | Für Multipart fehlen Disc-Nummern (`discNumber`/`existingDiscNumber`). | +| `MULTIPART_DISC_ALREADY_EXISTS` | Disc-Nummer im Multipart-Container bereits vergeben oder doppelt. | +| `MULTIPART_MEDIA_MISMATCH` | Multipart nur bei gleichem Medientyp (DVD oder Blu-ray). | +| `MULTIPART_SERIES_NOT_ALLOWED` | Multipart ist nur für Film-Jobs erlaubt, nicht für Serien-Workflow. | +| `MULTIPART_METADATA_MISMATCH` | Ausgewählter bestehender Job passt nicht zu den Film-Metadaten. | +| `SERIES_DISC_ALREADY_EXISTS` | Serien-Disc-Nummer innerhalb einer Staffel bereits vorhanden. | + +--- + +## POST /api/pipeline/jobs/:jobId/raw-decision + +Trifft Entscheidung bei vorhandenem RAW (`continue` oder `restart` je nach Dialogfluss). + +**Request:** + +```json +{ "decision": "continue" } +``` + +--- + +## POST /api/pipeline/start/:jobId + +Startet vorbereiteten Job oder queued ihn (je nach Parallel-Limit). + +**Mögliche Responses:** + +```json +{ "result": { "started": true, "stage": "RIPPING" } } +``` + +```json +{ "result": { "queued": true, "started": false, "queuePosition": 2, "action": "START_PREPARED" } } +``` + +--- + +## POST /api/pipeline/confirm-encode/:jobId + +Bestätigt Review-Auswahl (Tracks, Pre/Post-Skripte/Ketten, User-Preset). + +**Request (typisch):** + +```json +{ + "selectedEncodeTitleId": 1, + "selectedTrackSelection": { + "1": { + "audioTrackIds": [1, 2], + "subtitleTrackIds": [3] + } + }, + "selectedPreEncodeScriptIds": [1], + "selectedPostEncodeScriptIds": [2, 7], + "selectedPreEncodeChainIds": [3], + "selectedPostEncodeChainIds": [4], + "selectedUserPresetId": 5, + "skipPipelineStateUpdate": false +} +``` + +**Response:** + +```json +{ "job": { "id": 42, "encode_review_confirmed": 1 } } +``` + +--- + +## POST /api/pipeline/cancel + +Bricht laufenden Job ab oder entfernt Queue-Eintrag. + +**Request (optional):** + +```json +{ "jobId": 42 } +``` + +**Mögliche Responses:** + +```json +{ "result": { "cancelled": true, "queuedOnly": true, "jobId": 42 } } +``` + +```json +{ "result": { "cancelled": true, "queuedOnly": false, "jobId": 42 } } +``` + +```json +{ "result": { "cancelled": true, "queuedOnly": false, "pending": true, "jobId": 42 } } +``` + +--- + +## POST /api/pipeline/retry/:jobId + +Retry für `ERROR`/`CANCELLED`-Jobs (oder Queue-Einreihung). + +## POST /api/pipeline/reencode/:jobId + +Startet Re-Encode aus bestehendem RAW. + +## POST /api/pipeline/restart-review/:jobId + +Berechnet Review aus RAW neu. + +## POST /api/pipeline/restart-encode/:jobId + +Startet Encoding mit letzter bestätigter Review neu. + +## POST /api/pipeline/restart-cd-review/:jobId + +Berechnet CD-Review aus vorhandenem RAW neu. + +## POST /api/pipeline/resume-ready/:jobId + +Lädt `READY_TO_ENCODE`-Job nach Neustart wieder in aktive Session. + +## GET /api/pipeline/output-folders/:jobId + +Liefert bekannte Output-Ordner entlang der Job-Lineage. + +## POST /api/pipeline/delete-output-folders/:jobId + +Löscht ausgewählte Output-Ordner. + +**Request:** + +```json +{ "folderPaths": ["/mnt/movies/Inception (2010)"] } +``` + +Alle Endpunkte liefern `{ result: ... }` bzw. `{ job: ... }`. + +--- + +## Queue-Endpunkte + +### GET /api/pipeline/queue + +Liefert Queue-Snapshot. + +```json +{ + "queue": { + "maxParallelJobs": 1, + "runningCount": 1, + "queuedCount": 3, + "runningJobs": [ + { + "jobId": 41, + "title": "Inception", + "status": "ENCODING", + "lastState": "ENCODING" + } + ], + "queuedJobs": [ + { + "entryId": 11, + "position": 1, + "type": "job", + "jobId": 42, + "action": "START_PREPARED", + "actionLabel": "Start", + "title": "Matrix", + "status": "READY_TO_ENCODE", + "lastState": "READY_TO_ENCODE", + "hasScripts": true, + "hasChains": false, + "enqueuedAt": "2026-03-10T09:00:00.000Z" + }, + { + "entryId": 12, + "position": 2, + "type": "wait", + "waitSeconds": 30, + "title": "Warten 30s", + "status": "QUEUED", + "enqueuedAt": "2026-03-10T09:01:00.000Z" + } + ], + "updatedAt": "2026-03-10T09:01:02.000Z" + } +} +``` + +### POST /api/pipeline/queue/reorder + +Sortiert Queue-Einträge neu. + +**Request:** + +```json +{ + "orderedEntryIds": [12, 11] +} +``` + +Legacy fallback wird akzeptiert: + +```json +{ + "orderedJobIds": [42, 43] +} +``` + +### POST /api/pipeline/queue/entry + +Fügt Nicht-Job-Queue-Eintrag hinzu (`script`, `chain`, `wait`). + +**Request-Beispiele:** + +```json +{ "type": "script", "scriptId": 3 } +``` + +```json +{ "type": "chain", "chainId": 2, "insertAfterEntryId": 11 } +``` + +```json +{ "type": "wait", "waitSeconds": 45 } +``` + +**Response:** + +```json +{ + "result": { "entryId": 12, "type": "wait", "position": 2 }, + "queue": { "...": "..." } +} +``` + +### DELETE /api/pipeline/queue/entry/:entryId + +Entfernt Queue-Eintrag. + +**Response:** + +```json +{ "queue": { "...": "..." } } +``` + +--- + +## Pipeline-Zustände + +| State | Bedeutung | +|------|-----------| +| `IDLE` | Wartet auf Medium | +| `DISC_DETECTED` | Medium erkannt | +| `ANALYZING` | MakeMKV-Analyse läuft | +| `METADATA_SELECTION` | Metadaten-Auswahl | +| `WAITING_FOR_USER_DECISION` | Nutzerentscheidung erforderlich (Playlist-Auswahl oder RAW-Entscheidung) | +| `READY_TO_START` | Übergang vor Start | +| `RIPPING` | MakeMKV-Rip läuft | +| `MEDIAINFO_CHECK` | Titel-/Track-Auswertung | +| `READY_TO_ENCODE` | Review bereit | +| `ENCODING` | HandBrake-Encoding läuft | +| `CD_METADATA_SELECTION` | CD-Metadatenauswahl aktiv | +| `CD_READY_TO_RIP` | CD-Job ist startbereit | +| `CD_ANALYZING` | CD-Struktur/Tracks werden vorbereitet | +| `CD_RIPPING` | CD-Ripping läuft | +| `CD_ENCODING` | CD-Encode läuft | +| `FINISHED` | Abgeschlossen | +| `DONE` | Abgeschlossen (v. a. Audiobook/Converter/CD-Varianten) | +| `CANCELLED` | Abgebrochen | +| `ERROR` | Fehler | diff --git a/docs/api/runtime-activities.md b/docs/api/runtime-activities.md new file mode 100644 index 0000000..080a0ca --- /dev/null +++ b/docs/api/runtime-activities.md @@ -0,0 +1,235 @@ +# Runtime Activities API + +Ripster verfolgt alle laufenden und kürzlich abgeschlossenen Aktivitäten (Skripte, Skript-Ketten, Cron-Jobs, interne Tasks) in Echtzeit über den `RuntimeActivityService`. + +--- + +## Übersicht + +Aktivitäten entstehen, wenn Ripster intern Aktionen ausführt – z. B. beim Start eines Cron-Jobs, beim Ausführen einer Skript-Kette oder beim Durchlaufen von Pipeline-Schritten. Sie sind **nicht persistent** (kein DB-Speicher) und werden nur im Arbeitsspeicher gehalten. + +- **Aktive Aktivitäten** (`active`): Laufen gerade. +- **Letzte Aktivitäten** (`recent`): Abgeschlossen, max. 120 Einträge. + +Änderungen werden über WebSocket (`RUNTIME_ACTIVITY_CHANGED`) in Echtzeit gesendet. + +--- + +## Aktivitäts-Objekt + +```json +{ + "id": 7, + "type": "chain", + "name": "Post-Encode Aufräumen", + "status": "running", + "source": "cron", + "message": "Schritt 2 von 3", + "currentStep": "cleanup.sh", + "currentStepType": "script", + "currentScriptName": "cleanup.sh", + "stepIndex": 2, + "stepTotal": 3, + "parentActivityId": null, + "jobId": 42, + "cronJobId": 3, + "chainId": 5, + "scriptId": null, + "canCancel": true, + "canNextStep": false, + "outcome": "running", + "errorMessage": null, + "output": null, + "stdout": null, + "stderr": null, + "stdoutTruncated": false, + "stderrTruncated": false, + "startedAt": "2026-03-10T10:00:00.000Z", + "finishedAt": null, + "durationMs": null, + "exitCode": null, + "success": null +} +``` + +### Felder + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `id` | `number` | Eindeutige ID (Laufzähler, nicht persistent) | +| `type` | `string` | Art der Aktivität: `script` \| `chain` \| `cron` \| `task` | +| `name` | `string \| null` | Anzeigename der Aktivität | +| `status` | `string` | Aktueller Status: `running` \| `success` \| `error` | +| `source` | `string \| null` | Auslöser (z. B. `cron`, `pipeline`, `manual`) | +| `message` | `string \| null` | Kurztext zum aktuellen Zustand | +| `currentStep` | `string \| null` | Name des aktuell ausgeführten Schritts | +| `currentStepType` | `string \| null` | Typ des Schritts (z. B. `script`, `wait`) | +| `currentScriptName` | `string \| null` | Name des Skripts im aktuellen Schritt | +| `stepIndex` | `number \| null` | Aktueller Schritt (1-basiert) | +| `stepTotal` | `number \| null` | Gesamtanzahl Schritte | +| `parentActivityId` | `number \| null` | ID der übergeordneten Aktivität | +| `jobId` | `number \| null` | Verknüpfte Job-ID | +| `cronJobId` | `number \| null` | Verknüpfte Cron-Job-ID | +| `chainId` | `number \| null` | Verknüpfte Skript-Ketten-ID | +| `scriptId` | `number \| null` | Verknüpfte Skript-ID | +| `canCancel` | `boolean` | Abbrechen über API möglich | +| `canNextStep` | `boolean` | Nächster Schritt über API auslösbar | +| `outcome` | `string \| null` | Abschluss-Ergebnis: `success` \| `error` \| `cancelled` \| `skipped` \| `running` | +| `errorMessage` | `string \| null` | Fehlermeldung (max. 2.000 Zeichen) | +| `output` | `string \| null` | Allgemeine Ausgabe (max. 12.000 Zeichen) | +| `stdout` | `string \| null` | Standardausgabe des Prozesses (max. 12.000 Zeichen) | +| `stderr` | `string \| null` | Fehlerausgabe des Prozesses (max. 12.000 Zeichen) | +| `stdoutTruncated` | `boolean` | `true`, wenn `stdout` gekürzt wurde | +| `stderrTruncated` | `boolean` | `true`, wenn `stderr` gekürzt wurde | +| `startedAt` | `string` | ISO-8601-Zeitstempel des Starts | +| `finishedAt` | `string \| null` | ISO-8601-Zeitstempel des Endes | +| `durationMs` | `number \| null` | Laufzeit in Millisekunden | +| `exitCode` | `number \| null` | Exit-Code des Prozesses | +| `success` | `boolean \| null` | Erfolgsstatus (`null` bei laufender Aktivität) | + +--- + +## Snapshot-Objekt + +Alle Aktivitäts-Endpunkte geben einen Snapshot zurück: + +```json +{ + "active": [ /* laufende Aktivitäten, nach startedAt absteigend */ ], + "recent": [ /* abgeschlossene Aktivitäten, nach finishedAt absteigend, max. 120 */ ], + "updatedAt": "2026-03-10T10:05:00.000Z" +} +``` + +--- + +## Endpunkte + +### GET `/api/runtime/activities` + +Aktuellen Aktivitäts-Snapshot abrufen. + +**Antwort:** + +```json +{ + "active": [], + "recent": [ + { + "id": 5, + "type": "script", + "name": "notify.sh", + "status": "success", + "outcome": "success", + "startedAt": "2026-03-10T09:58:00.000Z", + "finishedAt": "2026-03-10T09:58:02.000Z", + "durationMs": 2100, + "exitCode": 0, + "success": true, + "canCancel": false, + "canNextStep": false + } + ], + "updatedAt": "2026-03-10T10:05:00.000Z" +} +``` + +--- + +### POST `/api/runtime/activities/:id/cancel` + +Aktive Aktivität abbrechen (nur wenn `canCancel: true`). + +**Parameter:** + +| Name | In | Typ | Beschreibung | +|------|----|-----|--------------| +| `id` | path | `number` | Aktivitäts-ID | +| `reason` | body | `string` | Optionaler Abbruchgrund | + +**Request Body:** + +```json +{ "reason": "Manueller Abbruch durch Benutzer" } +``` + +**Antwort (Erfolg):** + +```json +{ + "ok": true, + "action": null, + "snapshot": { "active": [], "recent": [], "updatedAt": "..." } +} +``` + +**Fehlercodes:** + +| HTTP | Bedeutung | +|------|-----------| +| `404` | Aktivität nicht gefunden oder bereits abgeschlossen | +| `409` | Abbrechen wird von dieser Aktivität nicht unterstützt | + +--- + +### POST `/api/runtime/activities/:id/next-step` + +Nächsten Schritt einer Aktivität auslösen (nur wenn `canNextStep: true`). + +**Parameter:** + +| Name | In | Typ | Beschreibung | +|------|----|-----|--------------| +| `id` | path | `number` | Aktivitäts-ID | + +**Antwort (Erfolg):** + +```json +{ + "ok": true, + "action": null, + "snapshot": { "active": [], "recent": [], "updatedAt": "..." } +} +``` + +**Fehlercodes:** + +| HTTP | Bedeutung | +|------|-----------| +| `404` | Aktivität nicht gefunden | +| `409` | Nächster Schritt wird von dieser Aktivität nicht unterstützt | + +--- + +### POST `/api/runtime/activities/clear-recent` + +Alle abgeschlossenen Aktivitäten aus `recent` löschen. + +**Antwort:** + +```json +{ + "ok": true, + "removed": 14, + "snapshot": { "active": [], "recent": [], "updatedAt": "..." } +} +``` + +--- + +## Grenzwerte + +| Wert | Limit | +|------|-------| +| Maximale `recent`-Einträge | 120 | +| Maximale Länge `stdout` / `stderr` / `output` | 12.000 Zeichen | +| Maximale Länge `errorMessage` / `message` | 2.000 Zeichen | +| Maximale Länge `outcome` | 40 Zeichen | + +Gekürzte Ausgaben erhalten den Suffix ` ...[gekürzt]` (bei Inline-Text) bzw. `\n...[gekürzt]` (bei mehrzeiligem Output). + +--- + +## Echtzeit-Updates + +Änderungen werden automatisch als [`RUNTIME_ACTIVITY_CHANGED`](websocket.md#runtime_activity_changed) WebSocket-Event gesendet. Die Frontend-Komponente braucht `GET /api/runtime/activities` nur beim initialen Laden aufzurufen. diff --git a/docs/api/settings.md b/docs/api/settings.md new file mode 100644 index 0000000..a357b43 --- /dev/null +++ b/docs/api/settings.md @@ -0,0 +1,424 @@ +# Settings API + +Endpunkte für Einstellungen, Skripte, Skript-Ketten und User-Presets. + +--- + +## GET /api/settings + +Liefert alle Einstellungen kategorisiert. + +**Response (Struktur):** + +```json +{ + "categories": [ + { + "category": "Pfade", + "settings": [ + { + "key": "raw_dir", + "label": "Raw Ausgabeordner", + "type": "path", + "required": true, + "description": "...", + "defaultValue": "data/output/raw", + "options": [], + "validation": { "minLength": 1 }, + "value": "data/output/raw", + "orderIndex": 100 + } + ] + } + ] +} +``` + +--- + +## PUT /api/settings/:key + +Aktualisiert eine einzelne Einstellung. + +**Request:** + +```json +{ "value": "/mnt/storage/raw" } +``` + +**Response:** + +```json +{ + "setting": { + "key": "raw_dir", + "value": "/mnt/storage/raw" + }, + "reviewRefresh": { + "triggered": false, + "reason": "not_ready" + } +} +``` + +`reviewRefresh` ist `null` oder ein Objekt mit Status der optionalen Review-Neuberechnung. + +--- + +## PUT /api/settings + +Aktualisiert mehrere Einstellungen atomar. + +**Request:** + +```json +{ + "settings": { + "raw_dir": "/mnt/storage/raw", + "movie_dir": "/mnt/storage/movies", + "handbrake_preset_bluray": "H.264 MKV 1080p30" + } +} +``` + +**Response:** + +```json +{ + "changes": [ + { "key": "raw_dir", "value": "/mnt/storage/raw" }, + { "key": "movie_dir", "value": "/mnt/storage/movies" } + ], + "reviewRefresh": { + "triggered": true, + "jobId": 42, + "relevantKeys": ["handbrake_preset_bluray"] + } +} +``` + +Bei Validierungsfehlern kommt `400` mit `error.details[]`. + +--- + +## GET /api/settings/handbrake-presets + +Liest Preset-Liste via `HandBrakeCLI -z` (mit Fallback auf konfigurierte Presets). + +**Response (Beispiel):** + +```json +{ + "source": "handbrake-cli", + "message": null, + "options": [ + { "label": "General/", "value": "__group__general", "disabled": true, "category": "General" }, + { "label": " Fast 1080p30", "value": "Fast 1080p30", "category": "General" } + ] +} +``` + +--- + +## POST /api/settings/pushover/test + +Sendet Testnachricht über aktuelle PushOver-Settings. + +**Request (optional):** + +```json +{ + "title": "Test", + "message": "Ripster Test" +} +``` + +**Response:** + +```json +{ + "result": { + "sent": true, + "eventKey": "test", + "requestId": "..." + } +} +``` + +Wenn PushOver deaktiviert ist oder Credentials fehlen, kommt i. d. R. ebenfalls `200` mit `sent: false` + `reason`. + +--- + +## Skripte + +Basis: `/api/settings/scripts` + +### GET /api/settings/scripts + +```json +{ "scripts": [ { "id": 1, "name": "...", "scriptBody": "...", "orderIndex": 1, "createdAt": "...", "updatedAt": "..." } ] } +``` + +### POST /api/settings/scripts + +```json +{ "name": "Move", "scriptBody": "mv \"$RIPSTER_OUTPUT_PATH\" /mnt/movies/" } +``` + +Response: `201` mit `{ "script": { ... } }` + +### PUT /api/settings/scripts/:id + +Body wie `POST`, Response `{ "script": { ... } }`. + +### DELETE /api/settings/scripts/:id + +Response `{ "removed": { ... } }`. + +### POST /api/settings/scripts/reorder + +```json +{ "orderedScriptIds": [3, 1, 2] } +``` + +Response `{ "scripts": [ ... ] }`. + +### POST /api/settings/scripts/:id/test + +Führt Skript als Testlauf aus. + +```json +{ + "result": { + "scriptId": 1, + "scriptName": "Move", + "success": true, + "exitCode": 0, + "signal": null, + "timedOut": false, + "durationMs": 120, + "stdout": "...", + "stderr": "...", + "stdoutTruncated": false, + "stderrTruncated": false + } +} +``` + +### Umgebungsvariablen für Skripte + +Diese Variablen werden beim Ausführen gesetzt: + +- `RIPSTER_SCRIPT_RUN_AT` +- `RIPSTER_JOB_ID` +- `RIPSTER_JOB_TITLE` +- `RIPSTER_MODE` +- `RIPSTER_INPUT_PATH` +- `RIPSTER_OUTPUT_PATH` +- `RIPSTER_RAW_PATH` +- `RIPSTER_SCRIPT_ID` +- `RIPSTER_SCRIPT_NAME` +- `RIPSTER_SCRIPT_SOURCE` + +--- + +## Skript-Ketten + +Basis: `/api/settings/script-chains` + +Eine Kette hat Schritte vom Typ: + +- `script` (`scriptId` erforderlich) +- `wait` (`waitSeconds` 1..3600) + +### GET /api/settings/script-chains + +Response `{ "chains": [ ... ] }` (inkl. `steps[]`). + +### GET /api/settings/script-chains/:id + +Response `{ "chain": { ... } }`. + +### POST /api/settings/script-chains + +```json +{ + "name": "After Encode", + "steps": [ + { "stepType": "script", "scriptId": 1 }, + { "stepType": "wait", "waitSeconds": 15 }, + { "stepType": "script", "scriptId": 2 } + ] +} +``` + +Response: `201` mit `{ "chain": { ... } }` + +### PUT /api/settings/script-chains/:id + +Body wie `POST`, Response `{ "chain": { ... } }`. + +### DELETE /api/settings/script-chains/:id + +Response `{ "removed": { ... } }`. + +### POST /api/settings/script-chains/reorder + +```json +{ "orderedChainIds": [2, 1, 3] } +``` + +Response `{ "chains": [ ... ] }`. + +### POST /api/settings/script-chains/:id/test + +Response: + +```json +{ + "result": { + "chainId": 2, + "chainName": "After Encode", + "steps": 3, + "succeeded": 3, + "failed": 0, + "aborted": false, + "results": [] + } +} +``` + +--- + +## User-Presets + +Basis: `/api/settings/user-presets` + +### GET /api/settings/user-presets + +Optionaler Query-Parameter: `media_type=bluray|dvd|other|all` + +```json +{ + "presets": [ + { + "id": 1, + "name": "Blu-ray HQ", + "mediaType": "bluray", + "handbrakePreset": "H.264 MKV 1080p30", + "extraArgs": "--encoder-preset slow", + "description": "...", + "createdAt": "...", + "updatedAt": "..." + } + ] +} +``` + +### POST /api/settings/user-presets + +```json +{ + "name": "Blu-ray HQ", + "mediaType": "bluray", + "handbrakePreset": "H.264 MKV 1080p30", + "extraArgs": "--encoder-preset slow", + "description": "optional" +} +``` + +Response: `201` mit `{ "preset": { ... } }` + +### PUT /api/settings/user-presets/:id + +Body mit beliebigen Feldern aus `POST`, Response `{ "preset": { ... } }`. + +### DELETE /api/settings/user-presets/:id + +Response `{ "removed": { ... } }`. + +--- + +## Weitere Endpunkte + +### GET /api/settings/effective-paths + +Liefert aufgelöste, effektiv genutzte Pfade aus den Settings (für UI- und Diagnosezwecke). + +### POST /api/settings/coverart/recover + +Startet eine manuelle Coverart-Recovery. + +**Response (Beispiel):** + +```json +{ + "result": { "started": true }, + "scheduler": { "enabled": true } +} +``` + +--- + +## Activation Bytes (AAX) + +### GET /api/settings/activation-bytes + +Liest gecachte AAX-Activation-Bytes. + +### POST /api/settings/activation-bytes + +Speichert Activation-Bytes. + +**Request:** + +```json +{ + "checksum": "abc123...", + "activationBytes": "1a2b3c4d" +} +``` + +--- + +## Laufwerke + +### GET /api/settings/drives + +Liefert erkannte optische Laufwerke (`/dev/...`) inkl. MakeMKV-Disc-Index. + +### POST /api/settings/drives/force-unlock + +Erzwingt Entsperren aktiver Laufwerkslocks. + +Hinweis: Expertenmodus (`ui_expert_mode`) ist erforderlich, sonst `403`. + +**Request (ein Laufwerk):** + +```json +{ "devicePath": "/dev/sr0" } +``` + +**Request (alle):** + +```json +{ "all": true } +``` + +--- + +## UI Preferences + +### GET /api/settings/prefs/:key + +Liest einen persistierten UI-Preference-Wert. + +### PUT /api/settings/prefs/:key + +Speichert einen persistierten UI-Preference-Wert. + +**Request:** + +```json +{ "value": "{\"columns\":[\"id\",\"status\"]}" } +``` diff --git a/docs/api/websocket.md b/docs/api/websocket.md new file mode 100644 index 0000000..d3507d1 --- /dev/null +++ b/docs/api/websocket.md @@ -0,0 +1,294 @@ +# WebSocket Events + +Ripster sendet Echtzeit-Updates über `/ws`. + +--- + +## Verbindung + +```js +const ws = new WebSocket('ws://localhost:3001/ws'); + +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + console.log(msg.type, msg.payload); +}; +``` + +--- + +## Nachrichtenformat + +Die meisten Broadcasts haben dieses Schema: + +```json +{ + "type": "EVENT_TYPE", + "payload": {}, + "timestamp": "2026-03-10T09:00:00.000Z" +} +``` + +Ausnahme: `WS_CONNECTED` beim Verbindungsaufbau enthält kein `timestamp`. + +--- + +## Event-Typen + +### WS_CONNECTED + +Sofort nach erfolgreicher Verbindung. + +```json +{ + "type": "WS_CONNECTED", + "payload": { + "connectedAt": "2026-03-10T09:00:00.000Z" + } +} +``` + +### PIPELINE_STATE_CHANGED + +Neuer Pipeline-Snapshot. + +```json +{ + "type": "PIPELINE_STATE_CHANGED", + "payload": { + "state": "ENCODING", + "activeJobId": 42, + "progress": 62.5, + "eta": "00:12:34", + "statusText": "ENCODING 62.50%", + "context": {}, + "jobProgress": { + "42": { + "state": "ENCODING", + "progress": 62.5, + "eta": "00:12:34", + "statusText": "ENCODING 62.50%" + } + }, + "queue": { + "maxParallelJobs": 1, + "runningCount": 1, + "queuedCount": 2, + "runningJobs": [], + "queuedJobs": [] + } + } +} +``` + +### PIPELINE_PROGRESS + +Laufende Fortschrittsupdates. + +```json +{ + "type": "PIPELINE_PROGRESS", + "payload": { + "state": "ENCODING", + "activeJobId": 42, + "progress": 62.5, + "eta": "00:12:34", + "statusText": "ENCODING 62.50%" + } +} +``` + +### PIPELINE_QUEUE_CHANGED + +Queue-Snapshot aktualisiert. + +### DISC_DETECTED / DISC_REMOVED + +Disc-Insertion/-Removal. + +```json +{ + "type": "DISC_DETECTED", + "payload": { + "device": { + "path": "/dev/sr0", + "discLabel": "INCEPTION", + "model": "ASUS BW-16D1HT", + "fstype": "udf", + "mountpoint": null, + "mediaProfile": "bluray" + } + } +} +``` + +`mediaProfile`: `bluray` | `dvd` | `other` | `null` + +### HARDWARE_MONITOR_UPDATE + +Snapshot aus Hardware-Monitoring. + +```json +{ + "type": "HARDWARE_MONITOR_UPDATE", + "payload": { + "enabled": true, + "intervalMs": 5000, + "updatedAt": "2026-03-10T09:00:00.000Z", + "sample": { + "cpu": {}, + "memory": {}, + "gpu": {}, + "storage": {} + }, + "error": null + } +} +``` + +### PIPELINE_ERROR + +Fehler bei Disc-Event-Verarbeitung in Pipeline. + +### DISK_DETECTION_ERROR + +Fehler in Laufwerkserkennung. + +### SETTINGS_UPDATED + +Einzelnes Setting wurde gespeichert. + +### SETTINGS_BULK_UPDATED + +Bulk-Settings gespeichert. + +```json +{ + "type": "SETTINGS_BULK_UPDATED", + "payload": { + "count": 3, + "keys": ["raw_dir", "movie_dir", "handbrake_preset_bluray"] + } +} +``` + +### SETTINGS_SCRIPTS_UPDATED + +Skript geändert (`created|updated|deleted|reordered`). + +### SETTINGS_SCRIPT_CHAINS_UPDATED + +Skript-Kette geändert (`created|updated|deleted|reordered`). + +### USER_PRESETS_UPDATED + +User-Preset geändert (`created|updated|deleted`). + +### CRON_JOBS_UPDATED + +Cron-Config geändert (`created|updated|deleted`). + +### CRON_JOB_UPDATED + +Laufzeitstatus eines Cron-Jobs geändert. + +```json +{ + "type": "CRON_JOB_UPDATED", + "payload": { + "id": 1, + "lastRunStatus": "running", + "lastRunAt": "2026-03-10T10:00:00.000Z", + "nextRunAt": null + } +} +``` + +### RUNTIME_ACTIVITY_CHANGED + +Vollständiger Snapshot aller laufenden und kürzlich abgeschlossenen Aktivitäten. + +Wird ausgelöst, wenn eine Aktivität gestartet, aktualisiert oder abgeschlossen wird sowie nach `clear-recent`. + +```json +{ + "type": "RUNTIME_ACTIVITY_CHANGED", + "payload": { + "active": [ + { + "id": 7, + "type": "chain", + "name": "Post-Encode Aufräumen", + "status": "running", + "source": "cron", + "message": "Schritt 2 von 3", + "currentStep": "cleanup.sh", + "currentStepType": "script", + "stepIndex": 2, + "stepTotal": 3, + "canCancel": true, + "canNextStep": false, + "outcome": "running", + "startedAt": "2026-03-10T10:00:00.000Z", + "finishedAt": null, + "durationMs": null + } + ], + "recent": [], + "updatedAt": "2026-03-10T10:00:05.000Z" + } +} +``` + +Vollständige Feldbeschreibung: [Runtime Activities API](runtime-activities.md). + +### CONVERTER_SCAN_UPDATE + +Wird nach einem Scan des Converter-Eingangsordners gesendet (manuell oder per Polling). + +```json +{ + "type": "CONVERTER_SCAN_UPDATE", + "payload": { + "entryCount": 12 + } +} +``` + +### DOWNLOADS_UPDATED + +Wird bei jeder Statusänderung in der Download-Queue gesendet. + +```json +{ + "type": "DOWNLOADS_UPDATED", + "payload": { + "reason": "ready", + "item": { + "id": "dl-1", + "jobId": 42, + "status": "ready", + "archiveName": "Job_42_movie.zip", + "createdAt": "2026-03-30T10:00:00.000Z" + }, + "summary": { + "total": 3, + "pending": 1, + "processing": 0, + "ready": 1, + "failed": 1 + } + } +} +``` + +Mögliche `reason`-Werte: `queued`, `processing`, `ready`, `failed`, `deleted`. + +--- + +## Reconnect-Verhalten + +`useWebSocket` verbindet bei Abbruch automatisch neu: + +- Retry-Intervall: `1500ms` +- Wiederverbindung bis Komponente unmounted wird diff --git a/docs/appendix/index.md b/docs/appendix/index.md new file mode 100644 index 0000000..0739305 --- /dev/null +++ b/docs/appendix/index.md @@ -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 diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md new file mode 100644 index 0000000..8cfd279 --- /dev/null +++ b/docs/architecture/backend.md @@ -0,0 +1,241 @@ +# Backend-Services + +Das Backend ist in Services aufgeteilt, die von Express-Routen orchestriert werden. Seit v0.12.0 erfolgt die Medienverarbeitung über ein **Plugin-System**. + +--- + +## Plugin-System (`src/plugins/`) + +Ab v0.12.0 ist die Pipeline modular aufgebaut. Jeder Medientyp wird von einem eigenen Plugin verwaltet. + +### Basisklassen + +- **`PluginBase.js`** — Abstrakte Basisklasse `SourcePlugin` mit Lifecycle-Methoden +- **`PluginRegistry.js`** — Dynamische Plugin-Erkennung, prioritätsbasierte Auswahl, Settings-Schema-Aggregation +- **`PluginContext.js`** — Ausführungskontext (Logger, Callbacks, Signals) für Plugin-Aufrufe + +### Plugin-Lifecycle + +Jedes Plugin implementiert die folgenden Methoden: + +| Methode | Beschreibung | +|---|---| +| `detect(discInfo)` | Medientyp-Erkennung | +| `analyze(devicePath, job, ctx)` | Analyse / Metadaten-Extraktion | +| `rip(job, ctx)` | Rohdaten-Extraktion | +| `review(job, ctx)` | Optionale Vorschau-Vorbereitung | +| `encode(job, ctx)` | Finales Encoding/Konvertierung | +| `finalize(job, ctx)` | Nachbearbeitung | +| `onCancel(job, ctx)` | Bereinigung bei Abbruch | +| `onRetry(job, ctx)` | Zustand-Reset vor Retry | + +### Implementierte Plugins + +- **`BluRayPlugin.js`** — Blu-ray via MakeMKV (Backup-Modus) +- **`DVDPlugin.js`** — DVD via MakeMKV (MKV-Modus) +- **`CdPlugin.js`** — Audio-CD via cdparanoia + FFmpeg (experimentell) +- **`AudiobookPlugin.js`** — AAX/M4B via FFmpeg mit Kapitel-Splitting +- **`ConverterPlugin.js`** — Generische Audio/Video-Konvertierung via FFmpeg +- **`VideoDiscPlugin.js`** — Gemeinsame Basis für Blu-ray/DVD (MediaInfo-Parsing) + +--- + +## `pipelineService.js` + +Zentrale Workflow-Orchestrierung. + +Aufgaben: + +- Pipeline-State-Machine + Persistenz (`pipeline_state`) +- Disc-Analyse/Rip/Review/Encode via Plugin-Delegation +- Queue-Management (Jobs + `script|chain|wait` Einträge) +- Retry/Re-Encode/Restart-Flows +- WebSocket-Broadcasts für State/Progress/Queue +- Converter-Job-Verwaltung (`createConverterJobFromEntry`, `uploadConverterFiles`, `startConverterJob`) + +Wichtige Methoden: + +- `analyzeDisc()` +- `selectMetadata()` +- `startPreparedJob()` +- `confirmEncodeReview()` +- `cancel()` +- `retry()` +- `reencodeFromRaw()` +- `restartReviewFromRaw()` +- `restartEncodeWithLastSettings()` +- `resumeReadyToEncodeJob()` +- `enqueueNonJobEntry()`, `reorderQueue()`, `removeQueueEntry()` +- `createConverterJobFromEntry()`, `createConverterJobsFromSelection()` +- `uploadConverterFiles()`, `startConverterJob()` + +--- + +## `diskDetectionService.js` + +Pollt Laufwerk(e) und emittiert: + +- `discInserted` +- `discRemoved` +- `error` + +Zusatz: + +- Modus `auto` oder `explicit` +- heuristische `mediaProfile`-Erkennung (`bluray`/`dvd`/`other`) +- `rescanAndEmit()` für manuellen Trigger + +--- + +## `settingsService.js` + +Settings-Layer mit Validation/Serialisierung. + +Features: + +- `getCategorizedSettings()` für UI-Form +- `setSettingValue()` / `setSettingsBulk()` +- profilspezifische Auflösung (`resolveEffectiveToolSettings`) +- CLI-Config-Building für MakeMKV/HandBrake/MediaInfo/FFmpeg +- HandBrake-Preset-Liste via `HandBrakeCLI -z` +- MakeMKV-Key-Synchronisation aus `makemkv_registration_key` nach `~/.MakeMKV/settings.conf` +- Pfadauflösung für alle Medienprofile inkl. Audiobook und Converter + +--- + +## `historyService.js` + +Historie + Dateioperationen. + +Features: + +- Job-Liste/Detail inkl. Log-Tail +- Orphan-RAW-Erkennung und Import +- TMDb-Neuzuordnung +- Dateilöschung (`raw|movie|both`) +- Job-Löschung (`none|raw|movie|both`) + +--- + +## `audiobookService.js` + +Audiobook-Verarbeitung (AAX/M4B). + +Features: + +- FFprobe-basierte Analyse (Kapitel, Metadaten) +- FFmpeg-basiertes Encoding mit Kapitel-Splitting +- Ausgabeformate: M4B, MP3, FLAC +- Template-basierte Pfade (`{author}`, `{title}`, `{year}`, `{narrator}`, `{series}`, `{part}`) + +--- + +## `activationBytesService.js` + +Verwaltung von Audible-Activation-Bytes für AAX-DRM-Handling. + +Features: + +- SHA-1-Prüfsumme der AAX-Datei berechnen +- Activation Bytes in `aax_activation_bytes`-Tabelle cachen +- Lookup via `audnexService` (wenn konfiguriert) + +--- + +## `converterScanService.js` + +Dateisystem-Überwachung des Converter-Eingangsordners. + +Features: + +- Vollständiger FS-Baum (`getTree()`) +- DB-basierter Datei-Explorer (`getEntries()`) +- Manueller und automatischer Scan (Polling) +- Pfad-Normalisierung mit Traversal-Schutz +- WebSocket-Broadcast: `CONVERTER_SCAN_UPDATE` + +--- + +## `downloadService.js` + +Download-Queue für Ausgabedateien aus der Historie. + +Features: + +- Job in Download-Queue einreihen (`enqueueHistoryJob`) +- ZIP-Archiv aus Ausgabedateien erstellen (`archiver`) +- Datei-Streaming via `res.download()` +- WebSocket-Broadcast: `DOWNLOADS_UPDATED` bei Status-Änderungen +- Download-Item löschen + +--- + +## `cronService.js` + +Integriertes Cron-System ohne externe Parser-Library. + +Features: + +- 5-Feld-Cron-Parser + `nextRun`-Berechnung +- Quellen: `script` oder `chain` +- Laufzeitlogs (`cron_run_logs`) +- manuelles Triggern +- WebSocket-Events: `CRON_JOBS_UPDATED`, `CRON_JOB_UPDATED` + +--- + +## `runtimeActivityService.js` + +In-Memory-Tracking aller laufenden und kürzlich abgeschlossenen Aktivitäten (Skripte, Ketten, Cron-Jobs, Tasks). + +Features: + +- `startActivity(type, payload)` → Aktivität registrieren, ID zurückgeben +- `updateActivity(id, patch)` → Laufende Aktivität aktualisieren +- `completeActivity(id, payload)` → Aktivität abschließen und in `recent` verschieben +- `setControls(id, { cancel, nextStep })` → Steuer-Handler registrieren (für `canCancel`/`canNextStep`) +- `requestCancel(id)` / `requestNextStep(id)` → Steuer-Handler aufrufen +- `clearRecent()` → Abgeschlossene Aktivitäten löschen +- `getSnapshot()` → Snapshot mit `active` + `recent` + `updatedAt` +- Broadcasts `RUNTIME_ACTIVITY_CHANGED` über WebSocket bei jeder Änderung + +Limits: + +- `recent` max. 120 Einträge +- `stdout`/`stderr`/`output` max. 12.000 Zeichen +- `message`/`errorMessage` max. 2.000 Zeichen + +Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activities.md) + +--- + +## Weitere Services + +- `scriptService.js` (CRUD + Test + Wrapper-Ausführung) +- `scriptChainService.js` (CRUD + Step-Execution) +- `userPresetService.js` (HandBrake User-Presets) +- `hardwareMonitorService.js` (CPU/RAM/GPU/Storage) +- `websocketService.js` (Client-Registry + Broadcast) +- `notificationService.js` (PushOver) +- `cdRipService.js` (CD-Ripping mit cdparanoia) +- `musicBrainzService.js` (MusicBrainz-Metadaten-Lookup) +- `audnexService.js` (Audnex-API für Audiobook-Metadaten) +- `coverArtRecoveryService.js` (Cover-Art-Wiederherstellung) +- `thumbnailService.js` (Vorschaubild-Generierung) +- `tmdbService.js` (Film-/Serien-Metadaten-Suche) +- `logger.js` (rotierende Datei-Logs) + +--- + +## Bootstrapping (`src/index.js`) + +Beim Start: + +1. DB init/migrate +2. Pipeline-Init (inkl. Plugin-Registry) +3. Cron-Init +4. Express-Routes + Error-Handler +5. WebSocket-Server auf `/ws` +6. Hardware-Monitoring-Init +7. Disk-Detection-Start +8. Converter-Scan-Service-Init (Polling falls aktiviert) diff --git a/docs/architecture/database.md b/docs/architecture/database.md new file mode 100644 index 0000000..204d66c --- /dev/null +++ b/docs/architecture/database.md @@ -0,0 +1,203 @@ +# Datenbank + +Ripster verwendet SQLite (`backend/data/ripster.db`). + +--- + +## Tabellen + +```text +settings_schema +settings_values +jobs +job_lineage_artifacts +job_output_folders +pipeline_state +scripts +script_chains +script_chain_steps +user_presets +cron_jobs +cron_run_logs +aax_activation_bytes +user_prefs +converter_scan_entries +``` + +--- + +## `jobs` + +Speichert Pipeline-Lifecycle und Artefakte pro Job. + +Zentrale Felder: + +- Metadaten: `title`, `year`, `imdb_id`, `poster_url`, `omdb_json`, `selected_from_omdb` +- Laufzeit: `start_time`, `end_time`, `status`, `last_state` +- Pfade: `raw_path`, `output_path`, `encode_input_path` +- Tool-Ausgaben: `makemkv_info_json`, `handbrake_info_json`, `mediainfo_info_json`, `encode_plan_json` +- Kontrolle: `encode_review_confirmed`, `rip_successful`, `error_message` +- Medientyp: `media_type` (`bluray|dvd|cd|audiobook|converter`) +- Job-Typ/Beziehung: `job_kind`, `parent_job_id` +- Multipart-/Disc-Merkmale: `is_multipart_movie`, `disc_number`, `metadata_fingerprint` +- Prüfsumme: `aax_checksum` (für AAX-Dateien) +- Audit: `created_at`, `updated_at` + +Typische `job_kind`-Werte: + +- Standard: `bluray`, `dvd`, `cd`, `audiobook`, `converter_audio`, `converter_video`, `converter_iso` +- Serien: `dvd_series_container`, `dvd_series_child` +- Multipart Film: `multipart_movie_container`, `multipart_movie_child` + +Wichtige Indizes: + +- `idx_jobs_status` +- `idx_jobs_parent_job_id` +- `idx_jobs_job_kind` +- `idx_jobs_disc_number` +- `idx_jobs_metadata_fingerprint` + +--- + +## `job_lineage_artifacts` + +Verknüpft Jobs mit ihren Quell-Jobs (z. B. Re-Encode aus einem vorherigen Rip). + +Felder: + +- `job_id` — der abgeleitete Job +- `source_job_id` — der Quell-Job +- `artifact_type` — Art der Verknüpfung (z. B. `reencode`, `restart_review`) +- `created_at` + +--- + +## `job_output_folders` + +Verfolgt Ausgabepfade pro Job (mehrere Ausgaben möglich, z. B. bei Kapitel-Splitting). + +Felder: + +- `job_id` +- `folder_path` +- `label` — optionaler Bezeichner +- `created_at` + +--- + +## `pipeline_state` + +Singleton-Tabelle (`id = 1`) für aktiven Snapshot: + +- `state` +- `active_job_id` +- `progress` +- `eta` +- `status_text` +- `context_json` +- `updated_at` + +--- + +## `settings_schema` + `settings_values` + +- `settings_schema`: Definition (Typ, Default, Validation, Reihenfolge) +- `settings_values`: aktueller Wert pro Key + +--- + +## `scripts`, `script_chains`, `script_chain_steps` + +- `scripts`: Shell-Skripte (`name`, `script_body`, `order_index`) +- `script_chains`: Ketten (`name`, `order_index`) +- `script_chain_steps`: Schritte je Kette + - `step_type`: `script` oder `wait` + - `script_id` oder `wait_seconds` + +--- + +## `user_presets` + +Benannte HandBrake-Preset-Sets: + +- `name` +- `media_type` (`bluray|dvd|other|all`) +- `handbrake_preset` +- `extra_args` +- `description` + +--- + +## `cron_jobs` + `cron_run_logs` + +- `cron_jobs`: Zeitplan + Status +- `cron_run_logs`: einzelne Läufe + - `status`: `running|success|error` + - `output` + - `error_message` + +--- + +## `aax_activation_bytes` + +Cache für Audible-Activation-Bytes (AAX-DRM). + +Felder: + +- `checksum` — SHA-1-Prüfsumme der AAX-Datei (Primärschlüssel) +- `activation_bytes` — Hex-String der Activation Bytes +- `created_at` + +--- + +## `user_prefs` + +Benutzer-spezifische Einstellungen (Key-Value). + +Felder: + +- `key` +- `value` +- `updated_at` + +--- + +## `converter_scan_entries` + +DB-seitige Erfassung gescannter Dateien im Converter-Eingangsordner. + +Felder: + +- `rel_path` — relativer Pfad zum `converter_raw_dir` +- `is_dir` — Verzeichnis-Flag +- `size` — Dateigröße in Bytes +- `modified_at` — Datei-Änderungsdatum +- `job_id` — zugewiesener Converter-Job (falls vorhanden) +- `scanned_at` + +--- + +## Migration/Recovery + +Beim Start werden Schema und Settings-Metadaten automatisch abgeglichen. + +Bei korruptem SQLite-File: + +1. Datei wird nach `backend/data/corrupt-backups/` verschoben +2. neue DB wird initialisiert +3. Schema wird neu aufgebaut + +--- + +## Direkte Inspektion + +```bash +sqlite3 backend/data/ripster.db + +.mode table +SELECT id, status, title, media_type, created_at FROM jobs ORDER BY created_at DESC; +SELECT id, parent_job_id, job_kind, is_multipart_movie, disc_number FROM jobs ORDER BY created_at DESC; +SELECT key, value FROM settings_values ORDER BY key; +SELECT checksum, activation_bytes FROM aax_activation_bytes; +SELECT rel_path, job_id FROM converter_scan_entries; +``` diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md new file mode 100644 index 0000000..06f92e0 --- /dev/null +++ b/docs/architecture/frontend.md @@ -0,0 +1,125 @@ +# Frontend-Komponenten + +Frontend: React + PrimeReact + Vite. + +--- + +## Hauptseiten + +### `RipperPage.jsx` + +Pipeline-Steuerung: + +- Status/Progress/ETA +- Metadaten-Dialog +- Playlist-Entscheidung +- Review-Panel (Track-Auswahl) +- Queue-Interaktion (reorder/add/remove) +- Job-Aktionen (Start/Cancel/Retry/Re-Encode) +- Hardware-Monitoring-Anzeige +- Aktivitäts-Tracking (Skripte, Ketten, Cron) + +### `SettingsPage.jsx` + +Konfiguration: + +- dynamisches Settings-Formular (`DynamicSettingsForm`) +- Skripte/Ketten inkl. Reorder/Test +- User-Presets +- Cron-Jobs (`CronJobsTab`) + +### `HistoryPage.jsx` + +Historie: + +- Job-Liste/Filter +- Job-Details + Logs +- TMDb-Neuzuordnung +- Re-Encode/Restart-Workflows + +### `ConverterPage.jsx` + +Datei-Converter: + +- Datei-Explorer des Converter-Eingangsordners (Baum-Ansicht) +- Upload von Audio/Video-Dateien (bis zu 50 Dateien gleichzeitig) +- Dateiverwaltung: Umbenennen, Verschieben, Löschen, Ordner erstellen +- Jobs aus Dateiauswahl erstellen +- Converter-Job-Konfiguration (Ausgabeformat, Preset, Tracks) +- Job-Start, Abbruch, Löschung +- Automatischer Scan-Status + +### `AudiobooksPage.jsx` + +Dedizierter AAX-/Audiobook-Flow: + +- Upload-Panel für AAX-Dateien +- aktive Audiobook-Jobkarten mit Start/Cancel/Retry/Delete +- Audiobook-Konfigurationspanel und Output-Explorer + +### `DownloadsPage.jsx` + +Download-Queue: + +- Ausgabedateien aus der Job-Historie in die Queue einreihen +- Download-Status verfolgen (ausstehend, wird verarbeitet, bereit, fehlgeschlagen) +- Dateien als ZIP herunterladen +- Download-Einträge löschen + +### `DatabasePage.jsx` + +Expert-Modus: + +- Tabellarische Rohsicht der Job-Datenbank +- Orphan-RAW-Import + +--- + +## Wichtige Komponenten + +- `PipelineStatusCard.jsx` — Pipeline-Status-Anzeige mit Progress +- `MetadataSelectionDialog.jsx` — TMDb-Metadaten-Auswahl +- `MediaInfoReviewPanel.jsx` — Track-Auswahl-Interface (Video/Audio/Untertitel) +- `JobDetailDialog.jsx` — Job-Detailansicht mit Logs +- `CronJobsTab.jsx` — Cron-Job-Verwaltung +- `DynamicSettingsForm.jsx` — Schema-gesteuertes Einstellungsformular +- `ConverterJobCard.jsx` — Converter-Job-Darstellung +- `CdRipConfigPanel.jsx` — Konfiguration für Audio-CD-Ripping + +--- + +## API-Client (`api/client.js`) + +- zentraler `request()` mit JSON-Handling +- Fehlerobjekt aus API wird auf `Error(message)` gemappt +- `VITE_API_BASE` default `/api` + +--- + +## WebSocket (`hooks/useWebSocket.js`) + +- URL: `VITE_WS_URL` oder automatisch `ws(s):///ws` +- Auto-Reconnect mit 1500ms Intervall + +In `App.jsx` werden u. a. verarbeitet: + +- `PIPELINE_STATE_CHANGED` +- `PIPELINE_PROGRESS` +- `PIPELINE_QUEUE_CHANGED` +- Disk erkannt / Disk entfernt +- `HARDWARE_MONITOR_UPDATE` +- `DOWNLOADS_UPDATED` +- `CONVERTER_SCAN_UPDATE` +- `RUNTIME_ACTIVITY_CHANGED` + +--- + +## Build/Run + +```bash +# dev +npm run dev --prefix frontend + +# prod build +npm run build --prefix frontend +``` diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 0000000..7ae42e4 --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,52 @@ +# Anhang: Architektur + +Ripster ist eine Client-Server-Anwendung mit REST + WebSocket und externen CLI-Tools. + +--- + +## Systemüberblick + +```mermaid +graph TB + subgraph Browser["Browser (React)"] + Ripper[Ripper] + Settings[Einstellungen] + History[Historie] + end + + subgraph Backend["Node.js Backend"] + API[REST API\nExpress] + WS[WebSocket\n/ws] + Pipeline[pipelineService] + Cron[cronService] + DB[(SQLite)] + end + + subgraph Tools["Externe Tools"] + MakeMKV[makemkvcon] + HandBrake[HandBrakeCLI] + MediaInfo[mediainfo] + end + + Browser <-->|HTTP| API + Browser <-->|WebSocket| WS + Pipeline --> MakeMKV + Pipeline --> HandBrake + Pipeline --> MediaInfo + API --> DB + Pipeline --> DB + Cron --> DB +``` + +--- + +## Details + +
+ +- [: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) + +
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..c800f17 --- /dev/null +++ b/docs/architecture/overview.md @@ -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 diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md new file mode 100644 index 0000000..61a5b92 --- /dev/null +++ b/docs/configuration/environment.md @@ -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 diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 0000000..8a0f4d8 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,29 @@ +# Anhang: Konfiguration + +Dieser Abschnitt ist die technische Referenz zu allen Konfigurationsarten in Ripster. + +## Inhalte + +
+ +- :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) + +
+ +## Zurück zum Handbuch + +- [Benutzerhandbuch Überblick](../getting-started/index.md) diff --git a/docs/configuration/settings-reference.md b/docs/configuration/settings-reference.md new file mode 100644 index 0000000..a3a2c37 --- /dev/null +++ b/docs/configuration/settings-reference.md @@ -0,0 +1,1472 @@ +# Einstellungsreferenz + +Diese Seite beschreibt jedes Feld aus `Settings -> Konfiguration` aus Anwendersicht: was du dort steuerst, wann die Einstellung greift und welchen Einfluss sie auf Review, Queue, Pfade oder Automatisierung hat. + +## So liest du diese Seite + +- `Standard` nennt den voreingestellten Wert oder den üblichen Leerzustand. +- `Greift in` sagt dir, in welchem Ablauf du die Wirkung besonders merkst. +- `Praxis` erklärt, warum du das Feld im Alltag bewusst setzen solltest. + +## Benachrichtigungen + +### Expertenmodus +Standard: `Aus` + +Der Schalter blendet zusätzliche technische Felder in der gesamten Settings-Oberfläche ein, zum Beispiel für Logging, Polling-Intervalle oder CLI-Feinjustierung. Er wird sofort gespeichert und wirkt ohne extra Speichern der ganzen Seite. + +Greift in: +- die Sichtbarkeit von Expertenfeldern in `Settings` +- zusätzliche Navigations- und Wartungsbereiche + +Praxis: +- Aktiviere ihn, wenn du Ripster genauer steuern oder Fehler analysieren willst. +- Lass ihn aus, wenn die Oberfläche bewusst schlank bleiben soll. + +### PushOver aktiviert +Standard: `Aus` + +Das ist der Master-Schalter für alle PushOver-Nachrichten. Ist er aus, sendet Ripster unabhängig von den einzelnen Ereignis-Toggles nichts. + +Greift in: +- Push-Benachrichtigungen zu Auswahl, Start, Erfolg, Fehler und Re-Encode + +Praxis: +- Aktiviere den Schalter nur zusammen mit gültigem Token und User-Key. + +### PushOver Token +Standard: `leer` + +Hier hinterlegst du den Application Token deiner PushOver-Anwendung. Ohne diesen Wert kann Ripster keine Nachricht zustellen. + +Greift in: +- jeden PushOver-Versand + +Praxis: +- Der Wert stammt aus deiner PushOver-App-Konfiguration. + +### PushOver User +Standard: `leer` + +Hier steht der User-Key des PushOver-Kontos, an das Ripster senden soll. + +Greift in: +- jeden PushOver-Versand + +Praxis: +- Trage hier den Zielaccount ein, der die Meldungen wirklich erhalten soll. + +### PushOver Device (optional) +Standard: `leer` + +Mit diesem Feld begrenzt du PushOver-Nachrichten auf ein bestimmtes Gerät. Bleibt es leer, gehen die Nachrichten an alle Geräte des PushOver-Users. + +Greift in: +- die Zielauswahl beim Versand + +Praxis: +- Sinnvoll, wenn Ripster nur ein bestimmtes Telefon oder Tablet benachrichtigen soll. + +### PushOver Titel-Präfix +Standard: `Ripster` + +Das Präfix wird vor den eigentlichen Titel jeder PushOver-Nachricht gesetzt. So kannst du mehrere Instanzen oder Systeme leichter auseinanderhalten. + +Greift in: +- die Betreffzeile aller PushOver-Nachrichten + +Praxis: +- Typische Werte sind zum Beispiel `Ripster Keller`, `Wohnzimmer-Ripper` oder der Hostname. + +### PushOver Priority +Standard: `0` + +Die Priorität steuert, wie dringend PushOver die Nachricht behandelt. Niedrigere Werte sind leiser, höhere Werte auffälliger. + +Greift in: +- die Zustellungsart auf der PushOver-Seite + +Praxis: +- Für normale Statusmeldungen reicht meist `0`. +- Höhere Prioritäten sind eher für Fehler oder manuelle Eingriffe gedacht. + +### PushOver Timeout (ms) +Standard: `7000` + +Dieser Wert legt fest, wie lange Ripster auf eine Antwort von PushOver wartet, bevor der Versand als fehlgeschlagen gilt. + +Greift in: +- die HTTP-Verbindung zu PushOver + +Praxis: +- Ein höherer Wert kann bei langsamen Verbindungen helfen. +- Ein niedrigerer Wert sorgt dafür, dass Hänger schneller abgebrochen werden. + +### Bei manueller Auswahl senden +Standard: `An` + +Ripster sendet eine Nachricht, wenn ein Job auf eine Benutzerentscheidung wartet, zum Beispiel bei Metadatenauswahl, RAW-Entscheidung, Playlist-Auswahl oder Titelwahl vor dem Review. + +Greift in: +- Playlistflow +- RAW-Wiederverwendung +- manuelle Review-Vorbereitung + +Praxis: +- Das ist eines der wichtigsten Events, wenn Ripster unbeaufsichtigt laufen soll. + +### Bei Rip-Start senden +Standard: `An` + +Sendet eine Nachricht in dem Moment, in dem der eigentliche MakeMKV-Rip startet. + +Greift in: +- Disc-Workflows im Ripper + +Praxis: +- Nützlich, wenn du wissen willst, dass die Wartephase beendet ist und das Laufwerk jetzt wirklich arbeitet. + +### Bei Encode-Start senden +Standard: `An` + +Sendet eine Nachricht beim Start von HandBrake oder eines anderen Encode-Schritts. + +Greift in: +- Film-, Serien-, Converter- und Audiobook-Encodes + +Praxis: +- Hilfreich, wenn Rip und Encode auf unterschiedlichen Systemlasten beruhen und du den eigentlichen Rechenstart mitbekommen willst. + +### Bei Erfolg senden +Standard: `An` + +Sendet eine Nachricht nach erfolgreichem Abschluss eines Jobs. + +Greift in: +- alle erfolgreichen Pipeline-Läufe + +Praxis: +- Gut für lange Nachtläufe oder Stapelverarbeitung. + +### Bei Fehler senden +Standard: `An` + +Sendet eine Nachricht, sobald ein Job in einen Fehlerzustand läuft. + +Greift in: +- Fehler in Rip, Analyse, Review-Aufbau, Encode oder Folgeaktionen + +Praxis: +- Dieses Event sollte in produktiven Umgebungen fast immer aktiv bleiben. + +### Bei Abbruch senden +Standard: `An` + +Sendet eine Nachricht, wenn ein Job manuell abgebrochen wurde. + +Greift in: +- Benutzerabbrüche im laufenden Workflow + +Praxis: +- Vor allem nützlich, wenn mehrere Personen mit derselben Instanz arbeiten. + +### Bei Re-Encode Start senden +Standard: `An` + +Sendet eine Nachricht beim Start eines Re-Encodes aus vorhandenem RAW. + +Greift in: +- `RAW neu encodieren` +- `Review neu starten` mit erneutem Encode + +Praxis: +- Praktisch, wenn du bestehende RAWs später gesammelt neu verarbeitest. + +### Bei Re-Encode Erfolg senden +Standard: `An` + +Sendet eine Nachricht, wenn ein Re-Encode erfolgreich abgeschlossen wurde. + +Greift in: +- Nachbearbeitung aus der Historie + +Praxis: +- Besonders hilfreich bei Korrekturläufen auf Basis alter RAWs. + +## Converter + +### Erlaubte Datei-Endungen +Standard: `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` + +Die Liste bestimmt, welche Dateitypen der Converter-Upload akzeptiert und welche Dateien beim Scannen des Converter-Eingangsordners berücksichtigt werden. Die Einträge werden kommagetrennt und ohne Punkt gepflegt. + +Greift in: +- Upload im Converter +- manueller Scan +- Auto-Scan + +Praxis: +- Entferne Formate, die in deiner Umgebung nie verarbeitet werden sollen. +- Ergänze nur Formate, die Ripster später auch sinnvoll als Job behandeln kann. + +### Auto-Scan (Polling) +Standard: `Aus` + +Wenn dieser Schalter aktiv ist, scannt Ripster den Converter-Eingangsordner in festen Abständen automatisch neu. Neue oder verschobene Dateien tauchen dann ohne manuellen Klick auf. + +Greift in: +- die Dateiansicht des Converters +- automatisches Erkennen externer Dateiimporte + +Praxis: +- Sinnvoll, wenn Dateien per Netzfreigabe, Script oder anderer Software in den Converter gelegt werden. + +### Polling-Intervall (Sekunden) +Standard: `300` + +Der Wert bestimmt, wie oft der Converter-Eingangsordner bei aktivem Auto-Scan geprüft wird. + +Greift in: +- Reaktionsgeschwindigkeit des Converter-Scans +- Systemlast durch wiederholte Verzeichnisprüfungen + +Praxis: +- Kleinere Werte aktualisieren schneller, erzeugen aber mehr Dateisystemzugriffe. + +## Laufwerk + +### Laufwerksmodus +Standard: `auto` + +Hier legst du fest, ob Ripster optische Laufwerke automatisch erkennen soll oder ob du feste Laufwerke manuell definierst. + +Greift in: +- Laufwerkserkennung +- Disc-Monitoring +- Zuordnung von Laufwerk und MakeMKV-Quelle + +Praxis: +- `auto` passt für die meisten Einzel-Laufwerke. +- Ein expliziter Modus ist besser, wenn mehrere Laufwerke stabil an bestimmte Geräte gebunden werden sollen. + +### Explizite Laufwerke +Standard: `leer` + +In diesem Editor legst du pro Laufwerk einen Gerätepfad und den passenden MakeMKV-Index fest. Diese Liste wird nur verwendet, wenn der Laufwerksmodus auf einen expliziten Betrieb gestellt ist. + +Greift in: +- Multi-Drive-Setups +- stabile Zuordnung zwischen Linux-Gerät und MakeMKV-Quelle + +Praxis: +- Besonders hilfreich bei zwei oder mehr Laufwerken oder bei wechselnden USB-Adaptern. + +### Device Pfad +Standard: `/dev/sr0` + +Dieses Feld ist normalerweise ausgeblendet und dient nur als älterer Einzelwert für feste Laufwerkskonfigurationen. In der Praxis solltest du stattdessen den Editor `Explizite Laufwerke` verwenden. + +Greift in: +- interne Fallbacks bei expliziter Laufwerkskonfiguration + +Praxis: +- Nur relevant für Sonderfälle oder Altdaten. + +### MakeMKV Source Index +Standard: `0` + +Dieses normalerweise ausgeblendete Feld legt den MakeMKV-Quellindex fest, wenn kein expliziter Laufwerks-Eintrag greift. + +Greift in: +- interne Quellauflösung für MakeMKV + +Praxis: +- Im Alltag wird dieser Wert meist automatisch aus der Laufwerkskonfiguration abgeleitet. + +### Automatische Disk-Erkennung +Standard: `An` + +Wenn der Schalter aktiv ist, prüft Ripster regelmäßig, ob neue Discs eingelegt wurden. Ist er aus, musst du Laufwerke manuell neu einlesen. + +Greift in: +- automatisches Erkennen neu eingelegter Discs + +Praxis: +- Deaktiviere die Automatik nur, wenn sie in deiner Umgebung stört oder du volle manuelle Kontrolle willst. + +### Polling Intervall (ms) +Standard: `4000` + +Der Wert bestimmt, wie häufig Ripster nach neuen oder entfernten Discs sucht. + +Greift in: +- Reaktionsgeschwindigkeit der Disc-Erkennung +- Häufigkeit der Laufwerksabfragen + +Praxis: +- Ein kleinerer Wert reagiert schneller. +- Ein größerer Wert reduziert die Polling-Last. + +### Laufwerk nach Rip auswerfen +Standard: `Aus` + +Wenn aktiv, wirft Ripster das beteiligte Laufwerk nach einem erfolgreich abgeschlossenen Rip automatisch aus. + +Greift in: +- Disc-Workflows nach erfolgreichem Rip + +Praxis: +- Nützlich bei Stapelbetrieb, damit du sofort siehst, welches Laufwerk fertig ist. + +## Logging + +### Terminal-Ausgabe aktiv +Standard: `An` + +Dieser Schalter steuert die Konsolenausgabe des Servers im Terminal. Die Log-Dateien auf Platte bleiben davon unberührt. + +Greift in: +- `start.sh` +- Service-Diagnose im Terminal + +Praxis: +- Deaktiviere die Ausgabe, wenn die Konsole bewusst ruhig bleiben soll. + +### HTTP-Logs im Terminal +Standard: `An` + +Zeigt oder unterdrückt eingehende HTTP-Requests in der Konsolenausgabe. + +Greift in: +- API-Debugging +- Analyse von Frontend-/Backend-Kommunikation + +Praxis: +- Besonders bei Fehlersuche im Webinterface nützlich. + +### DEBUG im Terminal +Standard: `An` + +Steuert, ob sehr detaillierte Debug-Meldungen im Terminal mitlaufen. + +Greift in: +- tiefes Troubleshooting + +Praxis: +- Gut für Fehlersuche, oft zu ausführlich für den Dauerbetrieb. + +### INFO im Terminal +Standard: `An` + +Steuert normale Statusmeldungen in der Konsolenausgabe. + +Greift in: +- alltägliche Laufzeitbeobachtung + +Praxis: +- Für normale Bedienung meist sinnvoll aktiviert. + +### WARN im Terminal +Standard: `An` + +Steuert Warnmeldungen in der Konsole. + +Greift in: +- Hinweise auf ungewöhnliche, aber nicht sofort kritische Situationen + +Praxis: +- Sollte fast immer sichtbar bleiben. + +### ERROR im Terminal +Standard: `An` + +Steuert Fehlermeldungen in der Konsole. + +Greift in: +- alle ernsthaften Laufzeitfehler + +Praxis: +- Sollte im Regelfall immer aktiviert bleiben. + +## Metadaten + +### TMDb Read Access Token +Standard: `leer` + +Ohne diesen Token kann Ripster keine reguläre TMDb-Metadatensuche für Filme und Serien durchführen. Der Wert wird für Suche, Zuordnung und spätere Neu-Zuweisungen verwendet. + +Greift in: +- Metadatenauswahl nach der Analyse +- Serienzuordnung +- `TMDb neu zuweisen` +- Poster- und Titellookups + +Praxis: +- Dieses Feld gehört zu den wichtigsten Grundeinstellungen für Disc-Workflows. + +### Film-Sprache +Standard: `de-DE` + +Hier legst du fest, in welcher TMDb-Sprache Ripster Titel, Beschreibungen, Staffelnamen und ähnliche Daten zuerst abruft. + +Greift in: +- Film- und Serienmetadaten in der Auswahl +- Sprache der zurückgegebenen TMDb-Daten + +Praxis: +- Wähle die Sprache, in der du Titel und Episodennamen später auch in Ordnern und Historie sehen willst. + +### Fallback Sprache +Standard: `de-DE,en-US` + +Wenn in der Hauptsprache keine brauchbaren Daten vorhanden sind, versucht Ripster nacheinander diese Fallback-Sprachen. + +Greift in: +- Trefferqualität bei seltenen oder schlecht lokalisierten Titeln + +Praxis: +- Eine Kombination aus lokaler Sprache und `en-US` ist oft am robustesten. + +### Coverart-Nachladen aktiv +Standard: `An` + +Wenn bei Jobs oder Containern Cover fehlen, prüft Ripster in Intervallen automatisch nach und versucht das Material später nachzuladen. + +Greift in: +- nachträgliche Pflege von Postern und Covern + +Praxis: +- Sinnvoll, wenn beim ersten Lauf gelegentlich noch keine vollständigen Bilder verfügbar waren. + +### Coverart-Prüfintervall (Stunden) +Standard: `6` + +Legt fest, wie oft die automatische Cover-Prüfung laufen darf. + +Greift in: +- Häufigkeit des Cover-Nachladens + +Praxis: +- Kleinere Werte aktualisieren schneller, größere Werte reduzieren Hintergrundaktivität. + +### NFO nach Encode erzeugen +Standard: `Aus` + +Wenn dieser Schalter aktiv ist, legt Ripster nach erfolgreichem Encode eine `.nfo`-Datei neben der finalen Ausgabedatei an. + +Greift in: +- Film- und Serienausgabe +- Nachbearbeitung in Media-Centern + +Praxis: +- Hilfreich für Setups mit Kodi, Jellyfin-Begleitdateien oder eigener Archivierung. + +## Monitoring + +### Hardware Monitoring aktiviert +Standard: `An` + +Das ist der Master-Schalter für CPU-, RAM-, GPU- und Speicher-Monitoring. Ist er aus, stoppt Ripster Polling und Live-Updates für diese Daten. + +Greift in: +- die kompakte Anzeige im `Ripper` +- die Seite `Hardware` + +Praxis: +- Deaktiviere das Monitoring nur, wenn du die Werte nicht brauchst oder die Abfragen in deiner Umgebung unerwünscht sind. + +### Hardware Monitoring Intervall (ms) +Standard: `5000` + +Hier stellst du ein, wie oft Hardware- und Speicherwerte neu ermittelt werden. + +Greift in: +- Aktualität der Hardware-Daten +- Polling-Last + +Praxis: +- Kürzere Intervalle fühlen sich live an, erzeugen aber mehr Messaufrufe. + +## Pfade + +### Grundregeln für Pfade, Besitzer und Templates + +- Leere Pfadfelder fallen je nach Bereich auf eingebaute Standardpfade oder auf den allgemeineren Profilpfad zurück. +- Besitzer-Felder erwarten `Benutzer:Gruppe` oder `UID:GID`, zum Beispiel `media:media` oder `1000:1000`. +- Wenn ein Besitzer gesetzt ist, versucht Ripster neue oder fertiggestellte Dateien nach dem Schreiben entsprechend zu übernehmen. +- In Templates erzeugt `/` Unterordner. +- Templates greifen immer auf die relative Struktur innerhalb des jeweiligen Zielordners. + +### Raw Ausgabeordner (Blu-ray) +Standard: `leer` + +Hier landet das von Blu-ray-Discs erzeugte RAW-Material, sofern kein spezieller Serien-RAW-Ordner greift. + +Greift in: +- Film- und Disc-Vorbereitung für Blu-ray +- Wiederverwendung vorhandener RAWs + +Praxis: +- Lege RAW-Material gern auf ein großes, schnelles Laufwerk. + +### Raw Ausgabeordner (DVD) +Standard: `leer` + +Hier landet das von DVD-Discs erzeugte RAW-Material, sofern kein spezieller Serien-RAW-Ordner greift. + +Greift in: +- DVD-Rip-Phase +- RAW-Wiederverwendung bei DVDs + +Praxis: +- Trenne Blu-ray- und DVD-RAW, wenn du unterschiedliche Speicherorte oder Aufbewahrungsstrategien willst. + +### CD RAW-Ordner +Standard: `leer` + +In diesem Ordner legt Ripster die rohen CD-Dateien aus dem Audio-CD-Rip ab, bevor das endgültige Ausgabeformat erzeugt wird. + +Greift in: +- Audio-CD-Rip + +Praxis: +- Sinnvoll auf einem lokalen Datenträger mit genug Platz für WAV-Zwischendaten. + +### Audiobook RAW-Ordner +Standard: `leer` + +Das ist der Basisordner für hochgeladene Audiobook-Quelldateien und deren vorbereitete Rohdaten. + +Greift in: +- AAX-Upload +- Audiobook-Vorbereitung + +Praxis: +- Gut geeignet für einen Bereich, in dem auch größere temporäre Quelldateien liegen dürfen. + +### Serien-Ordner (Blu-ray) +Standard: `leer` + +Das ist der Zielordner für fertige Blu-ray-Serienepisoden. + +Greift in: +- Ausgabe von Blu-ray-Serien + +Praxis: +- Typisch ist eine Trennung von Serien und Filmen auf Verzeichnisebene. + +### Film Ausgabeordner (Blu-ray) +Standard: `leer` + +Hierhin schreibt Ripster fertige Blu-ray-Filme. + +Greift in: +- finale Film-Ausgabe für Blu-ray + +Praxis: +- Wenn leer, greift Ripster auf den allgemeinen Film-Output zurück. + +### Film Ausgabeordner (DVD) +Standard: `leer` + +Hierhin schreibt Ripster fertige DVD-Filme. + +Greift in: +- finale Film-Ausgabe für DVD + +Praxis: +- Praktisch, wenn DVD- und Blu-ray-Filme getrennt archiviert werden sollen. + +### Serien-Ordner (DVD) +Standard: `leer` + +Das ist der Zielordner für fertige DVD-Serienepisoden. + +Greift in: +- Ausgabe von DVD-Serien + +Praxis: +- Kann bewusst getrennt vom Blu-ray-Serienordner geführt werden. + +### CD Output-Ordner +Standard: `leer` + +Hier landen die final encodierten Audio-CD-Dateien. Wenn das Feld leer ist, nutzt Ripster denselben Bereich wie für die CD-RAW-Daten oder den eingebauten Standard. + +Greift in: +- finale Audio-CD-Ausgabe + +Praxis: +- Trenne RAW und Ausgabe, wenn du Rohdaten nur kurzfristig behalten willst. + +### Audiobook Output-Ordner +Standard: `leer` + +Hier landen die fertigen Audiobook-Dateien. + +Greift in: +- `m4b` +- kapitelweises `mp3` +- kapitelweises `flac` + +Praxis: +- Lege diesen Ordner dort an, wo dein Hörbuch-Archiv liegen soll. + +### Download ZIP-Ordner +Standard: `leer` + +Ripster erstellt hier vorbereitete ZIP-Downloads aus RAW oder Output und legt auch die zugehörigen Metadateien dort ab. + +Greift in: +- Download-Aktionen aus der Historie + +Praxis: +- Gut geeignet für einen Bereich mit genug temporärem Platz für Archive. + +### Externe Speicher +Standard: `leer` + +Hier hinterlegst du zusätzliche Speicherorte, die Ripster in der Oberfläche unter den freien Speichern anzeigen soll. Diese Liste ist für die Überwachung gedacht, nicht als Schreibziel. + +Greift in: +- Speicheranzeige im Monitoring + +Praxis: +- Nützlich für USB-Platten, NAS-Mounts oder andere Zielmedien, deren freien Platz du im Blick behalten willst. + +### Log Ordner +Standard: `installationsabhängig` + +Das ist der Basisordner für die Log-Dateien von Ripster. Wenn der Pfad ungültig ist, fällt Ripster auf einen internen Fallback zurück. + +Greift in: +- Backend-Logs +- Job-Logs + +Praxis: +- Lege Logs auf persistenten Speicher, wenn du Fehlerverläufe langfristig aufbewahren willst. + +### CD Output Template +Standard: `{artist} - {album} ({year})/{trackNr} {artist} - {title}` + +Dieses Template steuert Ordnerstruktur und Dateinamen der finalen Audio-CD-Ausgabe. Der letzte Abschnitt wird Dateiname, alles davor Unterordner. + +Greift in: +- Audio-CD-Ordnerstruktur +- Track-Dateinamen + +Praxis: +- Beispiel: Mit dem Standard entsteht typischerweise `Interpret - Album (2024)/01 Interpret - Titel.flac`. + +### Output Template (Blu-ray) +Standard: `${title} (${year})/${title} (${year})` + +Dieses Template bestimmt Ordner und Dateiname für fertige Blu-ray-Filme. + +Greift in: +- finale Blu-ray-Filmausgabe + +Praxis: +- Das Standardtemplate erzeugt einen Filmordner und darin eine gleichnamige Datei. + +### Output Template (Blu-ray Serie, Episode) +Standard: `{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}` + +Dieses Template bestimmt den Pfad für einzelne Blu-ray-Serienfolgen. + +Greift in: +- Serien-Workflow mit Einzel-Episoden + +Praxis: +- Beispiel: `Serie/Staffel 02/S02E05 - Episodentitel.mkv`. + +### Output Template (Blu-ray Serie, Multi-Episode) +Standard: `{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})` + +Dieses Template greift, wenn mehrere Folgen in einer Datei zusammengefasst bleiben. + +Greift in: +- Serien-Workflow mit Doppelfolgen oder Play-All-Fällen + +Praxis: +- So bleibt direkt im Dateinamen sichtbar, dass mehrere Episoden in einer Datei liegen. + +### Output Template (DVD) +Standard: `${title} (${year})/${title} (${year})` + +Dieses Template bestimmt Ordner und Dateiname für fertige DVD-Filme. + +Greift in: +- finale DVD-Filmausgabe + +Praxis: +- Das Verhalten entspricht dem Blu-ray-Filmtemplate, nur für das DVD-Profil. + +### Output Template (DVD Serie, Episode) +Standard: `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeNo} - {episodeTitle}` + +Dieses Template steuert den Pfad für einzelne DVD-Serienfolgen. + +Greift in: +- DVD-Serien mit einzelner Episodenausgabe + +Praxis: +- Nutze es, um dein bevorzugtes Serien-Schema exakt abzubilden. + +### Output Template (DVD Serie, Multi-Episode) +Standard: `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeRange}` + +Dieses Template greift für zusammengefasste DVD-Folgen in einer Datei. + +Greift in: +- Multi-Episode-Ausgabe im DVD-Serienworkflow + +Praxis: +- Besonders relevant bei TV-DVDs mit Play-All-Titeln oder Doppelfolgen. + +### Output Template (Audiobook) +Standard: `{author}/{author} - {title} ({year})` + +Dieses Template bestimmt den Dateipfad für die Ein-Datei-Ausgabe von Audiobooks, also vor allem für `m4b`. + +Greift in: +- Audiobook-Ausgabe als Einzeltitel + +Praxis: +- Beispiel: `Autor/Autor - Titel (2023).m4b`. + +### Audiobook RAW Template +Standard: `{author} - {title} ({year})` + +Dieses Template steuert den Namen des relativen RAW-Unterordners, in den die Audiobook-Quelldaten gelegt werden. + +Greift in: +- Audiobook-Upload und RAW-Ablage + +Praxis: +- Es hilft dabei, AAX-Rohdaten sauber nach Titel und Jahr zu organisieren. + +### Converter Raw-Ordner +Standard: `leer` + +Das ist der Eingangsordner des Converters. Uploads, neue Unterordner und externe Dateiimporte landen hier. + +Greift in: +- gesamte Converter-Dateiverwaltung + +Praxis: +- Dieser Ordner sollte gut erreichbar und groß genug für Rohdateien sein. + +### Converter Ausgabe (Video) +Standard: `leer` + +Hierhin schreibt Ripster final konvertierte Video- oder ISO-Ergebnisse aus dem Converter. + +Greift in: +- Converter-Videojobs + +Praxis: +- Lege hier den Zielbereich für fertige Video-Transcodes fest. + +### Converter Ausgabe (Audio) +Standard: `leer` + +Hierhin schreibt Ripster die finalen Audio-Ergebnisse aus dem Converter. + +Greift in: +- Converter-Audiojobs + +Praxis: +- Sinnvoll getrennt vom Video-Ausgabeordner. + +### Output-Template (Video) +Standard: `{title}` + +Dieses Template bestimmt den Dateinamen von Converter-Videojobs. Unterordner sind ebenfalls möglich. + +Greift in: +- Converter-Videoausgabe + +Praxis: +- Beispiel: `{title}/{title}` erzeugt einen Unterordner pro Titel. + +### Output-Template (Audio) +Standard: `{artist} - {title}` + +Dieses Template bestimmt Dateiname und bei Bedarf Ordnerstruktur von Converter-Audiojobs. + +Greift in: +- einzelne Audio-Dateien +- gemeinsame Audio-Jobs +- Ordnerjobs + +Praxis: +- Bei gemeinsamen Audio-Jobs kann der vordere Teil des Templates als Ordner dienen, der hintere Teil als Track-Schema. + +### RAW-Ordner (Blu-ray Serie) +Standard: `leer` + +Hier kannst du Blu-ray-Serien-RAW bewusst getrennt vom allgemeinen Blu-ray-RAW ablegen. + +Greift in: +- Serien-RAW-Erzeugung bei Blu-ray + +Praxis: +- Sinnvoll, wenn Serien deutlich andere Speicher- oder Aufbewahrungsregeln haben als Filme. + +### Eigentümer RAW-Ordner (Blu-ray Serie) +Standard: `leer` + +Legt den Besitzer für Blu-ray-Serien-RAW fest. Ripster versucht neue Dateien und Ordner nach dem Schreiben entsprechend zu übernehmen. + +Greift in: +- Dateirechte auf Serien-RAW + +Praxis: +- Typisch für NAS-Freigaben oder Docker-Setups mit getrennten Nutzern. + +### Eigentümer Raw-Ordner (Blu-ray) +Standard: `leer` + +Legt den Besitzer für allgemeine Blu-ray-RAW-Daten fest. + +Greift in: +- Dateirechte auf Blu-ray-RAW + +Praxis: +- Relevant, wenn der Ripster-Dienst nicht als Zielnutzer schreiben soll. + +### RAW-Ordner (DVD Serie) +Standard: `leer` + +Hier kannst du DVD-Serien-RAW getrennt vom allgemeinen DVD-RAW ablegen. + +Greift in: +- Serien-RAW-Erzeugung bei DVD + +Praxis: +- Sinnvoll, wenn Serienmaterial getrennt verwaltet werden soll. + +### Eigentümer RAW-Ordner (DVD Serie) +Standard: `leer` + +Legt den Besitzer für DVD-Serien-RAW fest. + +Greift in: +- Dateirechte auf DVD-Serien-RAW + +Praxis: +- Besonders wichtig in Mehrbenutzer- oder NAS-Umgebungen. + +### Eigentümer Raw-Ordner (DVD) +Standard: `leer` + +Legt den Besitzer für allgemeine DVD-RAW-Daten fest. + +Greift in: +- Dateirechte auf DVD-RAW + +Praxis: +- Nutze das Feld, wenn finale Rechte nicht beim Service-User liegen sollen. + +### Eigentümer CD RAW-Ordner +Standard: `leer` + +Legt den Besitzer für Rohdaten aus Audio-CD-Rips fest. + +Greift in: +- Rechte auf CD-Zwischendaten + +Praxis: +- Hilfreich, wenn andere Tools oder Nutzer auf die Rohdaten zugreifen sollen. + +### Eigentümer Audiobook RAW-Ordner +Standard: `leer` + +Legt den Besitzer für Audiobook-Rohdaten fest. + +Greift in: +- Rechte auf AAX- und Vorbereitungsdaten + +Praxis: +- Relevant, wenn Audiobook-RAW nicht beim Dienstbenutzer verbleiben soll. + +### Eigentümer Converter RAW-Ordner +Standard: `leer` + +Legt den Besitzer für Dateien im Converter-Eingangsbereich fest. + +Greift in: +- Rechte auf Uploads und importierte Converter-Dateien + +Praxis: +- Praktisch, wenn Dateien nach dem Upload auch außerhalb von Ripster bearbeitet werden. + +### Eigentümer Serien-Ordner (Blu-ray) +Standard: `leer` + +Legt den Besitzer für fertige Blu-ray-Serienausgaben fest. + +Greift in: +- finale Rechte auf Blu-ray-Serien + +Praxis: +- Sinnvoll für Media-Center-Freigaben. + +### Eigentümer Film-Ordner (Blu-ray) +Standard: `leer` + +Legt den Besitzer für fertige Blu-ray-Filmdateien fest. + +Greift in: +- finale Rechte auf Blu-ray-Filme + +Praxis: +- Nutze das Feld, wenn Filme auf einem NAS oder von anderen Diensten gelesen werden. + +### Eigentümer Film-Ordner (DVD) +Standard: `leer` + +Legt den Besitzer für fertige DVD-Filmdateien fest. + +Greift in: +- finale Rechte auf DVD-Filme + +Praxis: +- Gleiches Prinzip wie beim Blu-ray-Filmordner, nur für DVD. + +### Eigentümer Serien-Ordner (DVD) +Standard: `leer` + +Legt den Besitzer für fertige DVD-Serienausgaben fest. + +Greift in: +- finale Rechte auf DVD-Serien + +Praxis: +- Hilfreich für gemeinsame Medienarchive. + +### Eigentümer CD Output-Ordner +Standard: `leer` + +Legt den Besitzer der finalen Audio-CD-Ausgabe fest. + +Greift in: +- Rechte auf FLAC-, MP3- oder andere CD-Endformate + +Praxis: +- Wichtig, wenn Musik später durch andere Anwendungen verwaltet wird. + +### Eigentümer Audiobook Output-Ordner +Standard: `leer` + +Legt den Besitzer der finalen Audiobook-Dateien fest. + +Greift in: +- Rechte auf `m4b`, `mp3` und `flac` + +Praxis: +- Sinnvoll für Bibliotheken, die von separaten Diensten gelesen werden. + +### Eigentümer Converter Video Output-Ordner +Standard: `leer` + +Legt den Besitzer der finalen Converter-Videoausgabe fest. + +Greift in: +- Rechte auf konvertierte Video-Dateien + +Praxis: +- Nützlich bei geteilten Medienordnern. + +### Eigentümer Converter Audio Output-Ordner +Standard: `leer` + +Legt den Besitzer der finalen Converter-Audioausgabe fest. + +Greift in: +- Rechte auf konvertierte Audio-Dateien + +Praxis: +- Hilfreich, wenn Audio-Dateien später von anderen Diensten verschoben oder indiziert werden. + +### Eigentümer Download ZIP-Ordner +Standard: `leer` + +Legt den Besitzer der erzeugten Download-Archive und Begleitdateien fest. + +Greift in: +- Rechte auf ZIP-Downloads aus der Historie + +Praxis: +- Besonders relevant, wenn Downloads über Freigaben oder einen anderen Webserver bereitgestellt werden. + +### Kapitel Template (Audiobook) +Standard: `{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}` + +Dieses Template steuert die Zielstruktur für kapitelweise Audiobook-Ausgaben wie `mp3` oder `flac`. + +Greift in: +- Kapiteldateien im Audiobook-Workflow + +Praxis: +- Beispiel: `Autor/Autor - Titel (2023)/01 Kapitelname.mp3`. + +## Tools + +### Grundregeln für diese Kategorie + +- Die Tool-Kommandos sagen Ripster, welches externe Programm gestartet werden soll. +- `Preset` und `Extra Args` bestimmen das Verhalten der jeweiligen CLI. +- Sprach-Review-Felder greifen nur als Vorauswahl in der Review und nur dann, wenn ein Preset nicht bereits selbst eine explizite Trackauswahl erzwingt. + +### MakeMKV Kommando +Standard: `makemkvcon` + +Hier gibst du den Befehl oder den vollständigen Pfad zur MakeMKV-CLI an. + +Greift in: +- Disc-Analyse +- Rip von Blu-ray und DVD + +Praxis: +- Trage einen absoluten Pfad ein, wenn das Tool nicht im `PATH` des Dienstes liegt. + +### MakeMKV Key +Standard: `leer` + +Hier hinterlegst du den Registrierungsschlüssel für MakeMKV. Ripster synchronisiert ihn beim Speichern in die MakeMKV-Konfiguration und kann außerdem den aktuellen Betakey übernehmen. + +Greift in: +- MakeMKV-Nutzung ohne Trial-Einschränkungen + +Praxis: +- Sinnvoll, wenn die CLI dauerhaft und ohne manuelle Host-Pflege nutzbar bleiben soll. + +### Mediainfo Kommando +Standard: `mediainfo` + +Hier gibst du den Befehl oder Pfad zu `mediainfo` an. + +Greift in: +- Mediainfo-Prüfung +- Review-Aufbau +- Analyse vorhandener Dateien + +Praxis: +- Wichtig für alle Workflows, die Track- und Streaminformationen zuverlässig aufbauen sollen. + +### Minimale Titellänge (Minuten) +Standard: `60` + +Diese Einstellung wirkt doppelt: Ripster hängt den Wert bei der MakeMKV-Analyse als Mindestlänge an, sofern du nicht schon selbst eine explizite Mindestlänge in den Extra-Args gesetzt hast. Zusätzlich markiert Ripster Titel unterhalb dieser Schwelle später im Review als nicht encodierwürdig. + +Greift in: +- Titel- und Playlist-Vorfilterung +- Disc-Analyse +- Review-Aufbau + +Praxis: +- Ein hoher Wert blendet Bonusmaterial und Trailer aus. +- Ein zu hoher Wert kann aber auch kürzere Haupttitel oder Episoden verdrängen. + +### TMDb Runtime-Abzug für Playlistfilter (%) +Standard: `0` + +Ripster vergleicht bei problematischen Blu-rays die Disc-Playlists mit der Laufzeit aus TMDb. Dieser Wert wird prozentual von der TMDb-Laufzeit abgezogen und bildet damit eine lockerere Untergrenze für akzeptable Playlist-Kandidaten. Zusätzlich bleibt immer eine Mindesttoleranz von zwei Minuten nach unten erhalten. + +Greift in: +- Playlistflow vor der Review +- Kandidatenauswahl bei Hauptfilm-Playlists + +Praxis: +- Beispiel: Steht ein Film in TMDb mit 120 Minuten und du trägst `5` ein, arbeitet Ripster für die Kandidatensuche mit rund 114 Minuten als Untergrenze, solange die feste Zweiminuten-Toleranz nicht ohnehin höher wäre. +- Das hilft bei Discs, deren korrekte Hauptfassung etwas kürzer gemeldet wird als in TMDb. + +### HandBrake Kommando +Standard: `HandBrakeCLI` + +Hier gibst du den Befehl oder Pfad zu `HandBrakeCLI` an. + +Greift in: +- alle Video-Encodes +- Re-Encode aus RAW +- Converter-Videojobs + +Praxis: +- Bei mehreren HandBrake-Installationen kannst du hier gezielt eine bestimmte Binary verwenden. + +### Encode-Neustart: unvollständige Ausgabe löschen +Standard: `An` + +Wenn du einen Encode aus der Historie neu startest und der vorherige Lauf nicht erfolgreich war, entfernt Ripster den unvollständigen Output vor dem Neustart automatisch. Erfolgreiche Ausgaben bleiben davon unberührt. + +Greift in: +- `Encode neu starten` + +Praxis: +- Aktiviert verhindert diese Option, dass alte Teil-Dateien mit dem neuen Lauf kollidieren. + +### Parallele Jobs +Standard: `1` + +Das ist das allgemeine Startlimit für gleichzeitig laufende Jobs in der Pipeline. Alles darüber wird in die Warteschlange gestellt. + +Greift in: +- Ripper +- Converter +- Audiobooks +- Queue-Pumpe im Hintergrund + +Praxis: +- Ein höherer Wert erhöht den Durchsatz, belastet aber CPU, I/O und ggf. mehrere Laufwerke stärker. + +### Script-Test Timeout (ms) +Standard: `0` + +Dieser Wert gilt nur für den Testlauf von Skripten in `Settings`. `0` bedeutet: kein Timeout. + +Greift in: +- `Scripte`-Tab +- Tests einzelner Skripte oder Kettenbausteine + +Praxis: +- Setze hier einen festen Wert, wenn du hängende Testskripte automatisch abbrechen willst. + +### Max. parallele Audio CD Jobs +Standard: `2` + +Diese Einstellung begrenzt speziell, wie viele Audio-CD-Jobs gleichzeitig laufen dürfen. Sie wirkt zusätzlich zum allgemeinen Joblimit. + +Greift in: +- Audio-CD-Queue +- Start mehrerer CDs hintereinander + +Praxis: +- So kannst du mehrere CDs parallel zulassen, ohne den Videobereich freizugeben. + +### Max. Encodes gesamt (medienunabhängig) +Standard: `3` + +Das ist der harte Deckel für alle gleichzeitig laufenden Encodes zusammen, unabhängig davon, ob sie von Film, Serie, Converter, CD oder Audiobook stammen. + +Greift in: +- alle Encode-Starts +- Gesamtlast der Maschine + +Praxis: +- Diese Grenze sticht die Einzellimits aus, wenn zu viele Medienarten gleichzeitig starten wollen. + +### Audio CDs: Queue-Reihenfolge überspringen +Standard: `Aus` + +Wenn aktiviert, dürfen Audio-CD-Jobs vor normalen Film- oder Videojobs starten, auch wenn diese früher in der Queue stehen. Die Parallelitäts- und Gesamtlimits gelten trotzdem weiter. + +Greift in: +- Reihenfolge der Queue +- Audio-CD-Durchsatz in gemischten Warteschlangen + +Praxis: +- Sinnvoll, wenn CDs schnell abgearbeitet werden sollen und nicht hinter langen Videoencodes hängen bleiben sollen. + +### cdparanoia Kommando +Standard: `cdparanoia` + +Hier gibst du den Befehl oder Pfad zu `cdparanoia` an. + +Greift in: +- Audio-CD-Rip + +Praxis: +- Wichtig, wenn `cdparanoia` nicht im Standardpfad des Systems liegt. + +### FFmpeg Kommando +Standard: `ffmpeg` + +Hier gibst du den Befehl oder Pfad zu `ffmpeg` an. + +Greift in: +- Audiobook-Encodes +- Audio-Nachbearbeitung + +Praxis: +- Ohne korrektes `ffmpeg` schlagen Audiobook-Ausgaben fehl. + +### FFprobe Kommando +Standard: `ffprobe` + +Hier gibst du den Befehl oder Pfad zu `ffprobe` an. + +Greift in: +- Analyse von Audiobooks +- Kapitel- und Metadatenaufbau + +Praxis: +- Ohne `ffprobe` kann Ripster Kapitel und Dateidetails nicht zuverlässig auslesen. + +### Mediainfo Extra Args (Blu-ray) +Standard: `leer` + +Diese zusätzlichen Argumente hängt Ripster bei Blu-ray-Mediainfo-Aufrufen an den Befehl an. + +Greift in: +- Mediainfo-Prüfung für Blu-ray +- Review-Aufbau aus bestehenden Dateien + +Praxis: +- Nur für gezielte Spezialfälle anpassen, wenn du das CLI-Verhalten bewusst überschreiben willst. + +### MakeMKV Rip Modus (Blu-ray) +Standard: `backup` + +Dieses normalerweise ausgeblendete Systemfeld bestimmt, ob Blu-rays als vollständige Disc-Struktur oder direkt als MKV gerippt werden. + +Greift in: +- technische RAW-Erzeugung bei Blu-ray + +Praxis: +- Für normale Nutzung bleibt dieser Wert unverändert. + +### MakeMKV Analyze Extra Args (Blu-ray) +Standard: `leer` + +Diese zusätzlichen Argumente werden an den Blu-ray-Analyseaufruf von MakeMKV angehängt. + +Greift in: +- Disc-Analyse +- Titel- und Playlist-Erkennung + +Praxis: +- Beachte, dass eine selbst gesetzte Mindestlänge in diesen Args die allgemeine Einstellung zur minimalen Titellänge übersteuert. + +### MakeMKV Rip Extra Args (Blu-ray) +Standard: `leer` + +Diese zusätzlichen Argumente werden an den eigentlichen Blu-ray-Rip angehängt. + +Greift in: +- RAW-Erzeugung bei Blu-ray + +Praxis: +- Nur anpassen, wenn du das MakeMKV-Verhalten sehr gezielt erweitern oder überschreiben willst. + +### HandBrake Preset (Blu-ray) +Standard: `H.264 MKV 1080p30` + +Dieses Feld setzt den technischen HandBrake-Standard für Blu-ray-Jobs, sofern im konkreten Job nichts anderes gewählt wird. + +Greift in: +- Review-Vorgaben +- Encode-Start bei Blu-ray + +Praxis: +- Das Preset ist deine Grundlinie; im Review oder über User-Presets kann später bewusst davon abgewichen werden. + +### HandBrake Extra Args (Blu-ray) +Standard: sprach- und codecbezogene Standardargumente + +Hier definierst du zusätzliche HandBrake-CLI-Argumente für Blu-ray-Jobs. Sie steuern zum Beispiel Audio-Sprachauswahl, Untertitelvorgaben, Encoder-Profil und Qualitätsparameter. + +Greift in: +- finalen HandBrake-Aufruf bei Blu-ray +- Vorauswahl von Audio und Untertiteln + +Praxis: +- Diese Einstellung ist sehr mächtig: Ein expliziter Audio- oder Untertitel-Selektor hier kann die separaten Review-Sprachfelder faktisch überstimmen. + +### Review Audio-Sprachen (Blu-ray Film) +Standard: `leer` + +Hier definierst du, welche Audiosprachen in der Review eines Blu-ray-Films automatisch vorausgewählt werden sollen. Ripster normalisiert übliche Schreibweisen wie `de`, `deu`, `en` oder `eng`. + +Greift in: +- `Bereit zum Encodieren` bei Blu-ray-Filmen + +Praxis: +- Beispiel: `de, en`. +- Bleibt das Feld leer, entscheidet Ripster stärker aus Preset und vorhandenen Tracks heraus. + +### Review UT-Sprachen (Blu-ray Film) +Standard: `leer` + +Hier definierst du die Untertitelsprachen, die in der Review eines Blu-ray-Films vorselektiert werden. + +Greift in: +- Untertitelvorauswahl im Film-Review + +Praxis: +- Gut für feste Standards wie nur Deutsch und Englisch. + +### Review Audio-Sprachen (Blu-ray Serie) +Standard: `leer` + +Dieses Feld funktioniert wie das Film-Pendant, greift aber nur bei Blu-ray-Serien. + +Greift in: +- Audio-Vorauswahl im Serien-Review + +Praxis: +- Praktisch, wenn du für Serien andere Sprachregeln als für Filme nutzt. + +### Review UT-Sprachen (Blu-ray Serie) +Standard: `leer` + +Dieses Feld funktioniert wie das Film-Pendant, greift aber nur bei Blu-ray-Serien. + +Greift in: +- Untertitel-Vorauswahl im Serien-Review + +Praxis: +- So lassen sich Serien-Defaults getrennt von Film-Defaults pflegen. + +### Ausgabeformat (Blu-ray) +Standard: `mkv` + +Hier legst du die Dateiendung der finalen Blu-ray-Ausgabe fest. + +Greift in: +- finalen Dateinamen +- Containerformat der Ausgabe + +Praxis: +- Wähle das Format, das zu deinem Player- oder Archiv-Setup passt. + +### Mediainfo Extra Args (DVD) +Standard: `leer` + +Diese zusätzlichen Argumente hängen an den Mediainfo-Aufrufen für DVD-Jobs. + +Greift in: +- Mediainfo-Prüfung für DVD + +Praxis: +- Nur für gezielte technische Sonderfälle anpassen. + +### MakeMKV Rip Modus (DVD) +Standard: `mkv` + +Dieses normalerweise ausgeblendete Systemfeld legt fest, ob DVDs direkt als MKV oder als Disc-Struktur gerippt werden. + +Greift in: +- technische RAW-Erzeugung bei DVD + +Praxis: +- Im Normalfall unverändert lassen. + +### MakeMKV Analyze Extra Args (DVD) +Standard: `leer` + +Diese zusätzlichen Argumente werden an den DVD-Analyseaufruf von MakeMKV angehängt. + +Greift in: +- DVD-Titelerkennung +- spätere Review-Vorbereitung + +Praxis: +- Wie bei Blu-ray gilt: eigene Mindestlängen-Args können die allgemeine Mindestlänge übersteuern. + +### MakeMKV Rip Extra Args (DVD) +Standard: `leer` + +Diese zusätzlichen Argumente werden beim eigentlichen DVD-Rip an MakeMKV angehängt. + +Greift in: +- RAW-Erzeugung bei DVD + +Praxis: +- Nur bewusst ändern, wenn du das CLI-Verhalten genau kennst. + +### HandBrake Preset (DVD) +Standard: `H.264 MKV 480p30` + +Dieses Feld setzt den technischen HandBrake-Standard für DVD-Jobs. + +Greift in: +- Review-Vorgaben +- Encode-Start bei DVD + +Praxis: +- Eine gute Basis für DVD kann sich deutlich von Blu-ray unterscheiden, deshalb gibt es ein eigenes Feld. + +### HandBrake Extra Args (DVD) +Standard: sprach- und codecbezogene Standardargumente + +Hier definierst du die zusätzlichen HandBrake-CLI-Argumente für DVD-Jobs. + +Greift in: +- finalen HandBrake-Aufruf bei DVD +- Audio- und Untertitelvorauswahl + +Praxis: +- Wie bei Blu-ray können explizite Selektionsargumente in diesen Args separate Review-Sprachfelder aushebeln. + +### Review Audio-Sprachen (DVD Film) +Standard: `leer` + +Definiert die Audio-Vorauswahl für DVD-Filme in der Review. + +Greift in: +- Film-Review bei DVD + +Praxis: +- Beispiel: `de, en`. + +### Review UT-Sprachen (DVD Film) +Standard: `leer` + +Definiert die Untertitel-Vorauswahl für DVD-Filme in der Review. + +Greift in: +- Untertitel-Review bei DVD-Filmen + +Praxis: +- Gut für feste Disc-Standards in deiner Sammlung. + +### Review Audio-Sprachen (DVD Serie) +Standard: `leer` + +Definiert die Audio-Vorauswahl für DVD-Serien in der Review. + +Greift in: +- Serien-Review bei DVD + +Praxis: +- Trenne Serien-Defaults bewusst von Film-Defaults, wenn nötig. + +### Review UT-Sprachen (DVD Serie) +Standard: `leer` + +Definiert die Untertitel-Vorauswahl für DVD-Serien in der Review. + +Greift in: +- Untertitel-Review bei DVD-Serien + +Praxis: +- Besonders nützlich bei Serien mit vielen redundanten UT-Spuren. + +### Ausgabeformat (DVD) +Standard: `mkv` + +Hier legst du die Dateiendung der finalen DVD-Ausgabe fest. + +Greift in: +- finalen Dateinamen +- Containerformat der DVD-Ausgabe + +Praxis: +- Dieses Feld ist vom Blu-ray-Format getrennt, damit beide Profile unabhängig voneinander arbeiten können. + +### mkvmerge Kommando +Standard: `mkvmerge` + +Hier gibst du den Befehl oder Pfad zu `mkvmerge` an. + +Greift in: +- Multipart-Film-Zusammenführungen + +Praxis: +- Relevant, wenn du mehrteilige Filme oder Disc-Splits später in eine zusammenhängende Ausgabe überführen willst. + +## Ausgeblendete Felder + +Einige technische Systemfelder sind im Schema vorhanden, werden aber im normalen Formular nicht aktiv angeboten oder nur intern benutzt. In der Doku sind sie oben trotzdem erklärt, damit auch Altdaten, API-Fälle und Spezialsetups nachvollziehbar bleiben. diff --git a/docs/deployment/development.md b/docs/deployment/development.md new file mode 100644 index 0000000..3a1eaf3 --- /dev/null +++ b/docs/deployment/development.md @@ -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 +``` + diff --git a/docs/deployment/index.md b/docs/deployment/index.md new file mode 100644 index 0000000..7bd90a2 --- /dev/null +++ b/docs/deployment/index.md @@ -0,0 +1,23 @@ +# Anhang: Deployment + +Technische Betriebsdokumentation für Entwicklung und Produktion. + +
+ +- :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) + +
diff --git a/docs/deployment/production.md b/docs/deployment/production.md new file mode 100644 index 0000000..a5f3208 --- /dev/null +++ b/docs/deployment/production.md @@ -0,0 +1,233 @@ +# Produktions-Deployment + +--- + +## Automatische Installation (empfohlen) + +Das mitgelieferte `install.sh` richtet Ripster vollautomatisch ein – inklusive Node.js, MakeMKV, HandBrake, nginx und systemd-Dienst. + +**Unterstützte Systeme laut Script:** Debian, Ubuntu, Linux Mint, Pop!_OS +**Voraussetzung:** root-Rechte, Internetzugang + +### Schnellstart via curl + +```bash +curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash +``` + +Oder mit wget: + +```bash +wget -qO- https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash +``` + +!!! warning "Optionen nur via Datei" + Beim Pipen von curl/wget können keine Argumente übergeben werden. Für benutzerdefinierte Optionen zuerst herunterladen und dann mit `sudo bash install.sh [Optionen]` ausführen. + +### Optionen + +| Option | Standard | Beschreibung | +|--------|----------|--------------| +| `--branch ` | `dev` | Git-Branch für die Installation | +| `--dir ` | `/opt/ripster` | Installationsverzeichnis | +| `--user ` | `ripster` | Systembenutzer für den Dienst | +| `--port ` | `3001` | Backend-Port | +| `--host ` | 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 `: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://` (oder der mit `--host` angegebene Hostname) + +### HandBrake-Modus (GPU/NVDEC) + +Bei nicht-interaktiver Ausführung (Pipe von curl) wird automatisch die Standard-Version gewählt. Für die GPU-Version zuerst herunterladen: + +```bash +curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh -o install.sh +sudo bash install.sh +# → Interaktive Auswahl: Option 2 für NVDEC +``` + +Das gebündelte Binary liegt unter `bin/HandBrakeCLI` und wird nach `/usr/local/bin/HandBrakeCLI` kopiert. + +--- + +## Manuelle Installation + +Die folgenden Abschnitte beschreiben die einzelnen Schritte für manuelle oder angepasste Setups. + +### Empfohlene Architektur + +```text +Client + -> nginx (Reverse Proxy + statisches Frontend) + -> Backend API/WebSocket (Node.js, Port 3001) +``` + +Wichtig: Das Backend serviert im aktuellen Stand keine `frontend/dist`-Dateien automatisch. + +--- + +## 1) Frontend builden + +```bash +cd frontend +npm install +npm run build +``` + +Artefakte liegen in `frontend/dist/`. + +--- + +## 2) Backend als systemd-Service + +Beispiel `/etc/systemd/system/ripster-backend.service`: + +```ini +[Unit] +Description=Ripster Backend +After=network.target + +[Service] +Type=simple +User=ripster +WorkingDirectory=/opt/ripster/backend +ExecStart=/usr/bin/env node src/index.js +Restart=on-failure +RestartSec=5 +Environment=NODE_ENV=production +Environment=PORT=3001 +Environment=LOG_LEVEL=info + +[Install] +WantedBy=multi-user.target +``` + +Aktivieren: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now ripster-backend +sudo systemctl status ripster-backend +``` + +--- + +## 3) nginx konfigurieren + +Beispiel `/etc/nginx/sites-available/ripster`: + +```nginx +server { + listen 80; + server_name ripster.local; + client_max_body_size 8G; + + root /opt/ripster/frontend/dist; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /ws { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +Aktivieren: + +```bash +sudo ln -s /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +Hinweis: Fuer groessere Uploads wie `.aax`-Audiobooks muss `client_max_body_size` ausreichend hoch gesetzt sein. Im mitgelieferten Beispiel sind `8G` hinterlegt. + +--- + +## Datenbank-Backup + +```bash +sqlite3 /opt/ripster/backend/data/ripster.db \ + ".backup '/var/backups/ripster-$(date +%Y%m%d).db'" +``` + +--- + +## Sicherheit + +- Ripster hat keine eingebaute Authentifizierung. +- Für externen Zugriff mindestens Basic Auth + TLS + Netzwerksegmentierung/VPN einsetzen. +- Secrets nicht ins Repo committen (`.env`, Settings-Felder). diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..41022d4 --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,151 @@ +# Ersteinrichtung + +Diese Seite ist die Betriebs-Checkliste vor dem ersten produktiven Lauf. + +## Ziel der Ersteinrichtung + +Vor dem ersten Job sollten fünf Bereiche sauber gesetzt sein: + +1. Pfade und Templates +2. Laufwerk und Disc-Erkennung +3. Metadatenzugang über TMDb +4. Encode-Standards und Queue-Regeln +5. optionale Automatisierung und Benachrichtigungen + +## Empfohlene Reihenfolge + +### 1. Basis in `Settings -> Konfiguration` + +Prüfe zuerst diese Kernfelder: + +- RAW- und Zielordner für Blu-ray, DVD, CD, Audiobooks und Converter +- `Log Ordner` +- `TMDb Read Access Token` +- `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando` +- `FFmpeg Kommando`, `FFprobe Kommando`, `cdparanoia Kommando`, `mkvmerge Kommando`, falls du die jeweiligen Workflows nutzt + +Danach speichern. + +### 2. Pfade und Templates bewusst festlegen + +Wichtig für den Alltag: + +- Film-Workflows nutzen die RAW- und Film-Ausgabeordner sowie die Film-Output-Templates +- Serien-Workflows nutzen zusätzlich die Serien-RAW-Ordner, die Serien-Zielordner und die Episode-/Multi-Episode-Templates +- Audiobooks nutzen `Audiobook RAW-Ordner`, `Audiobook Output-Ordner`, `Audiobook RAW Template`, `Output Template (Audiobook)` und `Kapitel Template (Audiobook)` +- Converter nutzt `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)` sowie `Output-Template (Video)` und `Output-Template (Audio)` +- Downloads nutzen `Download ZIP-Ordner` + +Wenn du auf NAS, USB oder mit anderen Benutzern/Gruppen schreiben willst, prüfe auch die jeweils zugehörigen `Eigentümer ...`-Felder. + +### 3. Laufwerk und automatische Erkennung prüfen + +Relevant: + +- `Laufwerksmodus` +- `Explizite Laufwerke`, falls du nicht mit Auto-Discovery arbeiten willst +- `Automatische Disk-Erkennung` +- `Polling Intervall (ms)` im Expertenmodus +- `Laufwerk nach Rip auswerfen` + +Empfehlung: + +- Einzelnes Standardlaufwerk: `auto` +- mehrere stabile Laufwerke oder ungewöhnliche Device-Mappings: `Explizite Laufwerke` + +### 4. TMDb und Metadatenverhalten festlegen + +Relevant: + +- `TMDb Read Access Token` +- `Film-Sprache` +- `Fallback Sprache` +- `Coverart-Nachladen aktiv` +- `Coverart-Prüfintervall (Stunden)` +- `NFO nach Encode erzeugen` + +Diese Einstellungen wirken direkt in Metadatensuche, Serienzuordnung, Nachpflege von Covern und im finalen Output. + +### 5. Encode- und Review-Standards festlegen + +Für Video-Workflows besonders wichtig: + +- `Minimale Titellänge (Minuten)` +- `TMDb Runtime-Abzug für Playlistfilter (%)` +- `HandBrake Preset` und `HandBrake Extra Args` je Medium +- `Review Audio-Sprachen (...)` +- `Review UT-Sprachen (...)` +- `Ausgabeformat` + +Praxis: + +- `Minimale Titellänge` filtert Bonusmaterial früh weg +- `TMDb Runtime-Abzug ...` lockert bei problematischen Blu-rays die Laufzeitprüfung der Playlist-Kandidaten +- Review-Sprachen bestimmen, welche Audio-/Untertitelspuren in der Review schon vorausgewählt sind + +### 6. Queue-Verhalten definieren + +Stelle diese vier Felder bewusst ein: + +- `Parallele Jobs` +- `Max. parallele Audio CD Jobs` +- `Max. Encodes gesamt (medienunabhängig)` +- `Audio CDs: Queue-Reihenfolge überspringen` + +Damit steuerst du, wie aggressiv Ripster mehrere Jobs gleichzeitig startet. + +### 7. Optional: User-Presets und Standard-Zuordnungen + +Unter `Settings -> Encode-Presets` kannst du: + +- eigene HandBrake-User-Presets anlegen +- Standard-Zuordnungen für `Blu-ray Film`, `Blu-ray Serie`, `DVD Film`, `DVD Serie` setzen + +Diese Defaults wirken später in der Review automatisch vorbefüllend. + +### 8. Optional: Benachrichtigungen + +Prüfe: + +- `PushOver aktiviert` +- Token/User/optional Device +- Event-Toggles wie `Bei manueller Auswahl senden`, `Bei Rip-Start senden`, `Bei Erfolg senden` + +Danach in `Settings` den Button `PushOver Test` ausführen. + +### 9. Optional: Automatisierung + +Erst wenn die Grundkonfiguration stabil ist: + +1. Skripte anlegen und testen +2. Skriptketten aufbauen +3. Cronjobs aktivieren + +## Funktionsprüfung + +Ein sinnvoller Kurztest: + +1. Disc in `Ripper` erkennen lassen +2. `Analyse starten` +3. TMDb-Metadaten übernehmen +4. ggf. Playlist-/RAW-Entscheidung durchlaufen +5. bis `Bereit zum Encodieren` kommen +6. Encode starten +7. Ergebnis in `Historie` prüfen + +## Typische Konfigurationsfehler + +| Symptom | Häufige Ursache | +|---|---| +| Disc wird nicht erkannt | Laufwerksmodus oder Auto-Erkennung passt nicht | +| Playlist-Auswahl wirkt unplausibel | `Minimale Titellänge` oder `TMDb Runtime-Abzug ...` ist zu streng/zu locker | +| falsche Spuren sind in der Review vorausgewählt | Review-Sprachfilter oder HandBrake-Args greifen unerwartet | +| Ausgabe landet am falschen Ort | Profilpfade oder Templates greifen über Fallback | +| Queue verhält sich unerwartet | `Parallele Jobs`, CD-Limit und Gesamtlimit widersprechen sich | + +## Weiter + +- [Erster Lauf](quickstart.md) +- [GUI-Seiten](../gui/index.md) +- [Workflows aus Nutzersicht](../workflows/index.md) +- [Alle Einstellungen](../configuration/settings-reference.md) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000..6506308 --- /dev/null +++ b/docs/getting-started/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) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..eafdc42 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,119 @@ +# Installation + +Die empfohlene Installation startet immer über `setup.sh`. `setup.sh` lädt das passende `install.sh` aus dem gewünschten Branch und übergibt alle weiteren Parameter unverändert an den eigentlichen Installer. + +Wichtig: + +- Standard-Einstieg: `setup.sh` +- kein `curl | bash` +- du musst die Tools nicht manuell vorinstallieren, sofern du sie nicht bewusst mit `--no-*` auslässt + +## Voraussetzungen + +- unterstütztes Linux-System +- Root-Rechte oder `sudo` +- Internetzugang während der Installation + +Der Installer erkennt aktuell: + +- Debian +- Ubuntu +- Linux Mint +- Pop!_OS + +## Standardinstallation + +```bash +wget -qO setup.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh +bash setup.sh +``` + +Ohne `--branch` fragt `setup.sh` interaktiv nach dem gewünschten Branch. + +Die typischen Branches sind: + +- `main`: stabiles Release +- `dev`: aktueller Entwicklungsstand + +## Installation mit Branch oder Optionen + +Beispiele: + +```bash +bash setup.sh --branch main +bash setup.sh --branch dev +bash setup.sh --branch dev --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10 +``` + +`setup.sh` wertet selbst nur `--branch` aus. Alles andere übernimmt `install.sh`. + +## Wichtige Optionen von `install.sh` + +| Option | Default | Zweck | +|---|---|---| +| `--branch ` | `dev` | installiert genau diesen Git-Branch | +| `--dir ` | `/opt/ripster` | Installationsverzeichnis | +| `--user ` | `ripster` | Systembenutzer für den Dienst | +| `--port ` | `3001` | Backend-Port | +| `--host ` | automatisch ermittelte Host-IP | Hostname/IP für Webzugriff und CORS | +| `--no-makemkv` | aus | MakeMKV nicht installieren | +| `--no-handbrake` | aus | HandBrake nicht installieren | +| `--no-nginx` | aus | nginx-Setup überspringen | +| `--no-system-deps` | aus | Systemabhängigkeiten nicht nachinstallieren | +| `--reinstall` | aus | bestehende Installation aktualisieren | + +## Was der Installer einrichtet + +Typischer Ablauf: + +1. Betriebssystem, Root-Rechte und Host prüfen +2. Systemabhängigkeiten installieren +3. Node.js 20 sicherstellen +4. optional MakeMKV installieren +5. optional HandBrakeCLI installieren +6. optional nginx konfigurieren +7. Repository nach `--dir` klonen oder aktualisieren +8. Backend-/Frontend-Abhängigkeiten installieren +9. Frontend bauen +10. `backend/.env` und Laufzeitverzeichnisse vorbereiten +11. `ripster-backend.service` anlegen und starten + +## Dienst prüfen + +```bash +sudo systemctl status ripster-backend +``` + +Typische Aufrufe im Betrieb: + +```bash +sudo journalctl -u ripster-backend -f +sudo systemctl restart ripster-backend +``` + +## Update einer bestehenden Installation + +Standard: + +```bash +sudo bash /opt/ripster/install.sh --reinstall +``` + +Wenn die Installation mit abweichenden Kernparametern eingerichtet wurde, dieselben Parameter wieder mitgeben: + +```bash +sudo bash /opt/ripster/install.sh --reinstall --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10 +``` + +Alternativ kannst du auch erneut über `setup.sh` gehen: + +```bash +sudo bash /opt/ripster/setup.sh --reinstall +``` + +`--reinstall` aktualisiert die Installation, behält aber die persistenten Daten der bestehenden Instanz. + +## Danach weiter + +1. [Ersteinrichtung](configuration.md) +2. [Erster Lauf](quickstart.md) diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md new file mode 100644 index 0000000..322f403 --- /dev/null +++ b/docs/getting-started/prerequisites.md @@ -0,0 +1,58 @@ +# Voraussetzungen + +Die Voraussetzungen hängen davon ab, ob du Ripster produktiv installieren oder lokal entwickeln willst. + +## Produktionsbetrieb mit `setup.sh` + +Für den normalen Betrieb brauchst du nur die Basisvoraussetzungen. Die eigentlichen Tools werden vom Installer eingerichtet, sofern du sie nicht bewusst mit `--no-*` überspringst. + +### Pflicht + +- unterstütztes Linux-System +- Root-Rechte oder `sudo` +- Internetzugang während der Installation +- optisches Laufwerk, wenn du Disc-Workflows nutzen willst + +Der Installer unterstützt aktuell: + +- Debian +- Ubuntu +- Linux Mint +- Pop!_OS + +Standard-Einstieg: + +```bash +wget -qO setup.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh +bash setup.sh +``` + +### Laufwerk kurz prüfen + +```bash +ls /dev/sr* +lsblk | grep rom +``` + +### Optional vorab + +- TMDb Read Access Token für Film-/Serien-Metadaten +- PushOver-Zugangsdaten für Benachrichtigungen + +Beides kann auch erst nach der Installation in `Settings` gesetzt werden. + +## Entwicklungsmodus + +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 + +- [ ] Linux + Root/Sudo + Internet vorhanden +- [ ] optisches Laufwerk vorhanden, falls Disc-Ripping genutzt werden soll +- [ ] TMDb/PushOver-Zugangsdaten liegen bereit, falls diese Funktionen sofort genutzt werden sollen +- [ ] im Dev-Modus zusätzlich Node.js und CLI-Tools lokal verfügbar diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..4642590 --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,151 @@ +# Erster Lauf + +Dieser Ablauf führt einen typischen Disc-Job von Erkennung bis fertiger Datei durch. + +## 1. Disc erkennen + +1. `Ripper` öffnen +2. Disc einlegen +3. auf `Medium erkannt` warten + +Prüfen: + +- `Disk-Information` zeigt Device, Label und Laufwerksstatus +- falls nicht: `Laufwerk neu lesen` + +Relevante Settings: + +- `Laufwerksmodus` +- `Automatische Disk-Erkennung` +- `Polling Intervall (ms)` + +## 2. Analyse starten + +Aktion: + +- `Analyse starten` + +Erwartung: + +- Status `Analyse` +- danach der Metadaten-Dialog + +Relevantes Setting: + +- `Minimale Titellänge (Minuten)` beeinflusst bereits, welche Titel/Playlists Ripster später als relevant betrachtet + +## 3. Metadaten in TMDb bestätigen + +Im Dialog: + +1. Treffer suchen oder filtern +2. Film oder Serie auswählen +3. bei Serien zusätzlich `Disc-Nummer` setzen +4. `Auswahl übernehmen` + +Mögliche Abzweigungen: + +- Duplikatfilm: `Übernahme erzwingen` oder `Multipart movie` +- vorhandenes RAW: Entscheidungsdialog `weiterverwenden` oder `neu rippen` + +Relevante Settings: + +- `TMDb Read Access Token` +- `Film-Sprache` +- `Fallback Sprache` + +## 4. RAW- und Playlist-Entscheidungen + +Je nach Disc-Situation geht es jetzt unterschiedlich weiter. + +### Normalfall + +`Startbereit` -> `Rippen` -> `Mediainfo-Prüfung` + +### Vorhandenes RAW + +Kein neuer Rip, sondern erst eine Entscheidung: + +- vorhandenes RAW weiterverwenden +- RAW löschen und Rip neu starten +- bei passenden Film-Konstellationen optional Multipart mit Orphan-RAW + +### Playlist- oder Titelauswahl erforderlich + +Bei bestimmten Blu-ray-/DVD-Fällen erscheint `Warte auf Auswahl`. + +Dann bestätigst du: + +- eine Playlist +- oder einen/mehrere HandBrake-Titel + +Relevante Settings: + +- `Minimale Titellänge (Minuten)` +- `TMDb Runtime-Abzug für Playlistfilter (%)` + +## 5. Encode-Review abschließen + +Bei `Bereit zum Encodieren` prüfst du: + +- Titelwahl +- Audio-Spuren +- Untertitel +- User-Preset oder HandBrake-Preset +- Pre-/Post-Encode-Skripte und Ketten + +Dann: + +- `Encoding starten` + +Relevante Settings: + +- `HandBrake Preset` und `HandBrake Extra Args` je Medium +- `Review Audio-Sprachen (...)` +- `Review UT-Sprachen (...)` +- `Ausgabeformat` +- Standard-Zuordnungen unter `Settings -> Encode-Presets` + +## 6. Encoding überwachen + +Während `Encodieren`: + +- Fortschritt/ETA im Ripper +- Queue-Status beobachten +- bei Bedarf `Hardware`-Ansicht öffnen + +Relevante Settings: + +- `Parallele Jobs` +- `Max. parallele Audio CD Jobs` +- `Max. Encodes gesamt (medienunabhängig)` +- `Hardware Monitoring aktiviert` +- `Hardware Monitoring Intervall (ms)` + +## 7. Ergebnis verifizieren + +Nach `Fertig`: + +1. `Historie` öffnen +2. Jobdetail prüfen +3. Output-Pfad und Log kontrollieren + +Kontrollpunkte: + +- finale Datei liegt am erwarteten Template-Pfad +- RAW-Ordner ist finalisiert +- optional `.nfo` wurde erzeugt, falls aktiviert + +Relevante Settings: + +- Film-Output-Templates oder Serien-Templates +- `Serien-Ordner (Blu-ray)` und `Serien-Ordner (DVD)` plus die Episode-/Multi-Episode-Templates +- `NFO nach Encode erzeugen` + +## 8. Typische Folgeaktionen + +- falsche TMDb-Zuordnung: `TMDb neu zuweisen` +- andere Trackauswahl: `Review neu starten` +- erneuter Encode aus RAW: `RAW neu encodieren` +- gleicher Plan erneut: `Encode neu starten` +- Rip erneut versuchen: `Retry Rippen` diff --git a/docs/gui/audiobooks.md b/docs/gui/audiobooks.md new file mode 100644 index 0000000..db1fa89 --- /dev/null +++ b/docs/gui/audiobooks.md @@ -0,0 +1,108 @@ +# Audiobooks + +Die Seite `Audiobooks` ist der spezialisierte Workflow für Audible-`*.aax`-Dateien. + +## Seitenstruktur + +Die Oberfläche besteht aus zwei Hauptbereichen: + +1. `Audiobooks Upload` +2. `Audiobook Jobs` + +## 1. Audiobooks Upload + +Der Upload akzeptiert ausschließlich `.aax`. + +### Bedienung + +- Datei auswählen oder per Drag-and-Drop ablegen +- Upload starten +- Auswahl entfernen oder laufenden Upload abbrechen + +Während des Uploads zeigt die UI: + +- Prozentfortschritt +- übertragene und gesamte Bytes +- Statusmeldung + +### Ablauf nach erfolgreichem Upload + +- die AAX-Datei wird geprüft +- Ripster versucht Metadata, Kapitel und Activation-Bytes-Kontext aufzubauen +- ein Audiobook-Job wird angelegt +- der Job startet direkt oder wird in die Queue eingereiht + +## 2. Audiobook Jobs + +Die Seite zeigt aktive Jobs. Abgeschlossene Jobs findest du in der `Historie`. + +Ein Job kann eingeklappt oder ausgeklappt werden und zeigt: + +- Titel, Autor, Sprecher, Jahr +- Kapitelanzahl +- Fortschritt und ETA +- bei Split-Ausgabe den Kapitelstatus + +## Konfigurierbare Job-Parameter + +Im ausgeklappten Job kannst du setzen: + +- Ausgabeformat `m4b`, `mp3` oder `flac` +- formatspezifische Optionen +- Kapitelnamen +- Pre-Encode-Skripte und -Ketten +- Post-Encode-Skripte und -Ketten + +### Formatverhalten + +- `m4b`: eine Datei mit Kapitelmarken +- `mp3`: eine Datei pro Kapitel +- `flac`: eine Datei pro Kapitel + +Wenn du `mp3` oder `flac` wählst, zeigt die UI zusätzlich eine Kapitel-Status-Tabelle. + +## Activation Bytes + +Wenn Ripster für eine Datei keine Activation Bytes verwenden kann, wird im Workflow eine manuelle Eingabe nötig. + +Wichtig: + +- die Bytes werden lokal gecacht +- derselbe AAX-Hash muss dann später nicht erneut manuell eingegeben werden +- der Expertenblock in `Settings` zeigt bekannte Cache-Einträge an + +## Relevante Settings + +- `Audiobook RAW-Ordner` +- `Audiobook Output-Ordner` +- `Eigentümer Audiobook RAW-Ordner` +- `Eigentümer Audiobook Output-Ordner` +- `Audiobook RAW Template` +- `Output Template (Audiobook)` +- `Kapitel Template (Audiobook)` +- `FFprobe Kommando` +- `FFmpeg Kommando` + +### So wirken die Settings im Alltag + +- `Audiobook RAW Template` bestimmt den Namen des AAX-RAW-Ordners +- `Output Template (Audiobook)` bestimmt die Einzeldatei für `m4b` +- `Kapitel Template (Audiobook)` bestimmt Zielstruktur und Dateinamen für `mp3`/`flac` +- `FFprobe Kommando` liest Kapitel und Metadaten +- `FFmpeg Kommando` erzeugt die finalen Dateien + +## Queue und Laufzeit + +Audiobook-Jobs nutzen dieselbe globale Pipeline wie die anderen Bereiche. + +Relevante Settings: + +- `Parallele Jobs` +- `Max. Encodes gesamt (medienunabhängig)` + +## Typische Fehlerbilder + +- Upload wird abgelehnt: Datei ist keine gültige `*.aax` +- Analyse schlägt fehl: `ffprobe` ist nicht erreichbar +- Encode schlägt fehl: `ffmpeg` ist nicht erreichbar +- Ausgabe landet unerwartet: Templates oder Zielpfade passen nicht zur gewünschten Struktur diff --git a/docs/gui/converter.md b/docs/gui/converter.md new file mode 100644 index 0000000..61fc125 --- /dev/null +++ b/docs/gui/converter.md @@ -0,0 +1,128 @@ +# Converter + +Die Seite `Converter` verarbeitet vorhandene Dateien ohne Laufwerks-Rip. Sie ist für Video-, Audio- und ISO-Dateien gedacht, die bereits auf dem System liegen oder hochgeladen werden. + +## Betriebsmodell + +Der Converter arbeitet auf dem in `Settings` festgelegten `Converter Raw-Ordner`. + +Dort landen: + +- Uploads +- neu angelegte Ordner +- umbenannte oder verschobene Dateien +- alle Dateien, aus denen später Converter-Jobs entstehen + +Wichtig: + +- Upload erzeugt nicht automatisch einen Job +- die Joberstellung erfolgt bewusst aus der Explorer-Auswahl + +## Datei-Explorer + +Der Explorer zeigt den kompletten Baum unter `Converter Raw-Ordner`. + +Aktionen: + +| Aktion | Wirkung | +|---|---| +| `Upload` | schreibt neue Dateien in den Converter-RAW-Baum | +| `Ordner erstellen` | legt Unterordner an | +| `Umbenennen` | benennt Datei oder Ordner um | +| `Verschieben` | verschiebt Einträge innerhalb des Raw-Baums | +| `Löschen` | entfernt Datei oder Ordner dauerhaft | +| `Scannen` | synchronisiert Dateisystem und Converter-Datenbank | + +### Upload- und Scan-Regeln + +- Dateiendungen müssen in `Erlaubte Datei-Endungen` enthalten sein +- neue Dateien erscheinen sofort nach manuellem Scan +- mit Polling auch automatisch + +Relevante Settings: + +- `Erlaubte Datei-Endungen` +- `Auto-Scan (Polling)` +- `Polling-Intervall (Sekunden)` + +## Joberstellung aus Explorer-Auswahl + +Es gibt zwei Audio-Strategien: + +1. `Ein Job pro Datei` +2. `Gemeinsamer Job (Audio)` + +Verhalten: + +- Video und ISO werden immer als Einzeljobs angelegt +- Audio kann als gemeinsamer Album-/Ordner-Job erzeugt werden + +Shared-Audio-Job: + +- hält mehrere `inputPaths` +- kann vor dem Start erweitert oder reduziert werden + +## Job-Konfiguration + +Vor dem Start je Job einstellbar: + +- `converterMediaType` +- `outputFormat` +- Metadaten +- Track-/Titelwahl bei Video/ISO +- User-Preset und HandBrake-Preset bei Video +- Pre-/Post-Encode-Skripte und Ketten + +Spezifika: + +- Video/ISO durchlaufen eine Review ähnlich zum Disc-Flow +- Audio wird direkt über Format und Audio-Optionen konfiguriert + +## Ausgabe-Pfade + +### Video / ISO + +- Zielordner: `Converter Ausgabe (Video)` +- Dateiname: `Output-Template (Video)` +- Endung: aus dem gewählten Ausgabeformat + +### Audio + +- Zielordner: `Converter Ausgabe (Audio)` +- Dateiname/Ordner: `Output-Template (Audio)` +- bei Shared- oder Folder-Jobs entsteht eine Ordnerstruktur mit mehreren Track-Dateien + +Relevante Settings: + +- `Converter Ausgabe (Video)` +- `Converter Ausgabe (Audio)` +- `Output-Template (Video)` +- `Output-Template (Audio)` +- `Eigentümer Converter Video Output-Ordner` +- `Eigentümer Converter Audio Output-Ordner` + +## Queue und Start + +Converter-Jobs laufen über dieselbe globale Pipeline-Queue wie Ripper und Audiobooks. + +Das bedeutet: + +- sofortiger Start, wenn Limits frei sind +- sonst Einreihung in die Warteschlange + +Relevante Settings: + +- `Parallele Jobs` +- `Max. Encodes gesamt (medienunabhängig)` + +## Typische Einsatzfälle + +- vorhandene MKV/MP4-Dateien mit neuem Preset encodieren +- ISO-Dateien in einen Video-Workflow überführen +- Musikdateien gesammelt in einen Audio-Ordner-Job packen +- Medien auf anderem Storage per Upload oder Dateibaum vorbereiten + +## Abgrenzung zu Audiobooks + +- `Converter`: beliebige Audio-, Video- und ISO-Dateien +- `Audiobooks`: spezialisierter AAX-Workflow mit Activation Bytes, Kapitelmetadaten und Kapiteltemplates diff --git a/docs/gui/database.md b/docs/gui/database.md new file mode 100644 index 0000000..7ee6e3d --- /dev/null +++ b/docs/gui/database.md @@ -0,0 +1,35 @@ +# Database (Expert) + +`/database` ist die Recovery- und Expertensicht für Fälle, in denen Dateisystem und Historie nicht mehr sauber zusammenpassen. + +## Zugriff + +- direkte Route: `/database` +- nicht Teil der Standardnavigation + +## Bereich `Gefundene RAW-Einträge` + +Dieser Bereich listet RAW-Verzeichnisse ohne zugeordneten Historie-Job. + +Aktionen: + +- `RAW prüfen`: scannt konfigurierte RAW-Wurzeln +- `Job anlegen`: erstellt aus einem orphan RAW einen neuen Historie-Job + +## Was bei `Job anlegen` passiert + +- RAW-Pfad wird als Importquelle registriert +- Job wird in die Historie geschrieben +- anschließende Analyse läuft als RAW-basierter Einstieg (ohne klassischen Disc-Read) + +Typischer Einsatz: + +- manuelle Dateioperationen außerhalb der UI +- Migrationen +- Wiederherstellung nach abgebrochenen Läufen + +## Sicherheits- und Betriebsregeln + +- Aktionen wirken direkt auf produktive Historie/Dateipfade. +- Vor Import oder Löschung immer Pfad und Ziel prüfen. +- Diese Seite nur für Recovery-Szenarien nutzen, nicht für normalen Tagesbetrieb. diff --git a/docs/gui/downloads.md b/docs/gui/downloads.md new file mode 100644 index 0000000..9849378 --- /dev/null +++ b/docs/gui/downloads.md @@ -0,0 +1,58 @@ +# Downloads + +Auf `Downloads` werden ZIP-Archive für Historie-Artefakte erstellt und bereitgestellt. + +## Was heruntergeladen werden kann + +Ein Download-Eintrag referenziert immer einen Historie-Job und genau ein Ziel: + +- `RAW` +- `Output` + +Erzeugt wird ein ZIP-Archiv im Download-Verzeichnis des Backends. + +## Statusmodell + +| Status | Bedeutung | +|---|---| +| `queued` | Eintrag erstellt, wartet auf Verarbeitung | +| `processing` | ZIP wird gerade gebaut | +| `ready` | Archiv fertig, Download-Link aktiv | +| `failed` | Erzeugung oder Zugriff fehlgeschlagen | + +## Eintrag anlegen + +Startpunkt ist die `Historie`: + +1. Job öffnen. +2. Download einreihen. +3. Ziel (`RAW` oder `Output`) wählen. +4. Status in `Downloads` verfolgen. + +## Wichtige Laufzeitdetails + +- Wenn bereits ein passendes, aktuelles Archiv existiert, kann der Eintrag wiederverwendet werden. +- Nach Backend-Neustart werden unterbrochene `queued`/`processing`-Einträge geprüft und fortgesetzt. +- Fehlt eine erwartete ZIP-Datei bei `ready`, wird der Eintrag auf `failed` gesetzt. + +## Download auslösen + +Bei Status `ready`: + +- Download-Button lädt die ZIP direkt aus `/api/downloads/:id/file`. + +## Einträge entfernen + +`Löschen` entfernt: + +- Queue-Metadaten +- zugehörige Archivdatei + +Nicht erlaubt: + +- Löschen während `queued`/`processing` + +## Echtzeitaktualisierung + +Die Seite reagiert auf `DOWNLOADS_UPDATED` per WebSocket. +Dadurch sind Statuswechsel ohne Reload sichtbar. diff --git a/docs/gui/hardware.md b/docs/gui/hardware.md new file mode 100644 index 0000000..1824f66 --- /dev/null +++ b/docs/gui/hardware.md @@ -0,0 +1,45 @@ +# Hardware + +Die Seite `Hardware` ist die ausführliche Detailansicht für das Live-Monitoring aus dem `Ripper`. + +## Was die Seite zeigt + +- aktuelle CPU-Auslastung +- RAM-Auslastung +- GPU-Auslastung und VRAM, falls vorhanden +- Temperaturen +- Verlaufshistorie für Auslastung und Temperatur +- Zeitfenster für `1h`, `6h`, `24h`, `4d` + +## Live-Bereich + +Oben zeigt die Seite: + +- ob Monitoring aktiv ist +- aktuelles Polling-Intervall +- Zeitpunkt der letzten Messung + +Wenn Monitoring deaktiviert ist, führt die Seite direkt zu `Settings`. + +## Historie + +Die Historie lädt Messpunkte aus dem Backend und zeigt: + +- Auslastung als Linienchart +- Temperatur als Linienchart +- umschaltbare Serien für CPU, RAM und GPU + +Das ist besonders nützlich bei: + +- mehreren parallelen Encodes +- Fehlersuche bei Hitzespitzen +- Abschätzung, ob weitere Jobs sinnvoll gestartet werden können + +## Relevante Settings + +- `Hardware Monitoring aktiviert` +- `Hardware Monitoring Intervall (ms)` + +Außerdem sinnvoll: + +- `Externe Speicher`, damit Speicherstände im `Ripper` sinnvoll angezeigt werden diff --git a/docs/gui/history.md b/docs/gui/history.md new file mode 100644 index 0000000..324a0c4 --- /dev/null +++ b/docs/gui/history.md @@ -0,0 +1,88 @@ +# Historie + +`Historie` ist die Kontroll- und Nachbearbeitungsoberfläche für alle bereits angelegten Jobs. + +## Hauptansicht + +Filter und Arbeitsmittel: + +- Suche +- Statusfilter +- Mediumfilter +- Sortierung +- Listen-/Grid-Layout + +Jeder Eintrag zeigt auf einen Blick: + +- Titel, Jahr, Poster oder Cover +- Status und Zeitstempel +- RAW- und Output-Verfügbarkeit +- Medien- und Containerhinweise + +## Detaildialog + +Der Detaildialog bündelt: + +- Metadaten +- Jobstatus und Fehlertexte +- RAW- und Output-Pfade +- Encode-Plan und Trackauswahl +- ausgeführte Kommandos +- Prozesslog +- verfügbare Folgeaktionen + +Bei Seriencontainern und Multipart-Jobs wird container- und child-orientiert dargestellt. + +## Nachbearbeitungsaktionen + +| Aktion | Typischer Einsatz | Technische Wirkung | +|---|---|---| +| `TMDb neu zuweisen` | falsches Match, anderer Film, andere Staffel | Metadaten werden neu geschrieben; Poster, Jahr und ggf. Ordnername werden best effort aktualisiert | +| `Review neu starten` | neue Track-/Titelbewertung nötig | Review wird aus vorhandenem RAW neu aufgebaut | +| `RAW neu encodieren` | neuer Encode mit vorhandenem RAW | startet Encode ohne neuen Disc-Rip | +| `Encode neu starten` | gleiche bestätigte Auswahl erneut fahren | letzter bestätigter Plan wird geladen | +| `Retry Rippen` | Rip oder Analyse erneut anstoßen | neuer Rip-Lauf inklusive RAW-Phase | + +Relevante Settings: + +- `Encode-Neustart: unvollständige Ausgabe löschen` +- `NFO nach Encode erzeugen` + +## Löschaktionen und Vorschau + +Vor dem endgültigen Löschen zeigt Ripster eine Vorschau mit: + +- RAW-Kandidaten +- Movie-/Episode-Kandidaten +- Related-Scope für Container, Kinder und verbundene Jobs + +Wichtige Punkte: + +- Pfade sind selektierbar +- bei mehreren RAW-Kandidaten muss mindestens ein RAW-Pfad gewählt werden +- Schutzlogik verhindert Löschungen außerhalb erlaubter Root-Verzeichnisse + +## Serien- und Multipart-Sicht + +### Serien + +- Container zeigt den aggregierten Zustand der Child-Jobs +- Disk- und Episodenaktionen können child-spezifisch ausgeführt werden +- `Im Ripper öffnen` hilft bei offenen Reviews + +### Multipart + +- gemeinsamer Container mit Disc-Childs +- Disc-Nummern bleiben pro Child nachvollziehbar +- Delete-Preview kann Related-Pfade gemeinsam auflösen + +## Logs sinnvoll nutzen + +- `Tail laden` für schnellen Fehlerkontext +- `Vollständiges Log laden` für tiefe Analyse + +Empfehlung: + +1. erst den Tail prüfen +2. dann die Stelle im Log eingrenzen +3. anschließend die passende Wiederanlauf-Aktion wählen diff --git a/docs/gui/index.md b/docs/gui/index.md new file mode 100644 index 0000000..47e69ca --- /dev/null +++ b/docs/gui/index.md @@ -0,0 +1,42 @@ +# GUI-Seiten + +Dieses Kapitel ist das Bedienhandbuch für die Oberflächen im Alltag. + +## Seitenüberblick + +| Seite | Rolle im Betrieb | Typischer Einsatz | +|---|---|---| +| [Ripper](ripper.md) | Live-Steuerung der Disc-Pipeline | Disc erkennen, Analyse starten, Entscheidungen treffen, Queue steuern | +| [Hardware](hardware.md) | Detailansicht für Live-Monitoring | CPU/RAM/GPU und Verlauf prüfen | +| [Audiobooks](audiobooks.md) | AAX-Spezialflow | Upload, Kapitelprüfung und Encode von Audiobooks | +| [Settings](settings.md) | Konfiguration und Automatisierung | Pfade, Tools, Queue, Presets, Skripte, Cron | +| [Historie](history.md) | Nachbearbeitung und Kontrolle | Logs, Re-Encode, TMDb-Neuzuordnung, Löschvorschau | +| [Converter](converter.md) | dateibasierte Konvertierung | Video/Audio/ISO ohne Disc-Rip verarbeiten | +| [Downloads](downloads.md) | ZIP-Exports | RAW/Output aus der Historie bereitstellen | +| [Database (Expert)](database.md) | Recovery-Sicht | orphan RAW finden, prüfen und importieren | +| [TMDb Migration](tmdb-migration.md) | Sonderseite für Altbestände | alte Jobs gesammelt auf TMDb umstellen | + +## Empfohlener Tagesablauf + +1. `Ripper`, `Converter` oder `Audiobooks`: neue Arbeit starten +2. `Historie`: Ergebnisse kontrollieren und ggf. nachbearbeiten +3. `Downloads`: Artefakte für externe Übergabe vorbereiten +4. `Settings`: Regeln, Presets oder Limits nur kontrolliert anpassen +5. `Hardware`: bei Lastspitzen oder Batch-Läufen den Verlauf prüfen + +## Navigation + +Direkt in der Top-Navigation: + +- `Ripper` +- `Converter` +- `Audiobooks` +- `Settings` +- `Historie` +- `Downloads` + +Zusatzansichten: + +- `Hardware` unter `/hardware` +- `Database` unter `/database` und im Expertenmodus zusätzlich in der Navigation +- `TMDb Migration` unter `/tmdb-migration` als Direktlink diff --git a/docs/gui/ripper.md b/docs/gui/ripper.md new file mode 100644 index 0000000..1f36001 --- /dev/null +++ b/docs/gui/ripper.md @@ -0,0 +1,227 @@ +# Ripper + +Die Seite `Ripper` ist die Live-Steuerung der Disc-Pipeline. Hier laufen Disc-Erkennung, Queue, manuelle Entscheidungen, Review und aktive Jobs zusammen. + +## Seitenaufbau + +Die wichtigsten Bereiche: + +1. `Hardware` +2. `Job Queue` +3. `Skript- / Cron-Status` +4. `Job Übersicht` +5. `Disk-Information` + +## 1. Hardware + +Die Hardware-Karte zeigt live: + +- CPU +- RAM +- GPU, falls vorhanden +- freie Speicherstände der konfigurierten Pfade + +Aktionen: + +- Klick auf das Hardware-Symbol öffnet die Seite [Hardware](hardware.md) + +Relevante Settings: + +- `Hardware Monitoring aktiviert` +- `Hardware Monitoring Intervall (ms)` +- `Externe Speicher` + +## 2. Job Queue + +Es gibt zwei operative Zonen: + +- laufende Jobs +- Warteschlange + +Mögliche Queue-Einträge: + +- normale Job-Starts +- `Retry Rippen` +- `RAW neu encodieren` +- `Encode neu starten` +- `Review neu starten` +- `Audio CD starten` +- `Skript` +- `Skriptkette` +- `Warten` + +Wichtige Regeln: + +- Reihenfolge ist per Drag-and-Drop änderbar +- `Warten` blockiert nachfolgende Starts, bis keine aktive Verarbeitung mehr läuft +- Queue-Einträge können auch ohne Medienjob existieren + +Relevante Settings: + +- `Parallele Jobs` +- `Max. parallele Audio CD Jobs` +- `Max. Encodes gesamt (medienunabhängig)` +- `Audio CDs: Queue-Reihenfolge überspringen` + +## 3. Skript- / Cron-Status + +Zeigt Runtime-Aktivitäten: + +- laufende Skripte +- laufende Ketten +- Cron-Ausführungen +- kürzlich abgeschlossene Aktivitäten + +Aktionen: + +- `Nächster Schritt` bei Ketten +- `Abbrechen` +- `Liste leeren` + +## 4. Job Übersicht + +Die Jobliste ist der zentrale Arbeitsbereich. Ein Klick öffnet den jeweiligen `Pipeline-Status`. + +### Zustandsabhängige Hauptaktionen + +| Zustand | Typische Aktion | Ergebnis | +|---|---|---| +| `Medium erkannt` | `Analyse starten` | MakeMKV-Analyse beginnt | +| `Metadatenauswahl` | `Metadaten öffnen` | TMDb-Dialog für Film/Serie | +| `Warte auf Auswahl` | Entscheidung bestätigen | Flow geht an die passende Stelle weiter | +| `Startbereit` | `Job starten` | Rip oder RAW-basierte Prüfung startet | +| `Bereit zum Encodieren` | `Encoding starten` | HandBrake startet mit bestätigter Auswahl | +| laufend | `Abbrechen` | Job wird gestoppt | +| `Fehler` / `Abgebrochen` | `Retry Rippen` | neuer Rip-Anlauf | + +### Was `Warte auf Auswahl` heute bedeuten kann + +Der Status ist nicht nur für eine einzige Entscheidung da. Im aktuellen Stand gibt es drei typische Fälle: + +#### Vorhandenes RAW + +Ripster hat zu den Metadaten bereits ein passendes RAW gefunden. Dann entscheidest du: + +- mit RAW weiterarbeiten +- RAW löschen und neu rippen +- in passenden Film-Fällen optional Multipart mit Orphan-RAW bilden + +#### Playlist-Auswahl + +Bei mehrdeutigen Blu-rays oder bestimmten DVD-Fällen musst du eine Playlist bestätigen. Die Liste zeigt unter anderem: + +- Playlist-Datei +- Titel-ID +- Laufzeit +- Score +- Empfehlung +- Segment- und Audio-Vorschau + +Relevante Settings: + +- `Minimale Titellänge (Minuten)` +- `TMDb Runtime-Abzug für Playlistfilter (%)` +- `Bei manueller Auswahl senden` für PushOver + +#### Titelauswahl / PlayAll-Grenzfall + +Wenn Ripster mehrere HandBrake-Titel als plausibel erkannt hat, musst du einen oder mehrere Titel bestätigen. Bei Serien kann das der Grenzfall `PlayAll oder Doppelfolge?` sein. + +## 5. Review-Bereich (`Bereit zum Encodieren`) + +Hier triffst du die eigentliche Encode-Entscheidung. + +Du legst fest: + +- welchen Titel Ripster encodieren soll +- welche Audio-Spuren übernommen werden +- welche Untertitel übernommen, erzwungen oder eingebrannt werden +- welches User-Preset oder HandBrake-Preset verwendet wird +- welche Pre-/Post-Encode-Skripte oder Ketten mitlaufen + +Wichtig: + +- ohne diese Bestätigung startet kein produktiver Encode +- bei Multipart-Locks können Einstellungen von einer Referenz-Disc übernommen und gesperrt sein + +### Welche Settings hier direkt sichtbar werden + +- `HandBrake Preset` +- `HandBrake Extra Args` +- `Review Audio-Sprachen (...)` +- `Review UT-Sprachen (...)` +- `Ausgabeformat` +- Standard-Zuordnungen aus `Settings -> Encode-Presets` + +## RAW-Ordnerphasen + +Der Ripper arbeitet mit drei RAW-Zuständen: + +- `Incomplete_...` während eines unvollständigen Rips +- `Rip_Complete_...` nach erfolgreichem Rip, vor dem finalen Encode +- ohne Prefix nach erfolgreichem Encode + +Das ist wichtig für: + +- Recovery +- RAW-Wiederverwendung +- Delete-Preview in `Historie` + +## Disk-Information + +Zeigt aktuelle Laufwerksdaten: + +- Device-Pfad +- Modell +- Disc-Label +- Mount-Informationen + +Aktionen: + +- `Laufwerk neu lesen` +- `Disk neu analysieren` +- Metadaten-Dialog öffnen + +Hinweis: + +- `Laufwerk neu lesen` ist nicht mehr an einen festen Pipeline-State gebunden + +## Wichtige Dialoge + +### Metadaten auswählen + +Im Dialog bestätigst du Film- oder Serienmetadaten aus TMDb. + +Sonderfälle: + +- Film-Duplikat: `Übernahme erzwingen` oder `Multipart movie` +- Serie: `Disc-Nummer` ist Pflicht + +### Vorhandenes RAW erkannt + +Wenn zu den Metadaten bereits RAW existiert, fordert Ripster eine explizite Entscheidung: + +- vorhandenes RAW verwenden +- RAW verwerfen/löschen und neu rippen + +### Queue-Eintrag einfügen + +Du kannst gezielt ab Position einfügen: + +- Skript +- Skriptkette +- Wartezeit + +### Audible Activation Bytes eintragen + +Wenn für eine AAX-Datei keine Activation Bytes verfügbar sind, öffnet Ripster einen Dialog zur manuellen Eingabe. + +- Format: genau 8 Hex-Zeichen, z. B. `1a2b3c4d` +- Speicherung: lokal im Activation-Bytes-Cache +- Wirkung: der Audiobook-Flow kann anschließend fortgesetzt werden + +## Siehe auch + +- [Hardware](hardware.md) +- [Settings](settings.md) +- [Workflows aus Nutzersicht](../workflows/index.md) diff --git a/docs/gui/settings.md b/docs/gui/settings.md new file mode 100644 index 0000000..5ce1732 --- /dev/null +++ b/docs/gui/settings.md @@ -0,0 +1,283 @@ +# Settings + +Die Seite `Settings` ist das Kontrollzentrum für Laufzeitverhalten, Standardwerte und Automatisierung. Alles, was spätere Jobs automatisch tun oder vorschlagen, wird hier vorbereitet. + +Für die detaillierte Feld-für-Feld-Erklärung jeder einzelnen Einstellung nutze die Seite [Einstellungsreferenz](../configuration/settings-reference.md). Dort ist jedes sichtbare Feld aus der GUI mit Wirkung, Workflow-Bezug und typischem Einsatz beschrieben. + +## Tab-Überblick + +| Tab | Zweck | +|---|---| +| `Konfiguration` | alle fachlichen Einstellungen aus der GUI | +| `Scripte` | einzelne Bash-Skripte anlegen, testen und sortieren | +| `Skriptketten` | mehrstufige Abläufe aus Skript- und Warte-Schritten bauen | +| `Encode-Presets` | eigene Video-Presets und Standard-Zuordnungen pflegen | +| `Cronjobs` | zeitgesteuerte Ausführung von Skripten oder Ketten | + +## Tab `Konfiguration` + +### Bedienlogik + +1. Felder ändern +2. `Änderungen speichern` klicken +3. erst dann gelten die neuen Werte für künftige Starts, Reviews oder Scheduler + +Ausnahmen: + +- `Expertenmodus` wird sofort gespeichert +- Buttons wie `PushOver Test`, `Coverarts prüfen` oder der MakeMKV-Betakey-Import lösen direkte Aktionen aus + +### Toolbar-Aktionen + +- `Änderungen speichern`: schreibt nur geänderte Felder +- `Änderungen verwerfen`: setzt den Formularzustand zurück +- `Neu laden`: lädt Schema und Werte neu +- `PushOver Test`: sendet eine Testnachricht mit den aktuellen PushOver-Werten +- `Coverarts prüfen`: startet den Coverart-Recovery-Lauf sofort + +### Was die Kategorien im Alltag steuern + +#### Benachrichtigungen + +Hier steuerst du zwei Dinge: + +- ob und wohin Ripster PushOver-Nachrichten sendet +- bei welchen Ereignissen Ripster überhaupt benachrichtigt + +Wichtig: + +- `PushOver aktiviert` ist der Master-Schalter +- `Bei manueller Auswahl senden` ist besonders relevant für Playlist-, RAW- oder Titelentscheidungen +- der `Expertenmodus` blendet zusätzliche technische Felder in der gesamten Settings-Seite ein + +#### Laufwerk + +Diese Kategorie beeinflusst, wie Ripster optische Laufwerke findet und überwacht. + +Typische Fragen: + +- Soll Ripster Laufwerke automatisch erkennen? +- Soll ein bestimmtes Laufwerk fest an einen MakeMKV-Index gebunden werden? +- Soll das Laufwerk nach erfolgreichem Rip automatisch auswerfen? + +Das ist vor allem wichtig bei: + +- mehreren Laufwerken +- USB-SATA-Adaptern +- Hosts mit instabilen Device-Namen + +#### Logging + +Diese Felder ändern nicht die Job-Logs auf Platte, sondern die Ausgabe im Server-Terminal. + +Damit steuerst du zum Beispiel: + +- ob `start.sh` oder ein Terminal-Run sehr gesprächig sein darf +- ob HTTP-Requests im Terminal sichtbar sein sollen +- ob DEBUG/INFO/WARN/ERROR dort mitlaufen + +#### Metadaten + +Hier legst du fest, wie Ripster mit TMDb, Covern und NFO-Dateien umgeht. + +Besonders wichtig: + +- ohne `TMDb Read Access Token` funktionieren Film-/Seriensuche und Neu-Zuordnung nur eingeschränkt oder gar nicht +- `Film-Sprache` und `Fallback Sprache` bestimmen, welche Titel, Beschreibungen und Staffelinformationen bevorzugt werden +- `Coverart-Nachladen aktiv` hilft bei nachträglich fehlenden Postern/Covern + +#### Monitoring + +Steuert das Hardware-Monitoring für: + +- CPU +- RAM +- GPU +- Speicherstände + +Diese Werte erscheinen: + +- kompakt im `Ripper` +- ausführlich in der Seite `Hardware` + +#### Pfade + +Hier legst du fest, wohin Ripster schreibt und wie Dateien benannt werden. + +Die Felder steuern: + +- RAW-Verzeichnisse pro Medium +- Zielordner für Filme, Serien, CDs, Audiobooks, Converter und Downloads +- Eigentümer per `user:gruppe` +- Templates für Datei- und Ordnernamen + +Praxisbeispiele: + +- Serien getrennt von Filmen ablegen +- RAW auf große HDD, Final-Output auf NAS +- Audiobooks kapitelweise in Unterordnern strukturieren + +#### Converter + +Diese Kategorie wirkt nur auf die Seite `Converter`. + +Sie steuert: + +- welche Dateiendungen der Upload und der Verzeichnisscan akzeptieren +- ob der Converter-RAW-Baum automatisch neu gescannt wird +- wie häufig dieses Polling läuft + +#### Tools + +Diese Kategorie entscheidet über das technische Verhalten der eigentlichen Pipeline. + +Dazu gehören: + +- Pfade/Befehle für externe Tools +- MakeMKV-Filter und Rip-Modi +- HandBrake-Presets und Extra-Args +- Vorauswahl von Audio-/Untertitelsprachen in der Review +- Ausgabeformate +- Queue- und Parallelitätsregeln +- Timeout für Script-Tests + +Für die Nutzer-Workflows besonders relevant: + +- `Minimale Titellänge (Minuten)` +- `TMDb Runtime-Abzug für Playlistfilter (%)` +- `HandBrake Preset` und `HandBrake Extra Args` +- `Review Audio-Sprachen (...)` +- `Review UT-Sprachen (...)` +- `Parallele Jobs` +- `Max. parallele Audio CD Jobs` +- `Max. Encodes gesamt (medienunabhängig)` + +### Kategorie `Pfade`: Besondere Interaktion + +Die Pfad-Kategorie hat eine eigene Darstellung: + +- Tabelle `Effektive Pfade`: zeigt die aktuell wirklich verwendeten Laufzeitpfade inklusive Fallbacks +- Accordion pro Bereich wie `Blu-ray`, `DVD`, `CD / Audio`, `Audiobook`, `Converter`, `Downloads`, `Logs` +- Owner-Felder (`*_owner`) erscheinen direkt beim zugehörigen Pfad +- Owner-Felder sind deaktiviert, solange der zugehörige Pfad leer ist + +### Spezial-Editoren + +- `Explizite Laufwerke`: Editor für mehrere Laufwerke mit Pfad und MakeMKV-Index +- `Externe Speicher`: Liste aus Anzeigename und Pfad für die Speicheranzeige +- `Erlaubte Datei-Endungen`: Auswahl der erlaubten Upload-/Scan-Endungen +- `MakeMKV Key`: inkl. Betakey-Hinweis und Übernahme-Button + +### Wichtig für Reviews und Workflows + +Die Seite `Settings` bestimmt direkt, was Nutzer später im Workflow sehen: + +- Playlist-Kandidaten werden durch `Minimale Titellänge` und `TMDb Runtime-Abzug ...` beeinflusst +- Audio-/Untertitel-Vorauswahlen in `Bereit zum Encodieren` hängen an den Review-Sprachfeldern +- automatisch vorgeschlagene Presets hängen an den Standard-Zuordnungen unter `Encode-Presets` +- Queue-Verhalten im `Ripper` hängt an den drei Parallelitätsfeldern plus `Audio CDs: Queue-Reihenfolge überspringen` + +## Tab `Scripte` + +Funktionen: + +- Skript anlegen +- Skript bearbeiten +- Skript löschen +- Skript testen +- Reihenfolge per Drag-and-Drop ändern +- Favorit markieren + +Das Testergebnis zeigt: + +- Erfolg oder Fehler +- Exit-Code +- Dauer +- stdout/stderr +- Timeout, falls `Script-Test Timeout (ms)` greift + +## Tab `Skriptketten` + +Skriptketten bestehen aus Schritten: + +- `Skript` +- `Warten (Sekunden)` + +Funktionen: + +- Ketten anlegen, bearbeiten, löschen +- Reihenfolge der Ketten ändern +- einzelne Ketten testen +- Schritte innerhalb der Kette per Drag-and-Drop sortieren + +Wichtig: + +- dieselben Skripte können sowohl in Jobs als auch in Ketten und Cronjobs wiederverwendet werden + +## Tab `Encode-Presets` + +Hier werden Video-Standards für Disc- und Converter-Workflows verwaltet. + +### Standard-Zuordnungen + +Es gibt vier feste Slots: + +- `Blu-ray Film` +- `Blu-ray Serie` +- `DVD Film` +- `DVD Serie` + +Wenn für einen Workflow hier ein Default gesetzt ist, erscheint dieses User-Preset später in der Review bereits vorausgewählt. + +### Preset-Inhalt + +Ein User-Preset besteht aus: + +- Name +- Medientyp (`all`, `bluray`, `dvd`) +- HandBrake-Preset +- Extra Args +- optionaler Beschreibung + +## Tab `Cronjobs` + +Funktionen: + +- Cronjob anlegen, bearbeiten, löschen +- Cron-Ausdruck live validieren +- Quelle wählen: `Skript` oder `Skriptkette` +- Aktiv- und PushOver-Status pro Job setzen +- `Jetzt ausführen` +- Lauf-Logs öffnen + +Die Runtime-Aktivitäten der laufenden Cronjobs werden zusätzlich live angezeigt. + +## Expertenbereich: Activation Bytes Cache + +Dieser Block wird nur im Expertenmodus angezeigt. + +Er zeigt lokal bekannte AAX-Activation-Bytes mit: + +- Prüfsumme +- Activation Bytes +- Speicherzeitpunkt + +Das hilft bei Audiobook-Workflows, wenn dieselbe Datei oder derselbe Hash später erneut auftaucht. + +## Validierung von Template-Feldern + +Template-Felder akzeptieren nur: + +- Buchstaben und Ziffern +- Leerzeichen +- `_` und `-` +- Platzhalter in `{...}` oder `${...}` +- `/` für Unterordner + +Damit verhindert Ripster fehlerhafte Dateinamen schon beim Speichern. + +## Siehe auch + +- [Alle Einstellungen](../configuration/settings-reference.md) +- [Ripper](ripper.md) +- [Workflows aus Nutzersicht](../workflows/index.md) diff --git a/docs/gui/tmdb-migration.md b/docs/gui/tmdb-migration.md new file mode 100644 index 0000000..0448aa7 --- /dev/null +++ b/docs/gui/tmdb-migration.md @@ -0,0 +1,52 @@ +# TMDb Migration + +Die Seite `TMDb Migration` ist eine Sonderansicht für ältere Bestandsjobs, die noch nicht als auf TMDb migriert markiert sind. + +## Zugriff + +- Direktlink: `/tmdb-migration` +- nicht in der normalen Top-Navigation verlinkt + +## Wofür die Seite gedacht ist + +Typische Einsatzfälle: + +- Altbestand aus früheren Ständen bereinigen +- mehrere Jobs nacheinander auf aktuelle TMDb-Metadaten umstellen +- Historie konsistent auf den heutigen Metadaten-Workflow bringen + +## Funktionsweise + +Die Seite lädt offene Migrationskandidaten und zeigt: + +- Job-ID +- Titel +- Jahr +- Medium +- aktuellen Status + +Aktionen: + +- `Liste neu laden` +- `Start` +- `Stop` + +## Ablauf einer Sequenz + +1. Jobliste laden +2. `Start` +3. der bekannte TMDb-Metadaten-Dialog öffnet sich für den ersten Job +4. nach erfolgreicher Übernahme plant Ripster den nächsten Job mit Cooldown + +Wichtig: + +- zwischen zwei Jobs liegt bewusst eine Wartezeit von mindestens 10 Sekunden +- damit wird die TMDb-API bei Serienmigrationen nicht unnötig belastet + +## Relevante Settings + +- `TMDb Read Access Token` +- `Film-Sprache` +- `Fallback Sprache` + +Ohne diese Einstellungen ist die Migration nicht sinnvoll nutzbar. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..eb8df67 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,24 @@ +# Ripster Handbuch + +Diese Dokumentation ist als vollständiges Benutzerhandbuch aufgebaut: tägliche Bedienung zuerst, technische Referenz danach. + +## Einstieg in 3 Schritten + +1. Installation: [Installation](getting-started/installation.md) +2. Betriebskonfiguration: [Ersteinrichtung](getting-started/configuration.md) +3. erster End-to-End-Lauf: [Erster Lauf](getting-started/quickstart.md) + +## Was das Handbuch abdeckt + +- alle GUI-Seiten mit Aktionen und Wirkung +- vollständige Workflows für Film, Serie und Multipart +- Queue-, Job-, Container- und Recovery-Verhalten +- detaillierte Settings-Wirkung inklusive Fallbacks + +## Empfohlene Lesereihenfolge + +1. [Benutzerhandbuch Überblick](getting-started/index.md) +2. [GUI-Seiten](gui/index.md) +3. [Workflows aus Nutzersicht](workflows/index.md) +4. [Einstellungsreferenz](configuration/settings-reference.md) +5. bei Bedarf technischer Tiefgang im [Anhang](appendix/index.md) diff --git a/docs/pipeline/encoding.md b/docs/pipeline/encoding.md new file mode 100644 index 0000000..c8db251 --- /dev/null +++ b/docs/pipeline/encoding.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 \ + -o \ + -t \ + -Z "" \ + \ + -a \ + -s +``` + +Untertitel-Flags werden bei Bedarf ergänzt: + +- `--subtitle-burned=` +- `--subtitle-default=` +- `--subtitle-forced=` 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. diff --git a/docs/pipeline/index.md b/docs/pipeline/index.md new file mode 100644 index 0000000..413f924 --- /dev/null +++ b/docs/pipeline/index.md @@ -0,0 +1,43 @@ +# Anhang: Pipeline intern + +Dieser Abschnitt beschreibt die technische Pipeline-Logik hinter den UI-Workflows. + +
+ +- :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) + +
+ +## Zurück zum Handbuch + +- [Workflows aus Nutzersicht](../workflows/index.md) diff --git a/docs/pipeline/playlist-analysis.md b/docs/pipeline/playlist-analysis.md new file mode 100644 index 0000000..bc37ce8 --- /dev/null +++ b/docs/pipeline/playlist-analysis.md @@ -0,0 +1,68 @@ +# 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 Titellänge (Minuten)` | Mindestlaufzeit für Kandidaten aus MakeMKV/Playlist-Analyse | +| `TMDb Runtime-Abzug für Playlistfilter (%)` | lockert bei Bedarf die untere Laufzeitgrenze relativ zur TMDb-Laufzeit, damit leicht kürzere Hauptfassungen Kandidaten bleiben | + +Default ist aktuell `60` Minuten. + +--- + +## UI-Verhalten + +Bei manueller Entscheidung zeigt der `Ripper` Kandidaten mit: + +- Playlist-Datei +- Titel-ID +- Laufzeit +- Score +- Empfehlung +- Segment- und Audio-Vorschau + +Nach Bestätigung: + +- mit vorhandenem RAW -> zurück zu `Mediainfo-Pruefung` +- ohne RAW -> Startpfad über `Startbereit` / `Rippen` diff --git a/docs/pipeline/post-encode-scripts.md b/docs/pipeline/post-encode-scripts.md new file mode 100644 index 0000000..98a5668 --- /dev/null +++ b/docs/pipeline/post-encode-scripts.md @@ -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. diff --git a/docs/pipeline/workflow.md b/docs/pipeline/workflow.md new file mode 100644 index 0000000..0b4fb1b --- /dev/null +++ b/docs/pipeline/workflow.md @@ -0,0 +1,101 @@ +# 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 | +| `Warte auf Auswahl` | auch für RAW-Entscheidung bei vorhandenen Quelldaten genutzt | +| `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 | +| `CD-Metadatenauswahl` | CD-Metadaten müssen bestätigt werden | +| `CD-Startbereit` | CD-Job wartet auf Start | +| `CD-Analyse` | CD-Track-/Strukturaufbereitung | +| `CD-Rippen` | Audio-CD-Rip läuft | +| `CD-Encodieren` | CD-Encode 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. + +### Vorhandenes RAW mit Nutzerentscheidung + +`Metadatenauswahl` -> `Warte auf Auswahl` bis Entscheidung für Weiterverarbeitung (`continue`) oder Neustart getroffen wurde. + +### CD-Flow + +`CD-Metadatenauswahl` -> `CD-Startbereit` -> `CD-Rippen` -> `CD-Encodieren` -> `Fertig` + +--- + +## 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 diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..9c24353 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -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); +} diff --git a/docs/tools/handbrake.md b/docs/tools/handbrake.md new file mode 100644 index 0000000..3ec2184 --- /dev/null +++ b/docs/tools/handbrake.md @@ -0,0 +1,69 @@ +# HandBrake + +Ripster verwendet `HandBrakeCLI` für Scan und Encode. + +--- + +## Verwendete Aufrufe + +### Scan (Review-Aufbau) + +```bash +HandBrakeCLI --scan --json -i -t 0 +``` + +### Encode (vereinfacht) + +```bash +HandBrakeCLI \ + -i \ + -o \ + -t \ + -Z "" \ + \ + -a \ + -s +``` + +Optional ergänzt Ripster: + +- `--subtitle-burned=` +- `--subtitle-default=` +- `--subtitle-forced=` 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. diff --git a/docs/tools/index.md b/docs/tools/index.md new file mode 100644 index 0000000..6af7003 --- /dev/null +++ b/docs/tools/index.md @@ -0,0 +1,31 @@ +# Anhang: Externe Tools + +Ripster orchestriert externe CLI-Tools. Dieser Abschnitt erklärt deren Rolle im Gesamtsystem. + +
+ +- :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) + +
diff --git a/docs/tools/makemkv.md b/docs/tools/makemkv.md new file mode 100644 index 0000000..1d74ee9 --- /dev/null +++ b/docs/tools/makemkv.md @@ -0,0 +1,69 @@ +# MakeMKV + +Ripster nutzt `makemkvcon` für Disc-Analyse und Rip. + +--- + +## Verwendete Aufrufe + +### Analyse + +```bash +makemkvcon -r info +``` + +`` ist typischerweise: + +- `disc:` (Auto-Modus) +- `dev:/dev/sr0` (explicit) +- `file:` (Datei/Ordner-Analyse) + +### Rip (MKV-Modus) + +```bash +makemkvcon mkv [--minlength=...] [...extraArgs] +``` + +### Rip (Backup-Modus) + +```bash +makemkvcon backup --decrypt +``` + +--- + +## Registrierungsschlüssel (optional) + +Wenn in `Settings` ein `MakeMKV Key` gesetzt ist, synchronisiert Ripster ihn nach: + +```bash +~/.MakeMKV/settings.conf +``` + +Format: + +```ini +app_Key = "product-key" +``` + +Zusätzlich zeigt die UI unterhalb des Feldes den aktuellen Betakey an, der per Button in das Eingabefeld übernommen werden kann. + +--- + +## 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. diff --git a/docs/tools/mediainfo.md b/docs/tools/mediainfo.md new file mode 100644 index 0000000..cfddb1b --- /dev/null +++ b/docs/tools/mediainfo.md @@ -0,0 +1,37 @@ +# MediaInfo + +Ripster nutzt `mediainfo` zur JSON-Analyse von Medien-Dateien. + +--- + +## Aufruf + +```bash +mediainfo --Output=JSON +``` + +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 ` +- unbekannte Sprache erscheint oft als `und` (undetermined) diff --git a/docs/workflows/index.md b/docs/workflows/index.md new file mode 100644 index 0000000..aa6babf --- /dev/null +++ b/docs/workflows/index.md @@ -0,0 +1,366 @@ +# Workflows aus Nutzersicht + +Diese Seite beschreibt die produktiven Abläufe aus Sicht der Benutzer: welche Entscheidung wann anfällt, welche Jobs oder Container entstehen und welche Settings den Ablauf sichtbar beeinflussen. + +## Gemeinsame Grundlagen + +### Typische Statuskette + +`Medium erkannt` -> `Analyse` -> `Metadatenauswahl` -> `Startbereit` -> `Rippen` -> `Mediainfo-Prüfung` -> `Bereit zum Encodieren` -> `Encodieren` -> `Fertig` + +Wichtige Abweichungen: + +- bei vorhandenem RAW: Sprung direkt in RAW-Entscheidung oder Review +- bei mehrdeutigen Blu-rays/DVDs: `Warte auf Auswahl` für Playlist- oder Titelauswahl +- bei Fehler oder Abbruch: `Fehler` oder `Abgebrochen` mit Folgeaktionen +- bei Audio-CDs und Audiobooks: eigener Spezialflow innerhalb derselben Gesamtpipeline + +### Welche Settings fast überall hineinspielen + +Die wichtigsten globalen Einflussfaktoren: + +- `Parallele Jobs` +- `Max. parallele Audio CD Jobs` +- `Max. Encodes gesamt (medienunabhängig)` +- `Audio CDs: Queue-Reihenfolge überspringen` +- `PushOver`-Ereignisse +- Pfade, Templates und Owner-Felder + +Für Disc-Video zusätzlich: + +- `Minimale Titellänge (Minuten)` +- `TMDb Runtime-Abzug für Playlistfilter (%)` +- `HandBrake Preset` +- `HandBrake Extra Args` +- `Review Audio-Sprachen (...)` +- `Review UT-Sprachen (...)` +- Standard-Zuordnungen aus `Settings -> Encode-Presets` + +## Workflow A: Film von Disc (Blu-ray / DVD) + +### 1. Disc analysieren und TMDb-Metadaten wählen + +1. Im `Ripper` `Analyse starten` +2. im Metadaten-Dialog Film auswählen +3. `Auswahl übernehmen` + +Wirkung: + +- der Job wird als Film-Workflow geführt +- Film-Metadaten, Poster und Fingerprint werden gesetzt + +Relevante Settings: + +- `TMDb Read Access Token` +- `Film-Sprache` +- `Fallback Sprache` + +### 2. Duplikatfall entscheiden + +Wenn ein Film mit gleichem Fingerprint bereits existiert: + +1. `Übernahme erzwingen`: neuer unabhängiger Film-Job +2. `Multipart movie`: neuer Job wird mit bestehendem Film zu einem Multipart-Container verknüpft + +Bei Multipart gilt: + +- beide Discs brauchen unterschiedliche Disc-Nummern +- gleiche Disc-Nummern blockieren den Vorgang + +### 3. RAW-Verzeichnis und RAW-Entscheidung + +Wenn noch kein passendes RAW vorhanden ist: + +- Ripster legt ein neues RAW-Verzeichnis an + +Wenn bereits ein passendes RAW gefunden wird: + +- der Job geht in `Warte auf Auswahl` +- du entscheidest, ob das vorhandene RAW weiterverwendet oder neu gerippt wird + +Relevante Settings: + +- `Raw Ausgabeordner (Blu-ray)` und `Raw Ausgabeordner (DVD)` +- `Eigentümer Raw-Ordner (Blu-ray)` und `Eigentümer Raw-Ordner (DVD)` + +### 4. Playlist- oder Titelauswahl + +Bei bestimmten Discs ist nach der Metadatenphase eine manuelle Auswahl nötig: + +- Playlist-Auswahl +- HandBrake-Titelauswahl +- bei Serien-Grenzfällen PlayAll- oder Doppelfolgen-Entscheidung + +Das ist der aktuelle `playlistflow`. + +Relevante Settings: + +- `Minimale Titellänge (Minuten)` +- `TMDb Runtime-Abzug für Playlistfilter (%)` +- `Bei manueller Auswahl senden` + +Praxis: + +- zu hoher Mindestwert blendet legitime Alternativfassungen aus +- ein kleiner Runtime-Abzug hilft bei Discs, deren Hauptfilm minimal unter der TMDb-Laufzeit liegt + +### 5. Review und Encode + +Im Zustand `Bereit zum Encodieren` entscheidest du: + +- welcher Titel encodiert wird +- welche Audio-Spuren übernommen werden +- welche Untertitel übernommen, erzwungen oder eingebrannt werden +- welches Preset gilt +- welche Pre-/Post-Encode-Skripte oder Ketten mitlaufen + +Relevante Settings: + +- `HandBrake Preset` +- `HandBrake Extra Args` +- `Review Audio-Sprachen (Blu-ray Film)` oder `Review Audio-Sprachen (DVD Film)` +- `Review UT-Sprachen (Blu-ray Film)` oder `Review UT-Sprachen (DVD Film)` +- `Ausgabeformat` +- Standard-Userpreset für Film unter `Settings -> Encode-Presets` + +### 6. Finale Ausgabe + +Finale Ausgabe: + +- Zielordner: `Film Ausgabeordner (Blu-ray)` oder `Film Ausgabeordner (DVD)` +- Dateiname und Ordner: `Output Template (Blu-ray)` oder `Output Template (DVD)` + +Optional: + +- `.nfo` direkt nach Encode + +Relevante Settings: + +- `Film Ausgabeordner (Blu-ray)` und `Film Ausgabeordner (DVD)` +- `Eigentümer Film-Ordner (Blu-ray)` und `Eigentümer Film-Ordner (DVD)` +- `Output Template (Blu-ray)` und `Output Template (DVD)` +- `NFO nach Encode erzeugen` + +## Workflow B: Serie von Disc (Blu-ray / DVD) + +### 1. Serienmodus festlegen + +1. Disc analysieren +2. im Metadaten-Dialog Serienmetadaten wählen +3. `Disc-Nummer` setzen + +Wirkung: + +- Ripster arbeitet als Serien-Workflow +- Seriencontainer wird gesucht oder angelegt +- der Job wird als Child unter diesem Container geführt + +### 2. Serien-RAW und Staffelkontext + +Für Serien bevorzugt Ripster eigene Serien-RAW-Verzeichnisse: + +- `RAW-Ordner (Blu-ray Serie)` +- `RAW-Ordner (DVD Serie)` + +Falls leer: + +- Fallback auf `Raw Ausgabeordner (Blu-ray)` bzw. `Raw Ausgabeordner (DVD)` + +### 3. Episodenprüfung und Multi-Episode-Fälle + +In der Review werden Episoden oder Mehrfachfolgen aufgelöst. + +Ausgabevarianten: + +- Einzel-Episode +- Multi-Episode-Datei + +Relevante Settings: + +- `Output Template (Blu-ray Serie, Episode)` +- `Output Template (Blu-ray Serie, Multi-Episode)` +- `Output Template (DVD Serie, Episode)` +- `Output Template (DVD Serie, Multi-Episode)` +- `Serien-Ordner (Blu-ray)` +- `Serien-Ordner (DVD)` + +### 4. Playlistflow auch bei Serien + +Auch Serien können vor der Review in eine Playlist- oder Titelauswahl laufen. + +Zusätzlich wirken: + +- `Review Audio-Sprachen (Blu-ray Serie)` / `Review Audio-Sprachen (DVD Serie)` +- `Review UT-Sprachen (Blu-ray Serie)` / `Review UT-Sprachen (DVD Serie)` +- Serien-Default-Presets unter `Settings -> Encode-Presets` + +## Workflow C: Multipart-Film + +### Wann der Flow gedacht ist + +- gleicher Film auf mehreren Discs +- die Discs sollen in Historie und Detailansichten zusammengehören + +### Ablauf + +1. Film-Metadaten wählen +2. Duplikatdialog `Multipart movie` +3. Disc-Nummern für bestehenden und neuen Job festlegen + +Wirkung: + +- `multipart_movie_container` wird angelegt oder wiederverwendet +- die Discs werden als Child-Jobs darunter geführt +- zusätzlich wird ein Merge-Job vorbereitet + +Relevante Settings: + +- `mkvmerge_command` +- Film-Output-Templates +- Queue-Limits, falls mehrere Discs parallel weiterverarbeitet werden + +## Workflow D: Audio-CD + +### 1. TOC lesen und Metadaten wählen + +1. Audio-CD analysieren +2. Tracks prüfen +3. Metadaten aus MusicBrainz übernehmen oder manuell anpassen + +### 2. Trackauswahl und Format + +Vor dem Start legst du fest: + +- welche Tracks übernommen werden +- welches Ausgabeformat gilt +- welche Formatoptionen genutzt werden +- welche Pre-/Post-Encode-Skripte oder Ketten mitlaufen + +### 3. Rip und Encode + +Die CD läuft anschließend als Track-für-Track-Flow: + +`CD-Metadatenauswahl` -> `CD-Startbereit` -> `CD-Rippen` -> `CD-Encodieren` -> `Fertig` + +Relevante Settings: + +- `CD RAW-Ordner` +- `CD Output-Ordner` +- `Eigentümer CD RAW-Ordner` +- `Eigentümer CD Output-Ordner` +- `CD Output Template` +- `cdparanoia Kommando` +- `Max. parallele Audio CD Jobs` +- `Audio CDs: Queue-Reihenfolge überspringen` + +## Workflow E: Converter + +### 1. Dateien bereitstellen + +Dateien gelangen in den Converter über: + +- Upload +- bestehende Ordner im `Converter Raw-Ordner` +- manuelle Dateiverwaltung im Explorer + +### 2. Jobs aus Auswahl erzeugen + +- Video und ISO: immer ein Job pro Datei +- Audio: ein Job pro Datei oder gemeinsamer Audio-Job + +### 3. Job konfigurieren und starten + +Je nach Medium: + +- Video/ISO: Review mit Track- und Preset-Auswahl +- Audio: direkte Format- und Zielkonfiguration + +Relevante Settings: + +- `Erlaubte Datei-Endungen` +- `Auto-Scan (Polling)` +- `Polling-Intervall (Sekunden)` +- `Converter Raw-Ordner` +- `Converter Ausgabe (Video)` +- `Converter Ausgabe (Audio)` +- `Output-Template (Video)` +- `Output-Template (Audio)` +- globale Queue-Limits + +## Workflow F: Audiobooks + +### 1. AAX hochladen + +1. Datei hochladen +2. Metadaten und Kapitel aufbauen lassen +3. bei Bedarf Activation Bytes klären + +### 2. Ausgabeformat wählen + +- `m4b`: eine Datei +- `mp3`: eine Datei pro Kapitel +- `flac`: eine Datei pro Kapitel + +### 3. Kapitel und Skripte prüfen + +Vor dem Start anpassbar: + +- Kapitelnamen +- Pre-Encode-Skripte und -Ketten +- Post-Encode-Skripte und -Ketten + +Relevante Settings: + +- `Audiobook RAW-Ordner` +- `Audiobook Output-Ordner` +- `Audiobook RAW Template` +- `Output Template (Audiobook)` +- `Kapitel Template (Audiobook)` +- `FFprobe Kommando` +- `FFmpeg Kommando` +- globale Queue-Limits + +## Workflow G: Nachbearbeitung in der Historie + +Typische Folgeaktionen: + +- `TMDb neu zuweisen` +- `Review neu starten` +- `RAW neu encodieren` +- `Encode neu starten` +- `Retry Rippen` +- `Download` als ZIP einreihen + +Besonders wichtig: + +- `Encode neu starten` verwendet den letzten bestätigten Plan +- `Encode-Neustart: unvollständige Ausgabe löschen` entscheidet, ob unvollständige Ausgaben vorher gelöscht werden + +## Workflow H: Downloads und Recovery + +### Downloads + +Aus `Historie` können ZIPs für: + +- `RAW` +- `Output` + +erstellt werden. + +Relevante Settings: + +- `Download ZIP-Ordner` +- `Eigentümer Download ZIP-Ordner` + +### Database (Expert) + +Die Recovery-Seite hilft bei orphan RAW: + +- `RAW prüfen` +- `Job anlegen` + +So lassen sich vorhandene RAW-Verzeichnisse wieder in einen normalen Workflow zurückführen. + +### TMDb Migration + +Für ältere Bestandsjobs gibt es die Sonderseite `/tmdb-migration`, um mehrere Jobs nacheinander auf den heutigen TMDb-Stand zu bringen. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..5210ca0 --- /dev/null +++ b/frontend/.env.example @@ -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):///ws) +# VITE_WS_URL=ws://10.10.10.24:3001/ws diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9482c0a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Ripster + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..184985c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1730 @@ +{ + "name": "ripster-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ripster-frontend", + "version": "1.0.0", + "dependencies": { + "chart.js": "^4.5.1", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/primeicons": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==" + }, + "node_modules/primereact": { + "version": "10.9.7", + "resolved": "https://registry.npmjs.org/primereact/-/primereact-10.9.7.tgz", + "integrity": "sha512-Ap/lg9GGaS8Pq7IIlzguuG3qlaU6PYF6E0cCRo0rnWauRw/SQGvfreSVIIxqEhtR6xqlf7OV759lyvVOvBzmsQ==", + "dependencies": { + "@types/react-transition-group": "^4.4.1", + "react-transition-group": "^4.4.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..377a94e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "ripster-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "chart.js": "^4.5.1", + "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" + } +} diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..f520fdf Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..cbbf934 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,782 @@ +import { useEffect, useRef, useState } from 'react'; +import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; +import { Button } from 'primereact/button'; +import { Toast } from 'primereact/toast'; +import { ConfirmDialog } from 'primereact/confirmdialog'; +import { api } from './api/client'; +import { useWebSocket } from './hooks/useWebSocket'; +import RipperPage from './pages/RipperPage'; +import SettingsPage from './pages/SettingsPage'; +import HistoryPage from './pages/HistoryPage'; +import DatabasePage from './pages/DatabasePage'; +import DownloadsPage from './pages/DownloadsPage'; +import ConverterPage from './pages/ConverterPage'; +import AudiobooksPage from './pages/AudiobooksPage'; +import TmdbMigrationPage from './pages/TmdbMigrationPage'; +import HardwarePage from './pages/HardwarePage'; + +function normalizeJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function extractUploadJobIdFromResponse(response) { + const payload = response && typeof response === 'object' ? response : {}; + const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}; + return ( + normalizeJobId(result?.jobId) + || normalizeJobId(payload?.jobId) + || normalizeJobId(result?.id) + || normalizeJobId(payload?.id) + || normalizeJobId(result?.job?.id) + || normalizeJobId(payload?.job?.id) + || null + ); +} + +function clampPercent(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return 0; + } + return Math.max(0, Math.min(100, parsed)); +} + +function normalizeStage(value) { + return String(value || '').trim().toUpperCase(); +} + +function isRunningStage(value) { + const normalized = normalizeStage(value); + return normalized === 'ANALYZING' + || normalized === 'RIPPING' + || normalized === 'MEDIAINFO_CHECK' + || normalized === 'ENCODING' + || normalized === 'CD_ANALYZING' + || normalized === 'CD_RIPPING' + || normalized === 'CD_ENCODING'; +} + +function isTerminalStage(value) { + const normalized = normalizeStage(value); + return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; +} + +function isInteractiveReviewStage(value) { + const normalized = normalizeStage(value); + return normalized === 'METADATA_LOOKUP' + || normalized === 'METADATA_SELECTION' + || normalized === 'WAITING_FOR_USER_DECISION' + || normalized === 'READY_TO_ENCODE'; +} + +function parseTimestamp(value) { + if (!value) { + return 0; + } + const ts = Date.parse(String(value)); + return Number.isFinite(ts) ? ts : 0; +} + +function mergePipelineStatePreservingNewestQueue(previousPipeline, incomingPipeline) { + const prev = previousPipeline && typeof previousPipeline === 'object' ? previousPipeline : {}; + const incoming = incomingPipeline && typeof incomingPipeline === 'object' ? incomingPipeline : {}; + const prevQueue = prev?.queue && typeof prev.queue === 'object' ? prev.queue : null; + const incomingQueue = incoming?.queue && typeof incoming.queue === 'object' ? incoming.queue : null; + if (!prevQueue || !incomingQueue) { + return { + ...prev, + ...incoming + }; + } + const prevQueueTs = parseTimestamp(prevQueue.updatedAt); + const incomingQueueTs = parseTimestamp(incomingQueue.updatedAt); + const queue = incomingQueueTs >= prevQueueTs ? incomingQueue : prevQueue; + return { + ...prev, + ...incoming, + queue + }; +} + +function createInitialAudiobookUploadState() { + return { + phase: 'idle', + fileName: null, + loadedBytes: 0, + totalBytes: 0, + progressPercent: 0, + statusText: null, + errorMessage: null, + jobId: null, + uploadSessionId: null, + fileLastModified: null, + fileFingerprint: null, + startedAt: null, + finishedAt: null + }; +} + +function getDownloadIndicatorMeta(summary) { + const activeCount = Number(summary?.activeCount || 0); + const failedCount = Number(summary?.failedCount || 0); + const totalCount = Number(summary?.totalCount || 0); + + if (activeCount > 0) { + return { + icon: 'pi pi-spinner pi-spin', + label: activeCount === 1 ? '1 ZIP aktiv' : `${activeCount} ZIPs aktiv`, + className: 'zip-status-indicator-active' + }; + } + if (totalCount > 0) { + return { + icon: 'pi pi-check', + label: failedCount > 0 ? 'ZIP-Jobs beendet' : 'ZIPs fertig', + className: 'zip-status-indicator-ready' + }; + } + return { + icon: 'pi pi-download', + label: 'ZIPs', + className: 'zip-status-indicator-idle' + }; +} + +const HARDWARE_MONITOR_SETTING_KEYS = new Set([ + 'hardware_monitoring_enabled', + 'hardware_monitoring_interval_ms' +]); + +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 [expertMode, setExpertMode] = useState(false); + const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState()); + const [ripperJobsRefreshToken, setRipperJobsRefreshToken] = useState(0); + const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0); + const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0); + const [downloadSummary, setDownloadSummary] = useState(null); + const location = useLocation(); + const navigate = useNavigate(); + const globalToastRef = useRef(null); + const prevCdDrivesRef = useRef({}); + const prevJobProgressStatesRef = useRef({}); + const hardwareWarmupRef = useRef({ timer: null, attempts: 0 }); + const audiobookUploadAbortRef = useRef(null); + + const clearHardwareWarmupRetry = () => { + if (hardwareWarmupRef.current?.timer) { + clearTimeout(hardwareWarmupRef.current.timer); + hardwareWarmupRef.current.timer = null; + } + }; + + // When a virtual CD drive is removed (CD encode/rip finished or failed), + // or when a CD drive transitions away from an active job, force both + // Ripper and History to re-fetch so jobs leave the live list reliably. + useEffect(() => { + const current = pipeline?.cdDrives || {}; + const prev = prevCdDrivesRef.current; + const normalizeState = (value) => String(value || '').trim().toUpperCase(); + const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']); + const TERMINAL_CD_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']); + let shouldRefresh = false; + + for (const [devicePath, previousDrive] of Object.entries(prev)) { + const previousJobId = normalizeJobId(previousDrive?.jobId); + if (!previousJobId) { + continue; + } + const previousState = normalizeState(previousDrive?.state); + const currentDrive = current?.[devicePath] || null; + const currentJobId = normalizeJobId(currentDrive?.jobId); + const currentState = normalizeState(currentDrive?.state); + const driveRemoved = !currentDrive; + const jobChanged = currentJobId !== previousJobId; + const movedToTerminal = currentJobId === previousJobId + && TERMINAL_CD_STATES.has(currentState) + && currentState !== previousState; + const leftActiveLifecycle = ACTIVE_CD_STATES.has(previousState) + && (driveRemoved || jobChanged || !ACTIVE_CD_STATES.has(currentState)); + if (movedToTerminal || leftActiveLifecycle) { + shouldRefresh = true; + break; + } + } + + if (shouldRefresh) { + setRipperJobsRefreshToken((t) => t + 1); + setHistoryJobsRefreshToken((t) => t + 1); + } + prevCdDrivesRef.current = current; + }, [pipeline?.cdDrives]); + + // Trigger a jobs refresh when a tracked job transitions into a terminal or + // interactive review state through PIPELINE_PROGRESS, so Ripper/History + // update immediately without a manual page reload. + useEffect(() => { + const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object' + ? pipeline.jobProgress + : {}; + const previousStates = prevJobProgressStatesRef.current || {}; + const nextStates = {}; + let shouldRefresh = false; + + for (const [jobId, entry] of Object.entries(current)) { + const currentState = normalizeStage(entry?.state); + const previousState = normalizeStage(previousStates[jobId]); + if (currentState) { + nextStates[jobId] = currentState; + } + if ( + currentState + && isTerminalStage(currentState) + && currentState !== previousState + && !isTerminalStage(previousState) + ) { + shouldRefresh = true; + } + if ( + currentState + && isInteractiveReviewStage(currentState) + && currentState !== previousState + ) { + shouldRefresh = true; + } + } + + prevJobProgressStatesRef.current = nextStates; + if (shouldRefresh) { + setRipperJobsRefreshToken((token) => token + 1); + setHistoryJobsRefreshToken((token) => token + 1); + } + }, [pipeline?.jobProgress]); + + const refreshPipeline = async () => { + const response = await api.getPipelineState(); + setPipeline(response.pipeline); + const monitoringPayload = response?.hardwareMonitoring || null; + setHardwareMonitoring(monitoringPayload); + const monitoringEnabled = Boolean(monitoringPayload?.enabled); + const hasSample = Boolean(monitoringPayload?.sample && typeof monitoringPayload.sample === 'object'); + if (monitoringEnabled && !hasSample) { + const nextAttempts = Number(hardwareWarmupRef.current?.attempts || 0) + 1; + hardwareWarmupRef.current.attempts = nextAttempts; + if (nextAttempts <= 10 && !hardwareWarmupRef.current.timer) { + hardwareWarmupRef.current.timer = setTimeout(() => { + hardwareWarmupRef.current.timer = null; + refreshPipeline().catch(() => null); + }, 1000); + } + } else { + hardwareWarmupRef.current.attempts = 0; + clearHardwareWarmupRetry(); + } + return response; + }; + + const handleAudiobookUpload = async (file, payload = {}) => { + if (!file) { + throw new Error('Bitte zuerst eine AAX-Datei auswählen.'); + } + const fileName = String(file.name || '').trim() || 'upload.aax'; + const totalBytes = Number(file.size || 0); + const abortController = new AbortController(); + audiobookUploadAbortRef.current = abortController; + + setAudiobookUpload({ + phase: 'uploading', + fileName, + loadedBytes: 0, + totalBytes, + progressPercent: 0, + statusText: 'AAX-Datei wird hochgeladen ...', + errorMessage: null, + jobId: null, + uploadSessionId: null, + fileLastModified: Number.isFinite(Number(file.lastModified)) + ? Math.trunc(Number(file.lastModified)) + : null, + fileFingerprint: null, + startedAt: new Date().toISOString(), + finishedAt: null + }); + + try { + const response = await api.uploadAudiobook(file, payload, { + signal: abortController.signal, + onProgress: ({ loaded, total, percent }) => { + const loadedBytes = Number.isFinite(Number(loaded)) ? Number(loaded) : 0; + const totalSize = Number.isFinite(Number(total)) && Number(total) > 0 ? Number(total) : totalBytes; + const progressPercent = Number.isFinite(Number(percent)) + ? clampPercent(Number(percent)) + : (totalSize > 0 ? clampPercent((loadedBytes / totalSize) * 100) : 0); + setAudiobookUpload((prev) => ({ + ...prev, + phase: 'uploading', + loadedBytes, + totalBytes: totalSize, + progressPercent, + statusText: 'AAX-Datei wird hochgeladen ...', + errorMessage: null + })); + } + }); + const uploadedJobId = extractUploadJobIdFromResponse(response); + const started = Boolean(response?.result?.started); + const queued = Boolean(response?.result?.queued); + await refreshPipeline().catch(() => null); + setRipperJobsRefreshToken((prev) => prev + 1); + setHistoryJobsRefreshToken((prev) => prev + 1); + setAudiobookUpload((prev) => ({ + ...prev, + phase: 'completed', + loadedBytes: prev.totalBytes || totalBytes, + totalBytes: prev.totalBytes || totalBytes, + progressPercent: 100, + statusText: uploadedJobId + ? ( + queued + ? `Upload abgeschlossen. Job #${uploadedJobId} wurde in die Queue eingereiht.` + : (started + ? `Upload abgeschlossen. Job #${uploadedJobId} wurde gestartet.` + : `Upload abgeschlossen. Job #${uploadedJobId} ist bereit.`) + ) + : 'Upload abgeschlossen.', + errorMessage: null, + jobId: uploadedJobId, + finishedAt: new Date().toISOString() + })); + return response; + } catch (error) { + if (error?.name === 'AbortError') { + setAudiobookUpload(createInitialAudiobookUploadState()); + throw error; + } + setAudiobookUpload((prev) => ({ + ...prev, + phase: 'error', + errorMessage: error?.message || 'Upload fehlgeschlagen.', + statusText: error?.message || 'Upload fehlgeschlagen.', + finishedAt: new Date().toISOString() + })); + throw error; + } finally { + if (audiobookUploadAbortRef.current === abortController) { + audiobookUploadAbortRef.current = null; + } + } + }; + + const handleCancelAudiobookUpload = () => { + const activeAbortController = audiobookUploadAbortRef.current; + if (activeAbortController) { + activeAbortController.abort(); + return; + } + setAudiobookUpload(createInitialAudiobookUploadState()); + }; + + useEffect(() => { + refreshPipeline().catch(() => null); + api.getDownloadsSummary() + .then((response) => { + setDownloadSummary(response?.summary || null); + }) + .catch(() => null); + api.getSettings() + .then((response) => { + const allSettings = (response?.categories || []).flatMap((c) => c.settings || []); + const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value; + setExpertMode(val === 'true' || val === true); + }) + .catch(() => null); + return () => { + clearHardwareWarmupRetry(); + }; + }, []); + + useWebSocket({ + onMessage: (message) => { + if (message.type === 'WS_CONNECTED') { + refreshPipeline().catch(() => null); + } + + if (message.type === 'PIPELINE_STATE_CHANGED') { + setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload)); + const nextState = normalizeStage(message?.payload?.state); + if (isTerminalStage(nextState) || isInteractiveReviewStage(nextState)) { + setRipperJobsRefreshToken((token) => token + 1); + setHistoryJobsRefreshToken((token) => token + 1); + } + } + + 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 }; + const normalizedProgressJobId = normalizeJobId(progressJobId); + const progressStage = normalizeStage(payload?.state); + const isCdProgressStage = progressStage === 'CD_ANALYZING' + || progressStage === 'CD_RIPPING' + || progressStage === 'CD_ENCODING'; + const incomingIsRunning = isRunningStage(progressStage); + const incomingIsTerminal = isTerminalStage(progressStage); + const prevActiveJobId = normalizeJobId(prev?.activeJobId || prev?.context?.jobId); + const prevJobProgress = normalizedProgressJobId + ? (prev?.jobProgress?.[normalizedProgressJobId] || null) + : null; + const prevJobProgressState = normalizeStage(prevJobProgress?.state); + const prevJobProgressIsRunning = isRunningStage(prevJobProgressState); + const prevJobProgressIsTerminal = isTerminalStage(prevJobProgressState); + const matchingCdDriveEntry = normalizedProgressJobId + ? Object.values(prev?.cdDrives || {}).find((driveState) => normalizeJobId(driveState?.jobId) === normalizedProgressJobId) + : null; + const matchingCdDriveState = normalizeStage(matchingCdDriveEntry?.state); + const matchingCdDriveIsRunning = isRunningStage(matchingCdDriveState); + const matchingCdDriveIsTerminal = isTerminalStage(matchingCdDriveEntry?.state); + const previousGlobalStage = normalizeStage(prev?.state); + const previousGlobalIsRunning = isRunningStage(previousGlobalStage); + const previousGlobalIsTerminal = isTerminalStage(previousGlobalStage); + const hasKnownBinding = Boolean( + normalizedProgressJobId + && ( + prevActiveJobId === normalizedProgressJobId + || prevJobProgress + || matchingCdDriveEntry + ) + ); + const regressesStableJobState = Boolean( + normalizedProgressJobId + && incomingIsRunning + && ( + (prevJobProgressState && !prevJobProgressIsRunning && !prevJobProgressIsTerminal) + || (matchingCdDriveState && !matchingCdDriveIsRunning && !matchingCdDriveIsTerminal) + || ( + prevActiveJobId === normalizedProgressJobId + && previousGlobalStage + && !previousGlobalIsRunning + && !previousGlobalIsTerminal + ) + ) + ); + const isUnknownRunningProgress = Boolean( + normalizedProgressJobId + && incomingIsRunning + && !hasKnownBinding + ); + const allowRunningTransitionFromTerminal = Boolean( + normalizedProgressJobId + && incomingIsRunning + && prevActiveJobId === normalizedProgressJobId + ); + + // Ignore late/stale progress packets that arrive after a terminal state + // or regress a job from an interactive/ready state back into an + // earlier running stage because an older progress packet arrived late. + if ( + normalizedProgressJobId + && !incomingIsTerminal + && !allowRunningTransitionFromTerminal + && ( + prevJobProgressIsTerminal + || matchingCdDriveIsTerminal + || isUnknownRunningProgress + || regressesStableJobState + ) + ) { + return prev; + } + + if (progressJobId != null) { + const previousJobProgress = prev?.jobProgress?.[progressJobId] || {}; + const previousJobStage = normalizeStage(previousJobProgress?.state); + const keepPreviousJobStage = isTerminalStage(previousJobStage) + && !incomingIsTerminal + && !allowRunningTransitionFromTerminal; + 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: keepPreviousJobStage ? previousJobProgress.state : payload.state, + progress: keepPreviousJobStage ? previousJobProgress.progress : payload.progress, + eta: keepPreviousJobStage ? previousJobProgress.eta : payload.eta, + statusText: keepPreviousJobStage ? previousJobProgress.statusText : payload.statusText, + ...(mergedJobContext !== undefined ? { context: mergedJobContext } : {}) + } + }; + } + if (progressJobId === prev?.activeJobId || progressJobId == null) { + const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) + && !incomingIsTerminal + && !allowRunningTransitionFromTerminal; + next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state); + next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress); + next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta); + next.statusText = keepPreviousGlobalStage ? prev?.statusText : (payload.statusText ?? prev?.statusText); + if (contextPatch) { + next.context = { + ...(prev?.context && typeof prev.context === 'object' ? prev.context : {}), + ...contextPatch + }; + } + } + + // Keep per-drive CD progress in sync with live progress events. + // Backend sends frequent PIPELINE_PROGRESS updates, while cdDrives + // snapshots are only broadcast on state transitions. + if (isCdProgressStage && prev?.cdDrives && typeof prev.cdDrives === 'object') { + const cdDrivesEntries = Object.entries(prev.cdDrives); + const nextCdDrives = { ...prev.cdDrives }; + const patchDrive = (driveState) => { + const currentDriveStage = normalizeStage(driveState?.state); + if (isTerminalStage(currentDriveStage) && !incomingIsTerminal) { + return driveState; + } + const mergedContext = contextPatch + ? { + ...(driveState?.context && typeof driveState.context === 'object' ? driveState.context : {}), + ...contextPatch + } + : driveState?.context; + return { + ...driveState, + state: payload?.state ?? driveState?.state, + progress: payload?.progress ?? driveState?.progress, + eta: payload?.eta ?? driveState?.eta, + statusText: payload?.statusText ?? driveState?.statusText, + ...(mergedContext !== undefined ? { context: mergedContext } : {}) + }; + }; + + let updated = false; + if (normalizedProgressJobId) { + for (const [drivePath, driveState] of cdDrivesEntries) { + if (normalizeJobId(driveState?.jobId) === normalizedProgressJobId) { + nextCdDrives[drivePath] = patchDrive(driveState); + updated = true; + } + } + } + + if (!updated) { + const activeCdEntries = cdDrivesEntries.filter(([, driveState]) => { + const driveStage = String(driveState?.state || '').trim().toUpperCase(); + return driveStage === 'CD_ANALYZING' + || driveStage === 'CD_RIPPING' + || driveStage === 'CD_ENCODING'; + }); + if (activeCdEntries.length === 1) { + const [drivePath, driveState] = activeCdEntries[0]; + nextCdDrives[drivePath] = patchDrive(driveState); + updated = true; + } + } + + if (updated) { + next.cdDrives = nextCdDrives; + } + } + + return next; + }); + } + + if (message.type === 'PIPELINE_QUEUE_CHANGED') { + setPipeline((prev) => ({ + ...(prev || {}), + queue: message.payload || null + })); + setRipperJobsRefreshToken((prev) => prev + 1); + setHistoryJobsRefreshToken((prev) => prev + 1); + } + + if (message.type === 'DISC_DETECTED') { + setLastDiscEvent(message.payload?.device || null); + refreshPipeline().catch(() => null); + } + + if (message.type === 'DISC_REMOVED') { + setLastDiscEvent(null); + } + + if (message.type === 'HARDWARE_MONITOR_UPDATE') { + setHardwareMonitoring(message.payload || null); + } + + if (message.type === 'SETTINGS_UPDATED') { + const setting = message.payload; + if (setting?.key === 'ui_expert_mode') { + const val = setting?.value; + setExpertMode(val === 'true' || val === true); + } + const normalizedKey = String(setting?.key || '').trim().toLowerCase(); + if (HARDWARE_MONITOR_SETTING_KEYS.has(normalizedKey)) { + refreshPipeline().catch(() => null); + } + } + + if (message.type === 'SETTINGS_BULK_UPDATED') { + const keys = message.payload?.keys || []; + const normalizedKeys = keys.map((key) => String(key || '').trim().toLowerCase()); + if (keys.includes('ui_expert_mode')) { + api.getSettings({ forceRefresh: true }) + .then((response) => { + const allSettings = (response?.categories || []).flatMap((c) => c.settings || []); + const val = allSettings.find((s) => s.key === 'ui_expert_mode')?.value; + setExpertMode(val === 'true' || val === true); + }) + .catch(() => null); + } + if (normalizedKeys.some((key) => HARDWARE_MONITOR_SETTING_KEYS.has(key))) { + refreshPipeline().catch(() => null); + } + } + + if (message.type === 'DOWNLOADS_UPDATED') { + const summary = message.payload?.summary && typeof message.payload.summary === 'object' + ? message.payload.summary + : null; + const reason = String(message.payload?.reason || '').trim().toLowerCase(); + const item = message.payload?.item && typeof message.payload.item === 'object' + ? message.payload.item + : null; + + if (summary) { + setDownloadSummary(summary); + } + setDownloadsRefreshToken((prev) => prev + 1); + + if (reason === 'ready' && item) { + globalToastRef.current?.show({ + severity: 'success', + summary: 'ZIP fertig', + detail: `${item.archiveName || 'ZIP-Datei'} steht jetzt auf der Downloads-Seite bereit.`, + life: 4500 + }); + } + + if (reason === 'failed' && item) { + globalToastRef.current?.show({ + severity: 'error', + summary: 'ZIP fehlgeschlagen', + detail: item.errorMessage || `${item.archiveName || 'ZIP-Datei'} konnte nicht erstellt werden.`, + life: 5000 + }); + } + } + } + }); + + const nav = [ + { label: 'Ripper', path: '/ripper' }, + { label: 'Converter', path: '/converter' }, + { label: 'Audiobooks', path: '/audiobooks' }, + { label: 'Settings', path: '/settings' }, + { label: 'Historie', path: '/history' }, + { label: 'Downloads', path: '/downloads' }, + ...(expertMode ? [{ label: 'Database', path: '/database' }] : []) + ]; + const downloadIndicator = getDownloadIndicatorMeta(downloadSummary); + const isNavActive = (path) => { + if (path === '/ripper') { + return location.pathname === '/' || location.pathname === '/ripper'; + } + return location.pathname === path; + }; + + return ( +
+ + + +
+
+ Ripster Logo +
+

Ripster

+
+

Disc Ripping Control Center

+ + v{appVersion} + +
+
+
+
+ {nav.map((item) => ( +
+
+ +
+ + + + + } + > + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + + +
+
+ ); +} + +export default App; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..bc603b6 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,1261 @@ +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 Promise.resolve(current.value); + } + if (!current.promise) { + void refreshCachedGet(path, ttlMs); + } + return Promise.resolve(current.value); + } + + if (!forceRefresh && current?.promise) { + return current.promise; + } + + return refreshCachedGet(path, ttlMs); +} + +function afterMutationInvalidate(prefixes = []) { + invalidateCachedGet(prefixes); +} + +function buildHandBrakePresetList(payload) { + const existing = Array.isArray(payload?.presets) ? payload.presets : []; + if (existing.length > 0) { + return existing; + } + const options = Array.isArray(payload?.options) ? payload.options : []; + const list = []; + for (const option of options) { + if (!option || option.disabled) { + continue; + } + const rawValue = option.value ?? option.name ?? option.label ?? ''; + const name = String(rawValue || '').trim(); + if (!name || name.startsWith('__group__')) { + continue; + } + const category = option.category ? String(option.category).trim() : ''; + list.push({ + name, + category: category || null + }); + } + return list; +} + +function normalizeHandBrakePresetPayload(payload) { + if (!payload || typeof payload !== 'object') { + return payload; + } + const existing = Array.isArray(payload.presets) ? payload.presets : []; + if (existing.length > 0) { + return payload; + } + return { + ...payload, + presets: buildHandBrakePresetList(payload) + }; +} + +async function request(path, options = {}) { + const isFormDataBody = typeof FormData !== 'undefined' && options?.body instanceof FormData; + const mergedHeaders = { + ...(isFormDataBody ? {} : { 'Content-Type': 'application/json' }), + ...(options.headers || {}) + }; + const response = await fetch(`${API_BASE}${path}`, { + headers: mergedHeaders, + ...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(); +} + +function resolveFilenameFromDisposition(contentDisposition, fallback = 'download.zip') { + const raw = String(contentDisposition || '').trim(); + if (!raw) { + return fallback; + } + + const encodedMatch = raw.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (encodedMatch?.[1]) { + try { + return decodeURIComponent(encodedMatch[1]); + } catch (_error) { + // ignore malformed content-disposition values + } + } + + const plainMatch = raw.match(/filename\s*=\s*"([^"]+)"/i) || raw.match(/filename\s*=\s*([^;]+)/i); + if (plainMatch?.[1]) { + return String(plainMatch[1]).trim(); + } + + return fallback; +} + +async function download(path, options = {}) { + const response = await fetch(`${API_BASE}${path}`, { + headers: options?.headers || {}, + method: options?.method || 'GET' + }); + + if (!response.ok) { + let errorPayload = null; + let message = `HTTP ${response.status}`; + try { + errorPayload = await response.json(); + message = errorPayload?.error?.message || message; + } catch (_error) { + // ignore parse errors + } + const error = new Error(message); + error.status = response.status; + error.details = errorPayload?.error?.details || null; + throw error; + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + const fallbackFilename = String(options?.filename || 'download.zip').trim() || 'download.zip'; + const filename = resolveFilenameFromDisposition(response.headers.get('content-disposition'), fallbackFilename); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + + return { + filename, + sizeBytes: blob.size + }; +} + +async function requestWithXhr(path, options = {}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const method = String(options?.method || 'GET').trim().toUpperCase() || 'GET'; + const url = `${API_BASE}${path}`; + const headers = options?.headers && typeof options.headers === 'object' ? options.headers : {}; + const signal = options?.signal; + const onUploadProgress = typeof options?.onUploadProgress === 'function' + ? options.onUploadProgress + : null; + + let finished = false; + let abortListener = null; + + const cleanup = () => { + if (signal && abortListener) { + signal.removeEventListener('abort', abortListener); + } + }; + + const settle = (callback) => { + if (finished) { + return; + } + finished = true; + cleanup(); + callback(); + }; + + xhr.open(method, url, true); + xhr.responseType = 'text'; + + Object.entries(headers).forEach(([key, value]) => { + if (value == null) { + return; + } + xhr.setRequestHeader(key, String(value)); + }); + + if (onUploadProgress && xhr.upload) { + xhr.upload.onprogress = (event) => { + const loaded = Number(event?.loaded || 0); + const total = Number(event?.total || 0); + const hasKnownTotal = Boolean(event?.lengthComputable && total > 0); + onUploadProgress({ + loaded, + total: hasKnownTotal ? total : null, + percent: hasKnownTotal ? (loaded / total) * 100 : null + }); + }; + } + + xhr.onerror = () => { + settle(() => { + if (Number(xhr.status || 0) === 0) { + const abortLikeError = new Error('Request unterbrochen.'); + abortLikeError.name = 'AbortError'; + reject(abortLikeError); + return; + } + reject(new Error('Netzwerkfehler')); + }); + }; + + xhr.onabort = () => { + settle(() => { + const error = new Error('Request abgebrochen.'); + error.name = 'AbortError'; + reject(error); + }); + }; + + xhr.onload = () => { + settle(() => { + const contentType = xhr.getResponseHeader('content-type') || ''; + const rawText = xhr.responseText || ''; + + if (xhr.status < 200 || xhr.status >= 300) { + let errorPayload = null; + let message = `HTTP ${xhr.status}`; + try { + errorPayload = rawText ? JSON.parse(rawText) : null; + message = errorPayload?.error?.message || message; + } catch (_error) { + // ignore parse errors + } + const error = new Error(message); + error.status = xhr.status; + error.details = errorPayload?.error?.details || null; + reject(error); + return; + } + + if (contentType.includes('application/json')) { + try { + resolve(rawText ? JSON.parse(rawText) : {}); + } catch (_error) { + reject(new Error('Ungültige JSON-Antwort vom Server.')); + } + return; + } + + resolve(rawText); + }); + }; + + if (signal) { + if (signal.aborted) { + xhr.abort(); + return; + } + abortListener = () => { + if (!finished) { + xhr.abort(); + } + }; + signal.addEventListener('abort', abortListener, { once: true }); + } + + xhr.send(options?.body ?? null); + }); +} + +export const api = { + getSettings(options = {}) { + return requestCachedGet('/settings', { + ttlMs: 5 * 60 * 1000, + forceRefresh: options.forceRefresh + }); + }, + getEffectivePaths(options = {}) { + return requestCachedGet('/settings/effective-paths', { + ttlMs: 30 * 1000, + forceRefresh: options.forceRefresh + }); + }, + getActivationBytes(options = {}) { + return requestCachedGet('/settings/activation-bytes', { + ttlMs: 0, + forceRefresh: options.forceRefresh ?? true + }); + }, + async saveActivationBytes(checksum, activationBytes) { + const result = await request('/settings/activation-bytes', { + method: 'POST', + body: JSON.stringify({ checksum, activationBytes }) + }); + afterMutationInvalidate(['/settings/activation-bytes']); + return result; + }, + getPendingActivation() { + return request('/pipeline/audiobook/pending-activation'); + }, + getDetectedDrives() { + return request('/settings/drives'); + }, + forceUnlockDrive(payload = {}) { + return request('/settings/drives/force-unlock', { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + }, + async getHandBrakePresets(options = {}) { + const result = await requestCachedGet('/settings/handbrake-presets', { + ttlMs: 10 * 60 * 1000, + forceRefresh: options.forceRefresh + }); + return normalizeHandBrakePresetPayload(result); + }, + getMakeMKVBetaKey(options = {}) { + return requestCachedGet('/settings/makemkv/beta-key', { + ttlMs: 10 * 60 * 1000, + forceRefresh: options.forceRefresh + }); + }, + async checkMakeMKVBetaKey() { + const result = await request('/settings/makemkv/beta-key/check', { + method: 'POST' + }); + afterMutationInvalidate(['/settings/makemkv/beta-key']); + return result; + }, + async applyMakeMKVBetaKey(payload = {}) { + const result = await request('/settings/makemkv/beta-key/apply', { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/settings/makemkv/beta-key', '/settings']); + return result; + }, + 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 setScriptFavorite(scriptId, isFavorite) { + const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}/favorite`, { + method: 'PUT', + body: JSON.stringify({ isFavorite: isFavorite === true }) + }); + 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 setScriptChainFavorite(chainId, isFavorite) { + const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}/favorite`, { + method: 'PUT', + body: JSON.stringify({ isFavorite: isFavorite === true }) + }); + 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 getPref(key) { + return request(`/settings/prefs/${encodeURIComponent(key)}`); + }, + async setPref(key, value) { + return request(`/settings/prefs/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ value }) + }); + }, + 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) + }); + }, + async recoverMissingCoverArt(payload = {}) { + const result = await request('/settings/coverart/recover', { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/history', '/settings']); + return result; + }, + getPipelineState() { + return request('/pipeline/state'); + }, + getHardwareHistory(options = {}) { + const hoursRaw = Number(options?.hours); + const maxPointsRaw = Number(options?.maxPoints); + const hours = Number.isFinite(hoursRaw) ? Math.max(1, Math.min(96, Math.trunc(hoursRaw))) : 24; + const maxPoints = Number.isFinite(maxPointsRaw) ? Math.max(120, Math.min(5000, Math.trunc(maxPointsRaw))) : 720; + const query = new URLSearchParams({ + hours: String(hours), + maxPoints: String(maxPoints) + }); + const path = `/pipeline/hardware/history?${query.toString()}`; + return requestCachedGet(path, { + ttlMs: 10 * 1000, + forceRefresh: options.forceRefresh + }); + }, + 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(devicePath = null) { + const body = devicePath ? { devicePath } : {}; + const result = await request('/pipeline/analyze', { + method: 'POST', + body: JSON.stringify(body) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async rescanDisc() { + const result = await request('/pipeline/rescan-disc', { + method: 'POST' + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async rescanDrive(devicePath) { + const result = await request('/pipeline/rescan-drive', { + method: 'POST', + body: JSON.stringify({ devicePath }) + }); + return result; + }, + searchTmdbMovie(q) { + return request(`/pipeline/tmdb/movie/search?q=${encodeURIComponent(q)}`); + }, + searchTmdbSeries(q, seasonNumber = null) { + const params = new URLSearchParams(); + params.set('q', String(q || '')); + if (seasonNumber !== undefined && seasonNumber !== null && String(seasonNumber).trim() !== '') { + params.set('season', String(seasonNumber).trim()); + } + return request(`/pipeline/tmdb/series/search?${params.toString()}`); + }, + searchMusicBrainz(q, options = {}) { + const params = new URLSearchParams(); + params.set('q', String(q || '')); + const expectedTrackCount = Number(options?.trackCount); + if (Number.isFinite(expectedTrackCount) && expectedTrackCount > 0) { + params.set('trackCount', String(Math.trunc(expectedTrackCount))); + } + return request(`/pipeline/cd/musicbrainz/search?${params.toString()}`); + }, + getMusicBrainzRelease(mbId) { + return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`); + }, + async selectCdMetadata(payload) { + const result = await request('/pipeline/cd/select-metadata', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async startCdRip(jobId, ripConfig) { + const result = await request(`/pipeline/cd/start/${jobId}`, { + method: 'POST', + body: JSON.stringify(ripConfig || {}) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async uploadAudiobook(file, payload = {}, options = {}) { + const formData = new FormData(); + if (file) { + formData.append('file', file); + } + if (payload?.format) { + formData.append('format', String(payload.format)); + } + if (payload?.startImmediately !== undefined) { + formData.append('startImmediately', String(payload.startImmediately)); + } + const result = await requestWithXhr('/pipeline/audiobook/upload', { + method: 'POST', + body: formData, + signal: options?.signal, + onUploadProgress: options?.onProgress + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async startAudiobook(jobId, payload = {}) { + const result = await request(`/pipeline/audiobook/start/${jobId}`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + getAudiobookJobs() { + return request('/pipeline/audiobook/jobs'); + }, + async selectMetadata(payload) { + const result = await request('/pipeline/select-metadata', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async submitRawDecision(jobId, decision) { + const result = await request(`/pipeline/jobs/${jobId}/raw-decision`, { + method: 'POST', + body: JSON.stringify({ decision }) + }); + 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 reorderMultipartMergeSources(jobId, orderedSourceJobIds = []) { + const result = await request(`/pipeline/multipart-merge/${jobId}/reorder`, { + method: 'POST', + body: JSON.stringify({ + orderedSourceJobIds: Array.isArray(orderedSourceJobIds) ? orderedSourceJobIds : [] + }) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async updateMultipartMergeSettings(jobId, settings = {}) { + const result = await request(`/pipeline/multipart-merge/${jobId}/settings`, { + method: 'POST', + body: JSON.stringify({ + deleteInputsAfterMerge: Boolean(settings?.deleteInputsAfterMerge) + }) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + getMultipartMergePreview(jobId) { + return request(`/pipeline/multipart-merge/${jobId}/preview`); + }, + async restoreMultipartMergeJob(containerJobId) { + const result = await request(`/pipeline/multipart-merge/${containerJobId}/restore`, { + 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, options = {}) { + const body = {}; + if (options.createNewJob) body.createNewJob = true; + const result = await request(`/pipeline/retry/${jobId}`, { + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined + }); + 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, options = {}) { + const body = {}; + if (options.keepBoth) body.keepBoth = true; + if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; + const result = await request(`/pipeline/reencode/${jobId}`, { + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async restartReviewFromRaw(jobId, options = {}) { + const body = {}; + if (options.keepBoth) body.keepBoth = true; + if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; + if (options.reuseCurrentJob) body.reuseCurrentJob = true; + if (options.createNewJob) body.createNewJob = true; + const result = await request(`/pipeline/restart-review/${jobId}`, { + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async restartEncodeWithLastSettings(jobId, options = {}) { + const body = {}; + if (options.keepBoth) body.keepBoth = true; + if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; + if (options.restartMode) body.restartMode = options.restartMode; + if (options.createNewJob) body.createNewJob = true; + const result = await request(`/pipeline/restart-encode/${jobId}`, { + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + getOutputFolders(jobId) { + return request(`/pipeline/output-folders/${jobId}`); + }, + async deleteOutputFolders(jobId, folderPaths = []) { + const result = await request(`/pipeline/delete-output-folders/${jobId}`, { + method: 'POST', + body: JSON.stringify({ folderPaths }) + }); + afterMutationInvalidate(['/history']); + return result; + }, + async restartCdReviewFromRaw(jobId, options = {}) { + const body = {}; + if (options.keepBoth) body.keepBoth = true; + if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; + const result = await request(`/pipeline/restart-cd-review/${jobId}`, { + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined + }); + 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'); + } + if (params.includeChildren) { + query.set('includeChildren', '1'); + } + const suffix = query.toString() ? `?${query.toString()}` : ''; + return request(`/history${suffix}`); + }, + getTmdbMigrationPendingJobs(params = {}) { + const query = new URLSearchParams(); + if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) { + query.set('limit', String(Math.trunc(Number(params.limit)))); + } + const suffix = query.toString() ? `?${query.toString()}` : ''; + return request(`/history/tmdb-migration/pending${suffix}`); + }, + async setJobTmdbMigrationFlag(jobId, migrated = false) { + const result = await request(`/history/${jobId}/tmdb-migration`, { + method: 'POST', + body: JSON.stringify({ + migrated: Boolean(migrated) + }) + }); + afterMutationInvalidate(['/history']); + return result; + }, + 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 deleteOrphanRawFolder(rawPath) { + const result = await request('/history/orphan-raw/delete', { + method: 'POST', + body: JSON.stringify({ rawPath }) + }); + afterMutationInvalidate(['/history']); + return result; + }, + async assignJobMetadata(jobId, payload = {}) { + const result = await request(`/history/${jobId}/metadata/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 acknowledgeJobError(jobId) { + const result = await request(`/history/${jobId}/error/ack`, { + method: 'POST' + }); + afterMutationInvalidate(['/history']); + return result; + }, + async generateJobNfo(jobId) { + const result = await request(`/history/${jobId}/nfo/generate`, { + method: 'POST' + }); + afterMutationInvalidate(['/history']); + return result; + }, + async deleteJobFiles(jobId, target = 'both', options = {}) { + const includeRelated = Boolean(options?.includeRelated); + const selectedJobIds = Array.isArray(options?.selectedJobIds) + ? options.selectedJobIds + .map((item) => Number(item)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + : null; + const selectedRawPaths = Array.isArray(options?.selectedRawPaths) + ? options.selectedRawPaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) + ? options.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const result = await request(`/history/${jobId}/delete-files`, { + method: 'POST', + body: JSON.stringify({ + target, + includeRelated, + ...(selectedJobIds ? { selectedJobIds } : {}), + ...(selectedRawPaths ? { selectedRawPaths } : {}), + ...(selectedMoviePaths ? { selectedMoviePaths } : {}) + }) + }); + afterMutationInvalidate(['/history']); + return result; + }, + getJobDeletePreview(jobId, options = {}) { + const includeRelated = options?.includeRelated !== false; + const selectedJobIds = Array.isArray(options?.selectedJobIds) + ? options.selectedJobIds + .map((item) => Number(item)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + : null; + const query = new URLSearchParams(); + query.set('includeRelated', includeRelated ? '1' : '0'); + if (selectedJobIds) { + query.set('selectedJobIds', selectedJobIds.join(',')); + } + return request(`/history/${jobId}/delete-preview?${query.toString()}`); + }, + async deleteJobEntry(jobId, target = 'none', options = {}) { + const includeRelated = Boolean(options?.includeRelated); + const resetDriveState = Boolean(options?.resetDriveState); + const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs); + const selectedJobIds = Array.isArray(options?.selectedJobIds) + ? options.selectedJobIds + .map((item) => Number(item)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + : null; + const selectedRawPaths = Array.isArray(options?.selectedRawPaths) + ? options.selectedRawPaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) + ? options.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + const hasKeepDetectedDevice = options?.keepDetectedDevice !== undefined; + const keepDetectedDevice = hasKeepDetectedDevice + ? Boolean(options.keepDetectedDevice) + : null; + const result = await request(`/history/${jobId}/delete`, { + method: 'POST', + body: JSON.stringify({ + target, + includeRelated, + resetDriveState, + preserveRawForImportJobs, + ...(selectedJobIds ? { selectedJobIds } : {}), + ...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}), + ...(selectedRawPaths ? { selectedRawPaths } : {}), + ...(selectedMoviePaths ? { selectedMoviePaths } : {}) + }) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + requestJobArchive(jobId, target = 'raw', options = {}) { + const outputPath = String(options?.outputPath || '').trim(); + return request(`/downloads/history/${jobId}`, { + method: 'POST', + body: JSON.stringify({ + target, + ...(outputPath ? { outputPath } : {}) + }) + }); + }, + getDownloads() { + return request('/downloads'); + }, + getDownloadsSummary() { + return request('/downloads/summary'); + }, + downloadPreparedArchive(downloadId) { + const link = document.createElement('a'); + link.href = `${API_BASE}/downloads/${encodeURIComponent(downloadId)}/file`; + link.download = ''; + document.body.appendChild(link); + link.click(); + link.remove(); + return Promise.resolve(); + }, + deleteDownload(downloadId) { + return request(`/downloads/${encodeURIComponent(downloadId)}`, { + method: 'DELETE' + }); + }, + 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 + }); + }, + getUserPresetDefaults(options = {}) { + return requestCachedGet('/settings/user-preset-defaults', { + ttlMs: 2 * 60 * 1000, + forceRefresh: options.forceRefresh + }); + }, + async updateUserPresetDefaults(defaults = {}) { + const result = await request('/settings/user-preset-defaults', { + method: 'PUT', + body: JSON.stringify({ defaults }) + }); + afterMutationInvalidate(['/settings/user-preset-defaults']); + return result; + }, + async createUserPreset(payload = {}) { + const result = await request('/settings/user-presets', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']); + 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', '/settings/user-preset-defaults']); + return result; + }, + async deleteUserPreset(id) { + const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, { + method: 'DELETE' + }); + afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']); + 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 }) + }); + }, + + // ── Converter ────────────────────────────────────────────────────────────── + converterGetTree() { + return request('/converter/tree'); + }, + converterBrowse(parent = null) { + const q = parent ? `?parent=${encodeURIComponent(parent)}` : ''; + return request(`/converter/browse${q}`); + }, + converterScan() { + return request('/converter/scan', { method: 'POST' }); + }, + converterCreateJobs(entries) { + return request('/converter/create-jobs', { + method: 'POST', + body: JSON.stringify({ entries }) + }); + }, + converterCreateJobsFromSelection(relPaths, audioMode = 'individual') { + return request('/converter/jobs/from-selection', { + method: 'POST', + body: JSON.stringify({ relPaths, audioMode }) + }); + }, + converterAssignFilesToJob(jobId, relPaths = []) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}/assign-files`, { + method: 'POST', + body: JSON.stringify({ relPaths }) + }); + }, + converterRemoveFileFromJob(jobId, relPath) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}/remove-file`, { + method: 'POST', + body: JSON.stringify({ relPath }) + }); + }, + converterRemoveInputFromJob(jobId, inputPath) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}/remove-input`, { + method: 'POST', + body: JSON.stringify({ inputPath }) + }); + }, + converterUpdateJobConfig(jobId, config = {}) { + return request(`/converter/jobs/${encodeURIComponent(jobId)}/config`, { + method: 'POST', + body: JSON.stringify(config) + }); + }, + converterUploadFiles(formData) { + return request('/converter/upload', { method: 'POST', body: formData }); + }, + getConverterJobs() { + return request('/converter/jobs'); + }, + startConverterJob(jobId, config = {}) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}/start`, { + method: 'POST', + body: JSON.stringify(config) + }); + }, + cancelConverterJob(jobId) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}/cancel`, { + method: 'POST' + }); + }, + deleteConverterJob(jobId) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}`, { + method: 'DELETE' + }); + }, + + // ── Converter Datei-Operationen ────────────────────────────────────────── + converterDeleteFile(relPath) { + return request('/converter/files', { method: 'DELETE', body: JSON.stringify({ relPath }) }); + }, + converterRenameFile(relPath, newName) { + return request('/converter/files/rename', { method: 'POST', body: JSON.stringify({ relPath, newName }) }); + }, + converterMoveFile(relPath, targetParentRelPath) { + return request('/converter/files/move', { method: 'POST', body: JSON.stringify({ relPath, targetParentRelPath }) }); + }, + converterCreateFolder(parentRelPath, name) { + return request('/converter/files/folder', { method: 'POST', body: JSON.stringify({ parentRelPath, name }) }); + } +}; + +export { API_BASE }; diff --git a/frontend/src/assets/media-audiobook.svg b/frontend/src/assets/media-audiobook.svg new file mode 100644 index 0000000..44890da --- /dev/null +++ b/frontend/src/assets/media-audiobook.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/media-bluray.svg b/frontend/src/assets/media-bluray.svg new file mode 100644 index 0000000..aaceb30 --- /dev/null +++ b/frontend/src/assets/media-bluray.svg @@ -0,0 +1,11 @@ + + + + + + + + + + BR + diff --git a/frontend/src/assets/media-disc.svg b/frontend/src/assets/media-disc.svg new file mode 100644 index 0000000..45609d6 --- /dev/null +++ b/frontend/src/assets/media-disc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + DVD + diff --git a/frontend/src/assets/media-merge.svg b/frontend/src/assets/media-merge.svg new file mode 100644 index 0000000..4d4e986 --- /dev/null +++ b/frontend/src/assets/media-merge.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/assets/media-other.svg b/frontend/src/assets/media-other.svg new file mode 100644 index 0000000..1a38c45 --- /dev/null +++ b/frontend/src/assets/media-other.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/components/AudiobookConfigPanel.jsx b/frontend/src/components/AudiobookConfigPanel.jsx new file mode 100644 index 0000000..8c70e16 --- /dev/null +++ b/frontend/src/components/AudiobookConfigPanel.jsx @@ -0,0 +1,877 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Dialog } from 'primereact/dialog'; +import { Dropdown } from 'primereact/dropdown'; +import { Slider } from 'primereact/slider'; +import { Button } from 'primereact/button'; +import { Tag } from 'primereact/tag'; +import { InputText } from 'primereact/inputtext'; +import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas'; +import { api } from '../api/client'; +import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; + +function normalizeJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeFormat(value) { + const raw = String(value || '').trim().toLowerCase(); + return AUDIOBOOK_FORMATS.some((entry) => entry.value === raw) ? raw : 'mp3'; +} + +function isFieldVisible(field, values) { + if (!field?.showWhen) { + return true; + } + return values?.[field.showWhen.field] === field.showWhen.value; +} + +function buildFormatOptions(format, existingOptions = {}) { + return { + ...getDefaultAudiobookFormatOptions(format), + ...(existingOptions && typeof existingOptions === 'object' ? existingOptions : {}) + }; +} + +function formatChapterTime(secondsValue) { + const totalSeconds = Number(secondsValue || 0); + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) { + return '-'; + } + const rounded = Math.max(0, Math.round(totalSeconds)); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const seconds = rounded % 60; + if (hours > 0) { + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function stripHtml(value) { + return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function truncateDescription(value, maxLength = 220) { + const normalized = stripHtml(value); + if (!normalized || normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength).trim()}...`; +} + +function normalizeChapterTitle(value, index) { + const normalized = String(value || '').replace(/\s+/g, ' ').trim(); + return normalized || `Kapitel ${index}`; +} + +function normalizeEditableChapters(chapters = []) { + const source = Array.isArray(chapters) ? chapters : []; + return source.map((chapter, index) => { + const safeIndex = Number(chapter?.index); + const resolvedIndex = Number.isFinite(safeIndex) && safeIndex > 0 ? Math.trunc(safeIndex) : index + 1; + return { + index: resolvedIndex, + title: normalizeChapterTitle(chapter?.title, resolvedIndex), + startSeconds: Number(chapter?.startSeconds || 0), + endSeconds: Number(chapter?.endSeconds || 0), + startMs: Number(chapter?.startMs || 0), + endMs: Number(chapter?.endMs || 0) + }; + }); +} + +function formatChapterDuration(startSecondsValue, endSecondsValue) { + const startSeconds = Number(startSecondsValue || 0); + const endSeconds = Number(endSecondsValue || 0); + if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) { + return '-'; + } + return formatChapterTime(endSeconds - startSeconds); +} + +function normalizeTrackStageStatus(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') { + return 'done'; + } + if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') { + return 'in_progress'; + } + if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') { + return 'error'; + } + return 'pending'; +} + +function trackStatusTagMeta(value) { + const normalized = normalizeTrackStageStatus(value); + if (normalized === 'done') { + return { label: 'Fertig', severity: 'success' }; + } + if (normalized === 'in_progress') { + return { label: 'Läuft', severity: 'info' }; + } + if (normalized === 'error') { + return { label: 'Fehler', severity: 'danger' }; + } + return { label: 'Offen', severity: 'secondary' }; +} + +function normalizeScriptId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeChainId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeIdList(values, kind = 'script') { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value); + if (normalized === null) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function buildEncodeItemsFromConfig(config, phase) { + const source = config && typeof config === 'object' ? config : {}; + const prefix = phase === 'post' ? 'post' : 'pre'; + const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : []; + const fromExplicit = explicitItems + .map((item) => { + const type = String(item?.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') { + return null; + } + const id = type === 'chain' + ? normalizeChainId(item?.id ?? item?.chainId) + : normalizeScriptId(item?.id ?? item?.scriptId); + if (!id) { + return null; + } + return { type, id }; + }) + .filter(Boolean); + if (fromExplicit.length > 0) { + return fromExplicit; + } + const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script'); + const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain'); + return [ + ...scriptIds.map((id) => ({ type: 'script', id })), + ...chainIds.map((id) => ({ type: 'chain', id })) + ]; +} + +function FormatField({ field, value, onChange, disabled }) { + if (field.type === 'slider') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, event.value)} + min={field.min} + max={field.max} + step={field.step || 1} + disabled={disabled} + /> +
+ ); + } + + if (field.type === 'select') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, event.value)} + disabled={disabled} + /> +
+ ); + } + + return null; +} + +export default function AudiobookConfigPanel({ + pipeline, + onStart, + onCancel, + onDeleteJob, + busy +}) { + const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {}; + const state = String(pipeline?.state || 'IDLE').trim().toUpperCase() || 'IDLE'; + const jobId = normalizeJobId(context?.jobId); + const metadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object' + ? context.selectedMetadata + : {}; + const audiobookConfig = context?.audiobookConfig && typeof context.audiobookConfig === 'object' + ? context.audiobookConfig + : (context?.mediaInfoReview && typeof context.mediaInfoReview === 'object' ? context.mediaInfoReview : {}); + const initialFormat = normalizeFormat(audiobookConfig?.format); + const chapters = Array.isArray(metadata?.chapters) + ? metadata.chapters + : (Array.isArray(context?.chapters) ? context.chapters : []); + const [format, setFormat] = useState(initialFormat); + const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions)); + const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters)); + const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false); + const [scriptCatalog, setScriptCatalog] = useState([]); + const [chainCatalog, setChainCatalog] = useState([]); + const [preRipItems, setPreRipItems] = useState([]); + const [postRipItems, setPostRipItems] = useState([]); + const audiobookConfigKey = JSON.stringify({ + preEncodeScriptIds: normalizeIdList(audiobookConfig?.preEncodeScriptIds, 'script'), + postEncodeScriptIds: normalizeIdList(audiobookConfig?.postEncodeScriptIds, 'script'), + preEncodeChainIds: normalizeIdList(audiobookConfig?.preEncodeChainIds, 'chain'), + postEncodeChainIds: normalizeIdList(audiobookConfig?.postEncodeChainIds, 'chain'), + preEncodeItems: Array.isArray(audiobookConfig?.preEncodeItems) ? audiobookConfig.preEncodeItems : [], + postEncodeItems: Array.isArray(audiobookConfig?.postEncodeItems) ? audiobookConfig.postEncodeItems : [] + }); + + useEffect(() => { + const nextFormat = normalizeFormat(audiobookConfig?.format); + setFormat(nextFormat); + setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions)); + }, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]); + + useEffect(() => { + setEditableChapters(normalizeEditableChapters(chapters)); + }, [jobId, JSON.stringify(chapters || [])]); + + useEffect(() => { + let cancelled = false; + const loadCatalog = async () => { + try { + const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]); + if (cancelled) { + return; + } + const scripts = scriptsResponse.status === 'fulfilled' + ? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []) + : []; + const chains = chainsResponse.status === 'fulfilled' + ? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []) + : []; + setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name }))); + setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name }))); + } catch (_error) { + if (!cancelled) { + setScriptCatalog([]); + setChainCatalog([]); + } + } + }; + void loadCatalog(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + setPreRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'pre')); + setPostRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'post')); + }, [jobId, audiobookConfigKey]); + + const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3; + const canStart = Boolean(jobId) && ( + state === 'READY_TO_START' + || state === 'READY_TO_ENCODE' + || state === 'ERROR' + || state === 'CANCELLED' + ); + const isRunning = state === 'ENCODING'; + const isFinished = state === 'FINISHED'; + const isSplitOutput = format === 'mp3' || format === 'flac'; + const showEditableChapters = !(isSplitOutput && isRunning); + const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null; + const completedChapterCount = Number(context?.completedChapterCount ?? -1); + const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0); + const statusLabel = getStatusLabel(state); + const statusSeverity = getStatusSeverity(state); + const description = String(metadata?.description || '').trim(); + const descriptionStripped = stripHtml(description); + const descriptionPreview = truncateDescription(description); + const posterUrl = String(metadata?.poster || '').trim() || null; + + const visibleFields = useMemo( + () => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []), + [schema, formatOptions] + ); + + const moveEncodeItem = (phase, index, direction) => { + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const list = Array.isArray(prev) ? [...prev] : []; + const from = Number(index); + const to = from + (direction === 'up' ? -1 : 1); + if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) { + return list; + } + const [moved] = list.splice(from, 1); + list.splice(to, 0, moved); + return list; + }); + }; + + const addEncodeItem = (phase, type) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const selectedIds = new Set( + current + .filter((item) => item?.type === normalizedType) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog; + const candidate = (Array.isArray(catalog) ? catalog : []) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .find((id) => id !== null && !selectedIds.has(String(id))); + if (candidate === undefined || candidate === null) { + return current; + } + return [...current, { type: normalizedType, id: candidate }]; + }); + }; + + const changeEncodeItem = (phase, index, type, nextId) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId); + if (normalizedId === null) { + return; + } + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) { + return current; + } + const duplicate = current.some((item, itemIndex) => { + if (itemIndex === rowIndex) { + return false; + } + if (item?.type !== normalizedType) { + return false; + } + const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id); + return existingId !== null && String(existingId) === String(normalizedId); + }); + if (duplicate) { + return current; + } + const next = [...current]; + next[rowIndex] = { type: normalizedType, id: normalizedId }; + return next; + }); + }; + + const removeEncodeItem = (phase, index) => { + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) { + return current; + } + return current.filter((_, itemIndex) => itemIndex !== rowIndex); + }); + }; + + return ( +
+
+
+ {posterUrl ? ( +
+ {metadata?.title +
+ ) : null} + +
+
Titel: {metadata?.title || '-'}
+
Autor: {metadata?.author || '-'}
+
Sprecher: {metadata?.narrator || '-'}
+
Jahr: {metadata?.year || '-'}
+
Kapitel: {editableChapters.length || '-'}
+ {descriptionPreview ? ( +
+ Beschreibung: + {descriptionPreview} + {descriptionStripped.length > descriptionPreview.length ? ( +
+ ) : null} +
+
+ +
+ +
+
+ +
+
+
+ + { + const nextFormat = normalizeFormat(event.value); + setFormat(nextFormat); + setFormatOptions(buildFormatOptions(nextFormat, {})); + }} + disabled={busy || isRunning} + /> +
+ + {visibleFields.map((field) => ( + { + setFormatOptions((prev) => ({ + ...prev, + [key]: nextValue + })); + }} + disabled={busy || isRunning} + /> + ))} + + + m4b erzeugt eine Datei mit bearbeitbaren Kapiteln. mp3 und flac werden kapitelweise als einzelne Dateien erzeugt. + +
+ + {showEditableChapters ? ( +
+

Kapitel

+ {editableChapters.length === 0 ? ( + Keine Kapitel in der Quelle erkannt. + ) : ( +
+ {editableChapters.map((chapter, index) => ( +
+
+ #{chapter.index || index + 1} + + {formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)} + +
+ { + const nextTitle = event.target.value; + setEditableChapters((prev) => prev.map((entry, entryIndex) => ( + entryIndex === index + ? { ...entry, title: nextTitle } + : entry + ))); + }} + disabled={busy || isRunning} + /> +
+ ))} +
+ )} +
+ ) : null} +
+ + {isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? ( +
+ Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig) +
+
+ + + + + + + + + + + + + {editableChapters.map((chapter, idx) => { + const chIdx = chapter.index || idx + 1; + const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount); + const isActive = !isDone && currentChapter?.index === chIdx; + const ripMeta = trackStatusTagMeta('done'); + const encodeMeta = trackStatusTagMeta(isDone ? 'done' : (isActive ? 'in_progress' : 'pending')); + return ( + + + + + + + + + ); + })} + +
AuswahlNrTitelLängeRipEncode
Ja{String(chIdx).padStart(2, '0')}{chapter.title || '-'}{formatChapterDuration(chapter.startSeconds, chapter.endSeconds)}
+
+
+
+ ) : null} + +
+
+

Pre-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {preRipItems.length === 0 ? ( + Keine Pre-Rip Ausführungen ausgewählt. + ) : null} + {preRipItems.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + preRipItems + .filter((entry, index) => entry?.type === 'script' && index !== rowIndex) + .map((entry) => normalizeScriptId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + preRipItems + .filter((entry, index) => entry?.type === 'chain' && index !== rowIndex) + .map((entry) => normalizeChainId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((entry) => ({ + label: entry?.name || `Skript #${entry?.id}`, + value: normalizeScriptId(entry?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id))) + })).filter((entry) => entry.value !== null); + const chainOptions = chainCatalog.map((entry) => ({ + label: entry?.name || `Kette #${entry?.id}`, + value: normalizeChainId(entry?.id), + disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) + })).filter((entry) => entry.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeEncodeItem('pre', rowIndex, 'script', event.value)} + className="full-width" + disabled={busy} + /> + ) : ( + changeEncodeItem('pre', rowIndex, 'chain', event.value)} + className="full-width" + disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der Encode abgebrochen. +
+ +
+

Post-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {postRipItems.length === 0 ? ( + Keine Post-Rip Ausführungen ausgewählt. + ) : null} + {postRipItems.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + postRipItems + .filter((entry, index) => entry?.type === 'script' && index !== rowIndex) + .map((entry) => normalizeScriptId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + postRipItems + .filter((entry, index) => entry?.type === 'chain' && index !== rowIndex) + .map((entry) => normalizeChainId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((entry) => ({ + label: entry?.name || `Skript #${entry?.id}`, + value: normalizeScriptId(entry?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id))) + })).filter((entry) => entry.value !== null); + const chainOptions = chainCatalog.map((entry) => ({ + label: entry?.name || `Kette #${entry?.id}`, + value: normalizeChainId(entry?.id), + disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) + })).filter((entry) => entry.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeEncodeItem('post', rowIndex, 'script', event.value)} + className="full-width" + disabled={busy} + /> + ) : ( + changeEncodeItem('post', rowIndex, 'chain', event.value)} + className="full-width" + disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander. +
+
+ +
+ {canStart ? ( +
+ + setDescriptionDialogVisible(false)} + > +
Keine Beschreibung vorhanden.

' }} + /> +
+
+ ); +} diff --git a/frontend/src/components/AudiobookUploadPanel.jsx b/frontend/src/components/AudiobookUploadPanel.jsx new file mode 100644 index 0000000..9a0b6f0 --- /dev/null +++ b/frontend/src/components/AudiobookUploadPanel.jsx @@ -0,0 +1,289 @@ +import { useRef, useState } from 'react'; +import { FileUpload } from 'primereact/fileupload'; +import { Button } from 'primereact/button'; +import { ProgressBar } from 'primereact/progressbar'; +import { Toast } from 'primereact/toast'; + +function formatBytes(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return 'n/a'; + } + if (parsed === 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let unitIndex = 0; + let current = parsed; + while (current >= 1024 && unitIndex < units.length - 1) { + current /= 1024; + unitIndex += 1; + } + const digits = unitIndex <= 1 ? 0 : 2; + return `${current.toFixed(digits)} ${units[unitIndex]}`; +} + +function normalizeJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function extractUploadJobIdFromResponse(response) { + const payload = response && typeof response === 'object' ? response : {}; + const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}; + return ( + normalizeJobId(result?.jobId) + || normalizeJobId(payload?.jobId) + || normalizeJobId(result?.id) + || normalizeJobId(payload?.id) + || normalizeJobId(result?.job?.id) + || normalizeJobId(payload?.job?.id) + || null + ); +} + +export default function AudiobookUploadPanel({ + audiobookUpload, + onAudiobookUpload, + onCancelUpload = null, + onUploaded = null +}) { + const toastRef = useRef(null); + const fileUploadRef = useRef(null); + const fallbackFileInputRef = useRef(null); + const [uploadFile, setUploadFile] = useState(null); + + const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); + const uploadBusy = phase === 'uploading' || phase === 'processing'; + const progress = Number.isFinite(Number(audiobookUpload?.progressPercent)) + ? Math.max(0, Math.min(100, Number(audiobookUpload.progressPercent))) + : 0; + const loadedBytes = Number(audiobookUpload?.loadedBytes || 0); + const totalBytes = Number(audiobookUpload?.totalBytes || 0); + const progressLabel = phase === 'processing' + ? '100% | Upload fertig, Job wird vorbereitet ...' + : totalBytes > 0 + ? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}` + : `${Math.trunc(progress)}%`; + const hasHeaderStatus = phase !== 'idle' || Boolean(uploadFile); + const canStartUpload = Boolean(uploadFile) && !uploadBusy; + const canCancelUpload = uploadBusy && typeof onCancelUpload === 'function'; + const canClearSelection = Boolean(uploadFile) && !uploadBusy; + + const handleUpload = async () => { + if (!uploadFile) { + toastRef.current?.show({ + severity: 'warn', + summary: 'Keine Datei', + detail: 'Bitte zuerst eine AAX-Datei auswaehlen.', + life: 2600 + }); + return; + } + try { + const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false }); + const uploadedJobId = extractUploadJobIdFromResponse(response); + if (uploadedJobId) { + toastRef.current?.show({ + severity: 'success', + summary: 'Audiobook importiert', + detail: `Job #${uploadedJobId} ist bereit.`, + life: 3200 + }); + } else { + toastRef.current?.show({ + severity: 'success', + summary: 'Audiobook importiert', + detail: 'Upload abgeschlossen.', + life: 2600 + }); + } + setUploadFile(null); + fileUploadRef.current?.clear?.(); + onUploaded?.(uploadedJobId, response); + } catch (error) { + if (error?.name === 'AbortError') { + toastRef.current?.show({ + severity: 'info', + summary: 'Upload abgebrochen', + detail: 'Der Audiobook-Upload wurde gestoppt.', + life: 2800 + }); + return; + } + toastRef.current?.show({ + severity: 'error', + summary: 'Upload fehlgeschlagen', + detail: error?.message || 'Bitte Logs pruefen.', + life: 4200 + }); + } + }; + + const handleFileSelected = (file) => { + if (!file) { + setUploadFile(null); + return; + } + setUploadFile(file); + }; + + const handleFileCleared = () => { + setUploadFile(null); + }; + + const handleChooseFile = () => { + if (uploadBusy) { + return; + } + fallbackFileInputRef.current?.click(); + }; + + const handleFileItemAction = (onRemove = null) => { + if (canCancelUpload) { + void onCancelUpload(); + onRemove?.(); + fileUploadRef.current?.clear?.(); + handleFileCleared(); + return; + } + onRemove?.(); + fileUploadRef.current?.clear?.(); + handleFileCleared(); + }; + + const renderFileRow = (file, onRemove = null) => ( +
+ +
+ {file?.name || 'upload.aax'} + {formatBytes(Number(file?.size || 0))} + {hasHeaderStatus ? ( +
+ <> + {progressLabel} + + {audiobookUpload?.statusText ? ( + {audiobookUpload.statusText} + ) : null} + +
+ ) : null} +
+
+ ); + + return ( +
+ + void handleUpload()} + disabled={uploadBusy} + onSelect={(event) => { + handleFileSelected(event.files[0] || null); + }} + onClear={() => { + handleFileCleared(); + }} + onRemove={() => { + handleFileCleared(); + }} + chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }} + uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }} + cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }} + headerTemplate={(options) => ( +
+
+
+
+
+
+ )} + itemTemplate={(file, options) => ( + renderFileRow(file, () => { + options.onRemove?.(); + }) + )} + emptyTemplate={() => ( + uploadFile + ? renderFileRow(uploadFile, () => { + // handled by handleFileItemAction + }) + : ( +
+ +

AAX-Datei hier ablegen

+ oder oben "Auswaehlen" klicken +
+ ) + )} + /> + { + const file = event.target?.files?.[0] || null; + handleFileSelected(file); + if (event.target) { + event.target.value = ''; + } + }} + /> +
+ ); +} diff --git a/frontend/src/components/CdMetadataDialog.jsx b/frontend/src/components/CdMetadataDialog.jsx new file mode 100644 index 0000000..139dc97 --- /dev/null +++ b/frontend/src/components/CdMetadataDialog.jsx @@ -0,0 +1,319 @@ +import { useEffect, useRef, useState } from 'react'; +import { Dialog } from 'primereact/dialog'; +import { Button } from 'primereact/button'; +import { DataTable } from 'primereact/datatable'; +import { Column } from 'primereact/column'; +import { InputText } from 'primereact/inputtext'; +import { InputNumber } from 'primereact/inputnumber'; + +function CoverThumb({ url, alt }) { + const [failed, setFailed] = useState(false); + useEffect(() => { + setFailed(false); + }, [url]); + if (!url || failed) { + return
-
; + } + return ( + {alt} setFailed(true)} + /> + ); +} + +const COVER_PRELOAD_TIMEOUT_MS = 3000; + +function preloadCoverImage(url) { + const src = String(url || '').trim(); + if (!src) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const image = new Image(); + let settled = false; + const cleanup = () => { + image.onload = null; + image.onerror = null; + }; + const done = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + const timer = window.setTimeout(done, COVER_PRELOAD_TIMEOUT_MS); + image.onload = () => { + window.clearTimeout(timer); + done(); + }; + image.onerror = () => { + window.clearTimeout(timer); + done(); + }; + image.src = src; + }); +} + +export default function CdMetadataDialog({ + visible, + context, + onHide, + onSubmit, + onSearch, + onFetchRelease, + busy +}) { + const [selected, setSelected] = useState(null); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searchBusy, setSearchBusy] = useState(false); + const searchRunRef = useRef(0); + + // Manual metadata inputs + const [manualTitle, setManualTitle] = useState(''); + const [manualArtist, setManualArtist] = useState(''); + const [manualYear, setManualYear] = useState(null); + + // Track titles are pre-filled from MusicBrainz and edited in the next step. + const [trackTitles, setTrackTitles] = useState({}); + + const tocTracks = Array.isArray(context?.tracks) ? context.tracks : []; + + const contextJobId = Number(context?.jobId || 0); + + useEffect(() => { + if (!visible) { + return; + } + setSelected(null); + setQuery(''); + setManualTitle(''); + setManualArtist(''); + setManualYear(null); + setResults([]); + setSearchBusy(false); + + const titles = {}; + for (const t of tocTracks) { + titles[t.position] = t.title || `Track ${t.position}`; + } + setTrackTitles(titles); + }, [visible, contextJobId]); + + useEffect(() => { + if (!selected) { + return; + } + setManualTitle(selected.title || ''); + setManualArtist(selected.artist || ''); + setManualYear(selected.year || null); + + // Pre-fill track titles from the MusicBrainz result + if (Array.isArray(selected.tracks) && selected.tracks.length > 0) { + const titles = {}; + for (const t of selected.tracks) { + if (t.position <= tocTracks.length) { + titles[t.position] = t.title || `Track ${t.position}`; + } + } + // Fill any remaining tracks not in MB result + for (const t of tocTracks) { + if (!titles[t.position]) { + titles[t.position] = t.title || `Track ${t.position}`; + } + } + setTrackTitles(titles); + } + }, [selected]); + + const handleSearch = async () => { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + return; + } + setSearchBusy(true); + const searchRunId = searchRunRef.current + 1; + searchRunRef.current = searchRunId; + try { + const expectedTrackCount = Array.isArray(tocTracks) ? tocTracks.length : 0; + const searchResults = await onSearch(trimmedQuery, { + trackCount: expectedTrackCount > 0 ? expectedTrackCount : null + }); + const normalizedResults = Array.isArray(searchResults) ? searchResults : []; + await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl))); + if (searchRunRef.current !== searchRunId) { + return; + } + setResults(normalizedResults); + setSelected(null); + } finally { + if (searchRunRef.current === searchRunId) { + setSearchBusy(false); + } + } + }; + + const handleSubmit = async () => { + const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + let releaseDetails = selected; + if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') { + const fetched = await onFetchRelease(selected.mbId); + if (fetched && typeof fetched === 'object') { + releaseDetails = fetched; + } + } + + const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : []; + const releaseTracksByPosition = new Map(); + releaseTracks.forEach((track, index) => { + const parsedPosition = Number(track?.position); + const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0 + ? Math.trunc(parsedPosition) + : index + 1; + if (!releaseTracksByPosition.has(normalizedPosition)) { + releaseTracksByPosition.set(normalizedPosition, track); + } + }); + + const tracks = tocTracks.map((t, index) => { + const position = Number(t.position); + const byPosition = releaseTracksByPosition.get(position); + const byIndex = releaseTracks[index]; + return { + position, + title: normalizeTrackText( + byPosition?.title + || byIndex?.title + || trackTitles[t.position] + ) || `Track ${t.position}`, + artist: normalizeTrackText( + byPosition?.artist + || byIndex?.artist + || manualArtist.trim() + || releaseDetails?.artist + ) || null, + selected: true + }; + }); + + const payload = { + jobId: context.jobId, + title: manualTitle.trim() || context?.detectedTitle || 'Audio CD', + artist: manualArtist.trim() || null, + year: manualYear || null, + mbId: releaseDetails?.mbId || selected?.mbId || null, + coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null, + tracks + }; + + await onSubmit(payload); + }; + + const mbTitleBody = (row) => ( +
+ +
+
{row.title}
+ {row.artist}{row.year ? ` | ${row.year}` : ''} + {row.label ? | {row.label} : null} +
+
+ ); + + return ( + + {/* MusicBrainz search */} +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Album / Interpret suchen" + /> +
+ + {results.length > 0 ? ( +
+ setSelected(e.value)} + dataKey="mbId" + size="small" + scrollable + scrollHeight="16rem" + emptyMessage="Keine Treffer" + > + + + + + +
+ ) : null} + + {/* Manual metadata */} +

Metadaten

+
+ setManualTitle(e.target.value)} + placeholder="Album-Titel" + /> + setManualArtist(e.target.value)} + placeholder="Interpret / Band" + /> + setManualYear(e.value)} + placeholder="Jahr" + useGrouping={false} + min={1900} + max={2100} + /> +
+ + {/* Track selection/editing moved to CD-Rip configuration panel */} + {tocTracks.length > 0 ? ( + + {tocTracks.length} Tracks erkannt. Auswahl/Feinschliff (Checkboxen, Interpret, Titel, Länge) erfolgt im nächsten Schritt in der Job-Übersicht. + + ) : null} + +
+
+
+ ); +} diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx new file mode 100644 index 0000000..1633b58 --- /dev/null +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -0,0 +1,1413 @@ +import { useState, useEffect } from 'react'; +import { Dropdown } from 'primereact/dropdown'; +import { Slider } from 'primereact/slider'; +import { Button } from 'primereact/button'; +import { ProgressBar } from 'primereact/progressbar'; +import { Tag } from 'primereact/tag'; +import { InputText } from 'primereact/inputtext'; +import { InputNumber } from 'primereact/inputnumber'; +import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas'; +import { api } from '../api/client'; +import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; + +function isFieldVisible(field, values) { + if (!field.showWhen) { + return true; + } + return values[field.showWhen.field] === field.showWhen.value; +} + +function FormatField({ field, value, onChange }) { + if (field.type === 'slider') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, e.value)} + min={field.min} + max={field.max} + step={field.step || 1} + /> +
+ ); + } + + if (field.type === 'select') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, e.value)} + /> +
+ ); + } + + return null; +} + +function normalizePosition(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeTrackText(value) { + return String(value || '') + .normalize('NFC') + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeYear(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function formatTrackDuration(track) { + const durationMs = Number(track?.durationMs); + const durationSec = Number(track?.durationSec); + const totalSec = Number.isFinite(durationMs) && durationMs > 0 + ? Math.round(durationMs / 1000) + : (Number.isFinite(durationSec) && durationSec > 0 ? Math.round(durationSec) : 0); + if (totalSec <= 0) { + return '-'; + } + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}:${String(sec).padStart(2, '0')}`; +} + +function formatTotalDuration(totalSec) { + const parsed = Number(totalSec); + if (!Number.isFinite(parsed) || parsed <= 0) { + return '-'; + } + const rounded = Math.max(0, Math.trunc(parsed)); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const seconds = rounded % 60; + if (hours > 0) { + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function formatProgressLabel(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return '0%'; + } + const clamped = Math.max(0, Math.min(100, parsed)); + const whole = Math.trunc(clamped); + return `${whole}%`; +} + +function normalizeTrackStageStatus(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') { + return 'done'; + } + if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') { + return 'in_progress'; + } + if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') { + return 'error'; + } + return 'pending'; +} + +function trackStatusTagMeta(value) { + const normalized = normalizeTrackStageStatus(value); + if (normalized === 'done') { + return { label: 'Fertig', severity: 'success' }; + } + if (normalized === 'in_progress') { + return { label: 'Läuft', severity: 'info' }; + } + if (normalized === 'error') { + return { label: 'Fehler', severity: 'danger' }; + } + return { label: 'Offen', severity: 'secondary' }; +} + +function normalizeScriptId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeChainId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeIdList(values, kind = 'script') { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value); + if (normalized === null) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function buildEncodeItemsFromConfig(config, phase) { + const source = config && typeof config === 'object' ? config : {}; + const prefix = phase === 'post' ? 'post' : 'pre'; + const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : []; + const fromExplicit = explicitItems + .map((item) => { + const type = String(item?.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') { + return null; + } + const id = type === 'chain' + ? normalizeChainId(item?.id ?? item?.chainId) + : normalizeScriptId(item?.id ?? item?.scriptId); + if (!id) { + return null; + } + return { type, id }; + }) + .filter(Boolean); + if (fromExplicit.length > 0) { + return fromExplicit; + } + const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script'); + const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain'); + return [ + ...scriptIds.map((id) => ({ type: 'script', id })), + ...chainIds.map((id) => ({ type: 'chain', id })) + ]; +} + +function describeEncodeItem(item, scriptById, chainById) { + if (!item || typeof item !== 'object') { + return '-'; + } + if (item.type === 'chain') { + const chain = chainById.get(normalizeChainId(item.id)); + return chain?.name || `Kette #${item.id}`; + } + const script = scriptById.get(normalizeScriptId(item.id)); + return script?.name || `Skript #${item.id}`; +} + +export default function CdRipConfigPanel({ + pipeline, + onStart, + onCancel, + onRetry, + onRestartReview, + onOpenMetadata, + onDeleteJob, + busy +}) { + const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {}; + const tracks = Array.isArray(context.tracks) ? context.tracks : []; + const selectedMeta = context.selectedMetadata || {}; + const state = String(pipeline?.state || '').trim().toUpperCase(); + const jobId = normalizePosition(context?.jobId); + const isPostRipWorkflow = context?.skipRip === true; + + const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING'; + const isFinished = state === 'FINISHED'; + const isTerminalFailure = state === 'CANCELLED' || state === 'ERROR'; + + const [format, setFormat] = useState('flac'); + const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac')); + + // Track selection: position → boolean + const [selectedTracks, setSelectedTracks] = useState(() => { + const map = {}; + for (const t of tracks) { + const position = normalizePosition(t?.position); + if (!position) { + continue; + } + map[position] = t?.selected !== false; + } + return map; + }); + // Editable track metadata in job overview (artist/title). + const [trackFields, setTrackFields] = useState(() => { + const map = {}; + const defaultArtist = normalizeTrackText(selectedMeta?.artist); + for (const t of tracks) { + const position = normalizePosition(t?.position); + if (!position) { + continue; + } + const fallbackTitle = `Track ${position}`; + map[position] = { + title: normalizeTrackText(t?.title) || fallbackTitle, + artist: normalizeTrackText(t?.artist) || defaultArtist + }; + } + return map; + }); + const [metaFields, setMetaFields] = useState(() => ({ + title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '', + artist: normalizeTrackText(selectedMeta?.artist) || '', + year: normalizeYear(selectedMeta?.year) + })); + const [scriptCatalog, setScriptCatalog] = useState([]); + const [chainCatalog, setChainCatalog] = useState([]); + const [preRipItems, setPreRipItems] = useState([]); + const [postRipItems, setPostRipItems] = useState([]); + const cdRipConfig = context?.cdRipConfig && typeof context.cdRipConfig === 'object' ? context.cdRipConfig : {}; + const scriptById = new Map( + (Array.isArray(scriptCatalog) ? scriptCatalog : []) + .map((item) => [normalizeScriptId(item?.id), item]) + .filter(([id]) => id !== null) + ); + const chainById = new Map( + (Array.isArray(chainCatalog) ? chainCatalog : []) + .map((item) => [normalizeChainId(item?.id), item]) + .filter(([id]) => id !== null) + ); + const cdRipConfigKey = JSON.stringify({ + preEncodeScriptIds: normalizeIdList(cdRipConfig?.preEncodeScriptIds, 'script'), + postEncodeScriptIds: normalizeIdList(cdRipConfig?.postEncodeScriptIds, 'script'), + preEncodeChainIds: normalizeIdList(cdRipConfig?.preEncodeChainIds, 'chain'), + postEncodeChainIds: normalizeIdList(cdRipConfig?.postEncodeChainIds, 'chain') + }); + + useEffect(() => { + setFormatOptions(getDefaultFormatOptions(format)); + }, [format]); + + useEffect(() => { + const configuredFormat = String(cdRipConfig?.format || '').trim().toLowerCase(); + if (!configuredFormat || !CD_FORMAT_SCHEMAS[configuredFormat]) { + return; + } + setFormat(configuredFormat); + setFormatOptions((prev) => ({ + ...getDefaultFormatOptions(configuredFormat), + ...(prev && typeof prev === 'object' ? prev : {}), + ...(cdRipConfig?.formatOptions && typeof cdRipConfig.formatOptions === 'object' ? cdRipConfig.formatOptions : {}) + })); + }, [context?.jobId, cdRipConfig?.format, JSON.stringify(cdRipConfig?.formatOptions || {})]); + + useEffect(() => { + setMetaFields({ + title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '', + artist: normalizeTrackText(selectedMeta?.artist) || '', + year: normalizeYear(selectedMeta?.year) + }); + }, [context?.jobId, selectedMeta?.title, selectedMeta?.artist, selectedMeta?.year, context?.detectedTitle]); + + useEffect(() => { + setSelectedTracks((prev) => { + const next = {}; + for (const t of tracks) { + const normalized = normalizePosition(t?.position); + if (!normalized) { + continue; + } + if (prev[normalized] !== undefined) { + next[normalized] = prev[normalized]; + } else { + next[normalized] = t?.selected !== false; + } + } + return next; + }); + }, [tracks]); + + useEffect(() => { + const defaultArtist = normalizeTrackText(selectedMeta?.artist); + setTrackFields((prev) => { + const next = {}; + for (const t of tracks) { + const position = normalizePosition(t?.position); + if (!position) { + continue; + } + const previous = prev[position] || {}; + const fallbackTitle = normalizeTrackText(t?.title) || `Track ${position}`; + const fallbackArtist = normalizeTrackText(t?.artist) || defaultArtist; + next[position] = { + title: normalizeTrackText(previous.title) || fallbackTitle, + artist: normalizeTrackText(previous.artist) || fallbackArtist + }; + } + return next; + }); + }, [tracks, selectedMeta?.artist]); + + useEffect(() => { + let cancelled = false; + const loadCatalog = async () => { + try { + const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]); + if (cancelled) { + return; + } + const scripts = scriptsResponse.status === 'fulfilled' + ? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []) + : []; + const chains = chainsResponse.status === 'fulfilled' + ? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []) + : []; + setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name }))); + setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name }))); + } catch (_error) { + if (!cancelled) { + setScriptCatalog([]); + setChainCatalog([]); + } + } + }; + void loadCatalog(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + setPreRipItems(buildEncodeItemsFromConfig(cdRipConfig, 'pre')); + setPostRipItems(buildEncodeItemsFromConfig(cdRipConfig, 'post')); + }, [context?.jobId, cdRipConfigKey]); + + const handleFormatOptionChange = (key, value) => { + setFormatOptions((prev) => ({ ...prev, [key]: value })); + }; + + const handleToggleTrack = (position) => { + setSelectedTracks((prev) => ({ ...prev, [position]: !prev[position] })); + }; + + const handleToggleAll = () => { + const allSelected = tracks.every((t) => { + const position = normalizePosition(t?.position); + return position ? selectedTracks[position] !== false : false; + }); + const map = {}; + for (const t of tracks) { + const position = normalizePosition(t?.position); + if (!position) { + continue; + } + map[position] = !allSelected; + } + setSelectedTracks(map); + }; + + const handleTrackFieldChange = (position, key, value) => { + if (!position) { + return; + } + setTrackFields((prev) => ({ + ...prev, + [position]: { + ...(prev[position] || {}), + [key]: value + } + })); + }; + + const handleMetaFieldChange = (key, value) => { + setMetaFields((prev) => ({ + ...prev, + [key]: value + })); + }; + + const moveEncodeItem = (phase, index, direction) => { + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const list = Array.isArray(prev) ? [...prev] : []; + const from = Number(index); + const to = from + (direction === 'up' ? -1 : 1); + if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) { + return list; + } + const [moved] = list.splice(from, 1); + list.splice(to, 0, moved); + return list; + }); + }; + + const addEncodeItem = (phase, type) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const selectedIds = new Set( + current + .filter((item) => item?.type === normalizedType) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog; + const candidate = (Array.isArray(catalog) ? catalog : []) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .find((id) => id !== null && !selectedIds.has(String(id))); + if (candidate === undefined || candidate === null) { + return current; + } + return [...current, { type: normalizedType, id: candidate }]; + }); + }; + + const changeEncodeItem = (phase, index, type, nextId) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId); + if (normalizedId === null) { + return; + } + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) { + return current; + } + const duplicate = current.some((item, itemIndex) => { + if (itemIndex === rowIndex) { + return false; + } + if (item?.type !== normalizedType) { + return false; + } + const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id); + return existingId !== null && String(existingId) === String(normalizedId); + }); + if (duplicate) { + return current; + } + const next = [...current]; + next[rowIndex] = { type: normalizedType, id: normalizedId }; + return next; + }); + }; + + const removeEncodeItem = (phase, index) => { + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) { + return current; + } + return current.filter((_, itemIndex) => itemIndex !== rowIndex); + }); + }; + + const handleStart = (options = {}) => { + const forceSkipRip = Boolean(options?.forceSkipRip); + const albumTitle = normalizeTrackText(metaFields?.title) + || normalizeTrackText(selectedMeta?.title) + || normalizeTrackText(context?.detectedTitle) + || 'Audio CD'; + const fallbackArtistFromTracks = tracks + .map((track) => normalizeTrackText(track?.artist)) + .find(Boolean) || null; + const albumArtist = normalizeTrackText(metaFields?.artist) + || normalizeTrackText(selectedMeta?.artist) + || fallbackArtistFromTracks + || null; + const albumYear = normalizeYear(metaFields?.year); + + const normalizedTracks = tracks + .map((t) => { + const position = normalizePosition(t?.position); + if (!position) { + return null; + } + const baseTitle = normalizeTrackText(t?.title) || `Track ${position}`; + const baseArtist = normalizeTrackText(t?.artist) || normalizeTrackText(selectedMeta?.artist); + const edited = trackFields[position] || {}; + const title = normalizeTrackText(edited.title) || baseTitle; + const artist = normalizeTrackText(edited.artist) || baseArtist || null; + return { + position, + title, + artist, + selected: selectedTracks[position] !== false + }; + }) + .filter(Boolean); + + const selected = normalizedTracks + .filter((t) => t.selected) + .map((t) => t.position); + + if (selected.length === 0) { + return; + } + + const selectedPreEncodeScriptIds = normalizeIdList( + preRipItems.filter((item) => item?.type === 'script').map((item) => item?.id), + 'script' + ); + const selectedPostEncodeScriptIds = normalizeIdList( + postRipItems.filter((item) => item?.type === 'script').map((item) => item?.id), + 'script' + ); + const selectedPreEncodeChainIds = normalizeIdList( + preRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id), + 'chain' + ); + const selectedPostEncodeChainIds = normalizeIdList( + postRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id), + 'chain' + ); + + onStart && onStart({ + format, + formatOptions, + selectedTracks: selected, + tracks: normalizedTracks, + selectedPreEncodeScriptIds, + selectedPostEncodeScriptIds, + selectedPreEncodeChainIds, + selectedPostEncodeChainIds, + skipRip: forceSkipRip || context.skipRip === true ? true : undefined, + metadata: { + title: albumTitle, + artist: albumArtist, + year: albumYear + } + }); + }; + + const schema = CD_FORMAT_SCHEMAS[format] || { fields: [] }; + const visibleFields = schema.fields.filter((f) => isFieldVisible(f, formatOptions)); + + const selectedCount = tracks.filter((t) => { + const position = normalizePosition(t?.position); + return position ? selectedTracks[position] !== false : false; + }).length; + const progress = Number(pipeline?.progress ?? 0); + const clampedProgress = Math.max(0, Math.min(100, progress)); + const wholeProgress = Math.trunc(clampedProgress); + const eta = String(pipeline?.eta || '').trim(); + const statusText = String(pipeline?.statusText || '').trim(); + const stateLabel = getStatusLabel(state); + const stateSeverity = getStatusSeverity(state); + const cdLive = context?.cdLive && typeof context.cdLive === 'object' ? context.cdLive : {}; + const cdLiveTrackStates = Array.isArray(cdLive?.trackStates) ? cdLive.trackStates : []; + const cdLiveTrackStateByPosition = new Map( + cdLiveTrackStates + .map((item) => [normalizePosition(item?.position), item]) + .filter(([position]) => position !== null) + ); + const cdLiveSelectedTrackPositions = Array.isArray(cdLive?.selectedTrackPositions) + ? cdLive.selectedTrackPositions + .map((value) => normalizePosition(value)) + .filter((value) => value !== null) + : []; + const cdLiveSelectedTrackSet = new Set(cdLiveSelectedTrackPositions.map((value) => String(value))); + const livePhase = String(cdLive?.phase || '').trim().toLowerCase(); + const livePhaseLabel = livePhase === 'encode' ? 'Encode' : 'Rip'; + const albumTitle = normalizeTrackText(metaFields?.title) + || normalizeTrackText(selectedMeta?.title) + || normalizeTrackText(context?.detectedTitle) + || '-'; + const fallbackArtistFromTracks = tracks + .map((track) => normalizeTrackText(track?.artist)) + .find(Boolean) || '-'; + const albumArtist = normalizeTrackText(metaFields?.artist) + || normalizeTrackText(selectedMeta?.artist) + || fallbackArtistFromTracks + || '-'; + const albumYear = normalizeYear(metaFields?.year) + ?? normalizeYear(selectedMeta?.year) + ?? '-'; + const musicBrainzId = normalizeTrackText( + selectedMeta?.mbId + || selectedMeta?.musicBrainzId + || selectedMeta?.musicbrainzId + || selectedMeta?.mbid + || '' + ) || '-'; + const coverUrl = normalizeTrackText( + selectedMeta?.coverUrl + || selectedMeta?.poster + || selectedMeta?.posterUrl + || '' + ) || null; + const devicePath = normalizeTrackText(context?.devicePath) || '-'; + const rawPath = normalizeTrackText(context?.rawPath) || '-'; + const outputPath = normalizeTrackText(context?.outputPath) || '-'; + const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase(); + const formatLabel = (Array.isArray(CD_FORMATS) + ? CD_FORMATS.find((entry) => String(entry?.value || '').trim().toLowerCase() === formatValue)?.label + : null) || (formatValue ? formatValue.toUpperCase() : '-'); + const effectiveTrackRows = tracks + .map((track) => { + const position = normalizePosition(track?.position); + if (!position) { + return null; + } + const selected = cdLiveSelectedTrackSet.size > 0 + ? cdLiveSelectedTrackSet.has(String(position)) + : (selectedTracks[position] !== false); + const trackDurationSec = Number.isFinite(Number(track?.durationMs)) && Number(track.durationMs) > 0 + ? Math.round(Number(track.durationMs) / 1000) + : (Number.isFinite(Number(track?.durationSec)) && Number(track.durationSec) > 0 + ? Math.round(Number(track.durationSec)) + : 0); + const liveTrackState = cdLiveTrackStateByPosition.get(position) || null; + const fallbackRipStatus = isFinished && selected ? 'done' : 'pending'; + const fallbackEncodeStatus = isFinished && selected ? 'done' : 'pending'; + return { + position, + selected, + title: normalizeTrackText(trackFields?.[position]?.title) + || normalizeTrackText(track?.title) + || `Track ${position}`, + artist: normalizeTrackText(trackFields?.[position]?.artist) + || normalizeTrackText(track?.artist) + || normalizeTrackText(selectedMeta?.artist) + || '-', + durationLabel: formatTrackDuration(track), + durationSec: trackDurationSec, + ripStatus: normalizeTrackStageStatus(liveTrackState?.ripStatus || fallbackRipStatus), + encodeStatus: normalizeTrackStageStatus(liveTrackState?.encodeStatus || fallbackEncodeStatus) + }; + }) + .filter(Boolean); + if (effectiveTrackRows.length === 0 && cdLiveTrackStates.length > 0) { + for (const trackState of cdLiveTrackStates) { + const position = normalizePosition(trackState?.position); + if (!position) { + continue; + } + const trackDurationSec = Number(trackState?.durationSec); + effectiveTrackRows.push({ + position, + selected: true, + title: normalizeTrackText(trackState?.title) || `Track ${position}`, + artist: normalizeTrackText(trackState?.artist) || '-', + durationLabel: formatTotalDuration(trackDurationSec), + durationSec: Number.isFinite(trackDurationSec) && trackDurationSec > 0 ? Math.trunc(trackDurationSec) : 0, + ripStatus: normalizeTrackStageStatus(trackState?.ripStatus), + encodeStatus: normalizeTrackStageStatus(trackState?.encodeStatus) + }); + } + } + const selectedTrackRows = effectiveTrackRows.filter((track) => track.selected); + const displayTrackRows = selectedTrackRows.length > 0 ? selectedTrackRows : effectiveTrackRows; + const selectedTrackNumbers = selectedTrackRows + .map((track) => String(track.position).padStart(2, '0')) + .join(', '); + const selectedTrackDurationSec = selectedTrackRows.reduce( + (sum, track) => sum + (Number.isFinite(track.durationSec) ? track.durationSec : 0), + 0 + ); + const completedRipCount = selectedTrackRows.filter((track) => track.ripStatus === 'done').length; + const completedEncodeCount = selectedTrackRows.filter((track) => track.encodeStatus === 'done').length; + const liveCurrentTrackPosition = normalizePosition(cdLive?.trackPosition); + const lastState = String(context?.lastState || '').trim().toUpperCase(); + const lastStateLabel = getStatusLabel(lastState); + const failureStage = String(context?.stage || '').trim().toUpperCase(); + const normalizedLivePhase = String(cdLive?.phase || '').trim().toLowerCase(); + const normalizedStatusText = String(statusText || '').trim().toUpperCase(); + const isEncodingFailure = Boolean( + isTerminalFailure + && ( + lastState === 'CD_ENCODING' + || failureStage === 'CD_ENCODING' + || context?.skipRip === true + || normalizedLivePhase === 'encode' + || normalizedStatusText.includes('CD_ENCODING') + ) + ); + const preScriptNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeScripts) ? cdRipConfig.preEncodeScripts : []) + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + const postScriptNamesFromConfig = (Array.isArray(cdRipConfig?.postEncodeScripts) ? cdRipConfig.postEncodeScripts : []) + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + const preChainNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeChains) ? cdRipConfig.preEncodeChains : []) + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + const postChainNamesFromConfig = (Array.isArray(cdRipConfig?.postEncodeChains) ? cdRipConfig.postEncodeChains : []) + .map((item) => String(item?.name || '').trim()) + .filter(Boolean); + const preScriptNames = preScriptNamesFromConfig.length > 0 + ? preScriptNamesFromConfig + : preRipItems + .filter((item) => item?.type === 'script') + .map((item) => describeEncodeItem(item, scriptById, chainById)); + const postScriptNames = postScriptNamesFromConfig.length > 0 + ? postScriptNamesFromConfig + : postRipItems + .filter((item) => item?.type === 'script') + .map((item) => describeEncodeItem(item, scriptById, chainById)); + const preChainNames = preChainNamesFromConfig.length > 0 + ? preChainNamesFromConfig + : preRipItems + .filter((item) => item?.type === 'chain') + .map((item) => describeEncodeItem(item, scriptById, chainById)); + const postChainNames = postChainNamesFromConfig.length > 0 + ? postChainNamesFromConfig + : postRipItems + .filter((item) => item?.type === 'chain') + .map((item) => describeEncodeItem(item, scriptById, chainById)); + + if (isRipping || isFinished || isTerminalFailure) { + return ( +
+
+ + {statusText || 'Bereit'} +
+ {isRipping ? ( +
+ formatProgressLabel(value)} + /> + + {isPostRipWorkflow + ? `Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode` + : `Fortschritt: ${formatProgressLabel(wholeProgress)}`} + + {eta ? `ETA ${eta}` : 'ETA unbekannt'} +
+ ) : isFinished ? ( +
+ '100%'} /> +
+ ) : null} + {isRipping ? ( +
+
+ ) : null} + {isTerminalFailure ? ( +
+ {isEncodingFailure ? ( +
+ ) : null} + +
+ CD-Details +
+ {coverUrl ? ( +
+ {`Cover +
+ ) : null} +
+
Album: {albumTitle}
+
Interpret: {albumArtist}
+
Jahr: {albumYear}
+
MusicBrainz: {musicBrainzId}
+
Status: {stateLabel}
+ {!isRipping ?
Format: {formatLabel}
: null} + {isPostRipWorkflow ? ( + <> +
Rip fertig: {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}
+
Encode fertig: {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}
+
Aktueller Track: {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}
+
Pre-Skripte: {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}
+
Pre-Ketten: {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}
+
Post-Skripte: {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}
+
Post-Ketten: {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}
+
Auswahl: {selectedTrackNumbers || '-'}
+
Gesamtdauer: {formatTotalDuration(selectedTrackDurationSec)}
+ + ) : null} + {!isRipping ?
Laufwerk: {devicePath}
: null} +
RAW-Pfad: {rawPath}
+ {!isRipping ?
Output-Pfad: {outputPath}
: null} + {lastState ?
Letzter Pipeline-State: {lastStateLabel}
: null} + {!isRipping && jobId ?
Job-ID: #{jobId}
: null} +
+
+
+ + {isPostRipWorkflow && displayTrackRows.length > 0 ? ( +
+ Zu rippende Tracks ({displayTrackRows.length}) +
+ + + + + + + + + + + + + + {displayTrackRows.map((track) => { + const ripMeta = trackStatusTagMeta(track?.ripStatus); + const encodeMeta = trackStatusTagMeta(track?.encodeStatus); + return ( + + + + + + + + + + ); + })} + +
AuswahlNrInterpretTitelLängeRipEncode
{track.selected ? 'Ja' : 'Nein'}{String(track.position).padStart(2, '0')}{track.artist || '-'}{track.title || '-'}{track.durationLabel}
+
+
+ ) : null} +
+ ); + } + + if (!isPostRipWorkflow) { + return ( +
+
+ + {statusText || 'Bereit'} +
+
+ CD-Details +
+
Album: {albumTitle}
+
Interpret: {albumArtist}
+
Jahr: {albumYear}
+
MusicBrainz: {musicBrainzId}
+
Status: {stateLabel}
+
Laufwerk: {devicePath}
+
RAW-Pfad: {rawPath}
+
Output-Pfad: {outputPath}
+ {lastState ?
Letzter Pipeline-State: {lastStateLabel}
: null} + {jobId ?
Job-ID: #{jobId}
: null} +
+
+
+
+
+ ); + } + + return ( +
+

CD-Rip Konfiguration

+ +
+ Album-Metadaten +
+ handleMetaFieldChange('title', e.target.value)} + placeholder="Album" + disabled={busy} + /> + handleMetaFieldChange('artist', e.target.value)} + placeholder="Interpret" + disabled={busy} + /> + handleMetaFieldChange('year', normalizeYear(e.value))} + placeholder="Jahr" + useGrouping={false} + min={1900} + max={2100} + disabled={busy} + /> +
+
+ + {/* Format selection */} +
+ + setFormat(e.value)} + disabled={busy} + /> +
+ + {/* Format-specific options */} + {visibleFields.map((field) => ( + + ))} + + {/* Track selection */} + {tracks.length > 0 ? ( +
+
+ Tracks ({selectedCount} / {tracks.length} ausgewählt) +
+
+ + + + + + + + + + + + {tracks.map((track) => { + const position = normalizePosition(track?.position); + if (!position) { + return null; + } + const isSelected = selectedTracks[position] !== false; + const fields = trackFields[position] || {}; + const titleValue = normalizeTrackText(fields.title) + || normalizeTrackText(track?.title) + || `Track ${position}`; + const artistValue = normalizeTrackText(fields.artist) + || normalizeTrackText(track?.artist) + || normalizeTrackText(selectedMeta?.artist); + const duration = formatTrackDuration(track); + return ( + + + + + + + + ); + })} + +
AuswahlNrInterpretTitelLänge
+ handleToggleTrack(position)} + disabled={busy} + /> + {String(position).padStart(2, '0')} + handleTrackFieldChange(position, 'artist', e.target.value)} + placeholder="Interpret" + disabled={busy || !isSelected} + /> + + handleTrackFieldChange(position, 'title', e.target.value)} + placeholder={`Track ${position}`} + disabled={busy || !isSelected} + /> + {duration}
+
+
+ ) : null} + +
+
+

Pre-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {preRipItems.length === 0 ? ( + Keine Pre-Rip Ausführungen ausgewählt. + ) : null} + {preRipItems.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + preRipItems + .filter((entry, index) => entry?.type === 'script' && index !== rowIndex) + .map((entry) => normalizeScriptId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + preRipItems + .filter((entry, index) => entry?.type === 'chain' && index !== rowIndex) + .map((entry) => normalizeChainId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((entry) => ({ + label: entry?.name || `Skript #${entry?.id}`, + value: normalizeScriptId(entry?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id))) + })).filter((entry) => entry.value !== null); + const chainOptions = chainCatalog.map((entry) => ({ + label: entry?.name || `Kette #${entry?.id}`, + value: normalizeChainId(entry?.id), + disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) + })).filter((entry) => entry.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeEncodeItem('pre', rowIndex, 'script', event.value)} + className="full-width" + disabled={busy} + /> + ) : ( + changeEncodeItem('pre', rowIndex, 'chain', event.value)} + className="full-width" + disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen. +
+ +
+

Post-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {postRipItems.length === 0 ? ( + Keine Post-Rip Ausführungen ausgewählt. + ) : null} + {postRipItems.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + postRipItems + .filter((entry, index) => entry?.type === 'script' && index !== rowIndex) + .map((entry) => normalizeScriptId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + postRipItems + .filter((entry, index) => entry?.type === 'chain' && index !== rowIndex) + .map((entry) => normalizeChainId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((entry) => ({ + label: entry?.name || `Skript #${entry?.id}`, + value: normalizeScriptId(entry?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id))) + })).filter((entry) => entry.value !== null); + const chainOptions = chainCatalog.map((entry) => ({ + label: entry?.name || `Kette #${entry?.id}`, + value: normalizeChainId(entry?.id), + disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) + })).filter((entry) => entry.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeEncodeItem('post', rowIndex, 'script', event.value)} + className="full-width" + disabled={busy} + /> + ) : ( + changeEncodeItem('post', rowIndex, 'chain', event.value)} + className="full-width" + disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander. +
+
+ + {/* Actions */} +
+ {jobId ? ( +
+
+ ); +} diff --git a/frontend/src/components/ConverterFileExplorer.jsx b/frontend/src/components/ConverterFileExplorer.jsx new file mode 100644 index 0000000..062797a --- /dev/null +++ b/frontend/src/components/ConverterFileExplorer.jsx @@ -0,0 +1,992 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from 'primereact/button'; +import { Tag } from 'primereact/tag'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { Dialog } from 'primereact/dialog'; +import { InputText } from 'primereact/inputtext'; +import { Dropdown } from 'primereact/dropdown'; +import { Toast } from 'primereact/toast'; +import { api } from '../api/client'; + +// ── Hilfsfunktionen ────────────────────────────────────────────────────────── + +function formatBytes(value) { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; let v = n; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(i <= 1 ? 0 : 1)} ${units[i]}`; +} + +function mediaTypeBadge(type) { + if (!type) return null; + const map = { video: { label: 'Video', severity: 'info' }, audio: { label: 'Audio', severity: 'success' }, iso: { label: 'ISO', severity: 'warning' } }; + const m = map[type] || { label: type, severity: 'secondary' }; + return ; +} + +/** Navigiert den Baum per Pfad-String (Ordner UND Dateien) */ +function getNodeByPath(root, targetPath) { + if (!root) return null; + if ((root.path || '') === (targetPath || '')) return root; + for (const child of (root.children || [])) { + if (child.type === 'folder') { + const found = getNodeByPath(child, targetPath); + if (found) return found; + } else if ((child.path || '') === (targetPath || '')) { + return child; + } + } + return null; +} + +/** Kinder des aktuellen Knotens (Ordner zuerst, alphabetisch) */ +function listChildren(node) { + if (!node || !Array.isArray(node.children)) return []; + return node.children; // buildRawTree liefert bereits sortiert +} + +/** Breadcrumb aus Pfad-String */ +function buildBreadcrumb(pathStr) { + if (!pathStr) return []; + const parts = String(pathStr).split('/').filter(Boolean); + return parts.map((part, i) => ({ name: part, path: parts.slice(0, i + 1).join('/') })); +} + +/** Baum nach Ordnername filtern (nur Ordner in der Seitenleiste) */ +function filterTree(node, query) { + if (!node || node.type !== 'folder') return null; + if (!query || !query.trim()) return node; + const q = query.toLowerCase(); + const filteredChildren = (node.children || []) + .filter((c) => c.type === 'folder') + .map((c) => filterTree(c, query)) + .filter(Boolean); + const nameMatches = node.name.toLowerCase().includes(q); + if (nameMatches || filteredChildren.length > 0) { + return { ...node, children: filteredChildren }; + } + return null; +} + +/** Alle Datei-Pfade innerhalb eines Knotens rekursiv sammeln */ +function collectDescendantFilePaths(node) { + const result = []; + for (const child of (node?.children || [])) { + if (child.type === 'file') result.push(child.path || ''); + else if (child.type === 'folder') result.push(...collectDescendantFilePaths(child)); + } + return result; +} + +/** Zustände, in denen ein Job aktiv läuft → Dateien sind gesperrt */ +const LOCKED_JOB_STATES = new Set([ + 'ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', + 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING' +]); + +/** Datei ist gesperrt wenn ihr Job gerade aktiv läuft */ +function isNodeLocked(node) { + if (!node || node.type !== 'file') return false; + const jobId = Number(node.jobId); + if (!Number.isFinite(jobId) || jobId <= 0) return false; + return LOCKED_JOB_STATES.has(String(node.jobStatus || '').trim().toUpperCase()); +} + +/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Dateipfade sammeln */ +function collectDescendantSelectableFilePaths(node) { + const result = []; + for (const child of (node?.children || [])) { + if (child.type === 'file') { + const assignedJobId = Number(child.jobId); + if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) { + result.push(child.path || ''); + } + } else if (child.type === 'folder') { + result.push(...collectDescendantSelectableFilePaths(child)); + } + } + return result; +} + +/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Datei-Knoten sammeln */ +function collectDescendantSelectableFileNodes(node) { + const result = []; + for (const child of (node?.children || [])) { + if (child.type === 'file') { + const assignedJobId = Number(child.jobId); + if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) { + result.push(child); + } + } else if (child.type === 'folder') { + result.push(...collectDescendantSelectableFileNodes(child)); + } + } + return result; +} + +/** Nur "Wurzel"-Selektionen: Pfade, die keinen ausgewählten Elternpfad haben */ +function getRootSelections(paths) { + const set = new Set(paths); + return paths.filter((p) => + !paths.some((other) => other !== p && p.startsWith(other + '/') && set.has(other)) + ); +} + +/** Alle Ordner-Pfade für das Verschieben-Dropdown sammeln */ +function collectFolderPaths(node, result = []) { + if (!node || node.type !== 'folder') return result; + result.push({ label: node.path ? node.path : '/ (Root)', value: node.path || '' }); + for (const child of (node.children || [])) { + if (child.type === 'folder') collectFolderPaths(child, result); + } + return result; +} + +// ── Hauptkomponente ─────────────────────────────────────────────────────────── + +export default function ConverterFileExplorer({ + onSelectionChange, + refreshToken, + selectionResetToken, + navigateToPath, + onAssignmentChanged +}) { + const toastRef = useRef(null); + const explorerRef = useRef(null); + + // Daten + const [tree, setTree] = useState(null); + const [rawDir, setRawDir] = useState(null); + const [loading, setLoading] = useState(false); + + // Navigation + const [currentPath, setCurrentPath] = useState(''); + const [expandedFolders, setExpandedFolders] = useState(() => new Set([''])); + + // Auswahl (Checkbox → Job-Zuweisung) + const [selectedPaths, setSelectedPaths] = useState([]); + // Aktive Zeilen-Auswahl (Klick → Rename/Delete/Move) + const [activePaths, setActivePaths] = useState([]); + const [activeAnchor, setActiveAnchor] = useState(null); + + // Seitenleiste + const [sidebarQuery, setSidebarQuery] = useState(''); + + // Modals + const [activeModal, setActiveModal] = useState(''); + const [newFolderName, setNewFolderName] = useState(''); + const [renameName, setRenameName] = useState(''); + const [moveTarget, setMoveTarget] = useState(''); + const [busy, setBusy] = useState(false); + const [jobUnassignTarget, setJobUnassignTarget] = useState(null); + + // Stabile Ref für onSelectionChange + const onSelectionChangeRef = useRef(onSelectionChange); + useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]); + const onAssignmentChangedRef = useRef(onAssignmentChanged); + useEffect(() => { onAssignmentChangedRef.current = onAssignmentChanged; }, [onAssignmentChanged]); + + // ── Baum laden ───────────────────────────────────────────────────────────── + + const loadTree = useCallback(async () => { + setLoading(true); + try { + const data = await api.converterGetTree(); + setTree(data.tree || null); + setRawDir(data.rawDir || null); + } catch (err) { + console.error('ConverterFileExplorer: tree load error', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadTree(); }, [loadTree, refreshToken]); + + useEffect(() => { + setSelectedPaths([]); + setActivePaths([]); + setActiveAnchor(null); + }, [selectionResetToken]); + + // Auto-Navigation nach Upload: Baum neu laden, dann in Zielordner navigieren + // navigateToPath ist { path: string, ts: number } damit jeder Upload einen neuen Trigger auslöst + useEffect(() => { + if (!navigateToPath?.path) return; + loadTree().then(() => { + navigateTo(navigateToPath.path); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navigateToPath]); + + // Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert + useEffect(() => { + if (!tree) return; + setSelectedPaths((prev) => { + const next = prev.filter((entryPath) => { + const node = getNodeByPath(tree, entryPath); + if (!node) return false; + if (node.type !== 'file') return true; + const assignedJobId = Number(node.jobId); + return !Number.isFinite(assignedJobId) || assignedJobId <= 0; + }); + return next.length === prev.length ? prev : next; + }); + + const rootPaths = getRootSelections(selectedPaths); + const report = []; + for (const p of rootPaths) { + const node = getNodeByPath(tree, p); + if (!node) continue; + if (node.type === 'folder') { + const fileNodes = collectDescendantSelectableFileNodes(node); + if (fileNodes.length > 0) { + for (const fn of fileNodes) { + report.push({ + relPath: fn.path, + entryType: 'file', + detectedMediaType: fn.detectedMediaType || null, + detectedFormat: fn.detectedFormat || null + }); + } + } else { + // Leerer Ordner: als Ordner-Eintrag weitergeben + report.push({ relPath: node.path, entryType: 'directory', detectedMediaType: null, detectedFormat: null }); + } + } else { + report.push({ + relPath: node.path, + entryType: 'file', + detectedMediaType: node.detectedMediaType || null, + detectedFormat: node.detectedFormat || null + }); + } + } + onSelectionChangeRef.current?.(report); + }, [selectedPaths, tree]); + + // Auswahl bleibt erhalten — kein Outside-Click-Handler + + // ── Navigation ───────────────────────────────────────────────────────────── + + const currentNode = getNodeByPath(tree, currentPath); + const currentItems = listChildren(currentNode); + const filteredTree = filterTree(tree, sidebarQuery); + const topLevelFolders = tree ? (tree.children || []).filter((c) => c.type === 'folder') : []; + + function navigateTo(pathStr) { + setCurrentPath(pathStr || ''); + setActivePaths([]); + setActiveAnchor(null); + setExpandedFolders((prev) => new Set(prev).add(pathStr || '')); + } + + function handleGoUp() { + if (!currentPath) return; + const parts = currentPath.split('/').filter(Boolean); + parts.pop(); + navigateTo(parts.join('/')); + } + + function toggleFolder(pathValue) { + const key = pathValue || ''; + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + } + + // ── Auswahl ──────────────────────────────────────────────────────────────── + + /** Gibt true wenn item.path direkt oder als Kind eines ausgewählten Ordners selektiert ist */ + function isSelected(pathValue) { + const p = pathValue || ''; + if (selectedPaths.includes(p)) return true; + return selectedPaths.some((sel) => p.startsWith(sel + '/')); + } + + /** Unbestimmter Zustand: Ordner, von dem nur Teile selektiert sind */ + function isIndeterminate(item) { + if (item.type !== 'folder') return false; + const p = item.path || ''; + if (selectedPaths.includes(p)) return false; + const folderNode = getNodeByPath(tree, p); + const allFiles = collectDescendantFilePaths(folderNode); + return allFiles.length > 0 && allFiles.some((fp) => selectedPaths.includes(fp)); + } + + function handleCheckboxChange(item, checked) { + const p = item.path || ''; + + if (item.type === 'file') { + // Gesperrte Dateien (Job läuft) → keine Aktion möglich + if (isNodeLocked(item)) return; + + const assignedJobId = Number(item.jobId); + const isAssigned = Number.isFinite(assignedJobId) && assignedJobId > 0; + if (isAssigned && !checked) { + setJobUnassignTarget({ + relPath: p, + jobId: Math.trunc(assignedJobId), + jobTitle: String(item.jobTitle || '').trim() || null + }); + setActiveModal('job-unassign'); + return; + } + if (isAssigned) { + return; + } + } + + if (item.type === 'folder') { + const folderNode = getNodeByPath(tree, p); + const descendantFiles = collectDescendantSelectableFilePaths(folderNode); + if (checked) { + if (descendantFiles.length === 0) return; + setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles]))); + } else { + const toRemove = new Set([p, ...descendantFiles]); + setSelectedPaths((prev) => prev.filter((x) => !toRemove.has(x))); + } + } else { + const allFilesInCurrentView = currentItems.filter((i) => i.type === 'file').map((i) => i.path || ''); + if (checked) { + setSelectedPaths((prev) => { + const next = Array.from(new Set([...prev, p])); + // Wenn alle Dateien im aktuellen Ordner gewählt → Ordner auch auswählen + if (currentPath && allFilesInCurrentView.length > 0 && + allFilesInCurrentView.every((fp) => next.includes(fp)) && + !next.includes(currentPath)) { + return [...next, currentPath]; + } + return next; + }); + } else { + setSelectedPaths((prev) => { + const next = prev.filter((x) => x !== p); + // Ordner abwählen wenn eine seiner Dateien abgewählt wird + if (currentPath && next.includes(currentPath)) { + return next.filter((x) => x !== currentPath); + } + return next; + }); + } + } + } + + async function handleRemoveFromJob() { + if (!jobUnassignTarget?.jobId || !jobUnassignTarget?.relPath) return; + setBusy(true); + try { + const result = await api.converterRemoveFileFromJob(jobUnassignTarget.jobId, jobUnassignTarget.relPath); + const removedRelPath = String(result?.removedRelPath || jobUnassignTarget.relPath || '').trim(); + toastRef.current?.show({ + severity: 'success', + summary: 'Aus Job entfernt', + detail: removedRelPath || 'Datei wurde aus dem Job entfernt.', + life: 2800 + }); + setSelectedPaths((prev) => prev.filter((entryPath) => entryPath !== removedRelPath)); + setJobUnassignTarget(null); + setActiveModal(''); + await loadTree(); + onAssignmentChangedRef.current?.(); + } catch (err) { + toastRef.current?.show({ + severity: 'error', + summary: 'Fehler', + detail: err.message || 'Datei konnte nicht aus dem Job entfernt werden.', + life: 4200 + }); + } finally { + setBusy(false); + } + } + + function handleOpen(item) { + if (item.type !== 'folder') return; + navigateTo(item.path || ''); + } + + // ── Datei-Operationen ────────────────────────────────────────────────────── + + async function handleCreateFolder() { + const name = newFolderName.trim(); + if (!name) return; + setBusy(true); + try { + await api.converterCreateFolder(currentPath, name); + toastRef.current?.show({ severity: 'success', summary: 'Ordner erstellt', detail: name, life: 2500 }); + setNewFolderName(''); + setActiveModal(''); + await loadTree(); + } catch (err) { + toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 }); + } finally { setBusy(false); } + } + + async function handleRename() { + if (!activePaths.length) return; + const name = renameName.trim(); + if (!name) return; + setBusy(true); + try { + await api.converterRenameFile(activePaths[0], name); + toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `→ ${name}`, life: 2500 }); + setRenameName(''); + setActiveModal(''); + setActivePaths([]); + await loadTree(); + } catch (err) { + toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 }); + } finally { setBusy(false); } + } + + async function handleDeleteSelected() { + if (!activePaths.length) return; + setBusy(true); + try { + for (const p of activePaths) { + await api.converterDeleteFile(p); + } + toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${activePaths.length} Eintrag/Einträge`, life: 2500 }); + setActivePaths([]); + setSelectedPaths((prev) => prev.filter((x) => !activePaths.includes(x))); + setActiveModal(''); + await loadTree(); + } catch (err) { + toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 }); + } finally { setBusy(false); } + } + + async function handleMoveSelected() { + if (!activePaths.length) return; + setBusy(true); + try { + const isRoot = moveTarget === '' || moveTarget === '__root__'; + await api.converterMoveFile(activePaths[0], isRoot ? '' : moveTarget); + toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 }); + setMoveTarget(''); + setActivePaths([]); + setActiveModal(''); + await loadTree(); + } catch (err) { + toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 }); + } finally { setBusy(false); } + } + + function handleSelectActive() { + const newPaths = []; + for (const p of activePaths) { + const node = getNodeByPath(tree, p); + if (!node || isNodeLocked(node)) continue; + if (node.type === 'folder') { + newPaths.push(p, ...collectDescendantSelectableFilePaths(node)); + } else { + newPaths.push(p); + } + } + setSelectedPaths((prev) => Array.from(new Set([...prev, ...newPaths]))); + } + + function handleRowClick(e, item) { + if (isNodeLocked(item)) return; + const p = item.path || ''; + const items = currentItems; + + if (e.shiftKey && activeAnchor) { + const anchorIdx = items.findIndex((i) => (i.path || '') === activeAnchor); + const clickIdx = items.findIndex((i) => (i.path || '') === p); + if (anchorIdx !== -1 && clickIdx !== -1) { + const from = Math.min(anchorIdx, clickIdx); + const to = Math.max(anchorIdx, clickIdx); + const rangePaths = items.slice(from, to + 1).map((i) => i.path || ''); + if (e.ctrlKey || e.metaKey) { + setActivePaths((prev) => Array.from(new Set([...prev, ...rangePaths]))); + } else { + setActivePaths(rangePaths); + } + } + } else if (e.ctrlKey || e.metaKey) { + setActivePaths((prev) => + prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p] + ); + setActiveAnchor(p); + } else { + setActivePaths([p]); + setActiveAnchor(p); + } + } + + // ── Ordnerbaum (Seitenleiste) ─────────────────────────────────────────────── + + function renderFolderTree(node, depth) { + if (!node || node.type !== 'folder') return null; + const isActive = (node.path || '') === currentPath; + const key = node.path || ''; + const isExpanded = expandedFolders.has(key) || depth === 0; + const childFolders = (node.children || []).filter((c) => c.type === 'folder'); + const hasChildren = childFolders.length > 0; + + const nodeSel = isSelected(key); + const nodeIndet = isIndeterminate(node); + + return ( +
+
handleOpen(node)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') handleOpen(node); }} + > + {hasChildren ? ( +
{ e.stopPropagation(); toggleFolder(node.path || ''); }} + onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); toggleFolder(node.path || ''); } }} + > + +
+ ) : ( +
+ {isExpanded && hasChildren && childFolders.map((child) => renderFolderTree(child, depth + 1))} +
+ ); + } + + // ── Abgeleitete Werte ────────────────────────────────────────────────────── + + const allFolders = tree ? collectFolderPaths(tree) : [{ label: '/ (Root)', value: '' }]; + const breadcrumb = buildBreadcrumb(currentPath); + const rootSelected = getRootSelections(selectedPaths); + // Aktionen basieren auf activePaths (Zeilen-Klick-Auswahl) + const hasLockedActive = activePaths.some((p) => { + const node = tree ? getNodeByPath(tree, p) : null; + return node ? isNodeLocked(node) : false; + }); + const canRename = activePaths.length === 1 && !hasLockedActive; + const canDelete = activePaths.length > 0 && !hasLockedActive; + const canMove = activePaths.length === 1 && !hasLockedActive; + const canSelectActive = activePaths.length > 1; + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( +
+ + + {/* Obere Leiste: rawdir-Pfad + Aktualisieren */} +
+ + {rawDir + ? <>{rawDir} + : Kein Ordner konfiguriert} + +
+ + {loading && !tree ? ( +
+ +
+ ) : !rawDir ? ( +
+ Bitte Converter Raw-Ordner in den Settings konfigurieren. +
+ ) : ( +
+ + {/* Linke Seitenleiste */} +
+
+ setSidebarQuery(e.target.value)} + aria-label="Ordner suchen" + /> +
+
+ {topLevelFolders.length === 0 && !sidebarQuery && ( +
+ Keine Ordner gefunden. +
+ )} + {filteredTree && renderFolderTree(filteredTree, 0)} +
+
+ + {/* Rechter Hauptbereich */} +
+
+ {/* ArrowUp */} + + + {/* Breadcrumb */} +
+ navigateTo('')} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(''); }} + > + raw + + {breadcrumb.map((crumb) => ( + navigateTo(crumb.path)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(crumb.path); }} + > + / {crumb.name} + + ))} +
+ + {/* Toolbar-Aktionen rechts */} +
+ + {canSelectActive && ( + + )} + + + +
+
+ + {/* Dateiliste */} +
+ {/* Header */} +
+ + Name + Typ + Größe + Job +
+ + {/* Zeilen */} + {currentItems.length === 0 ? ( +
+ Leer. +
+ ) : currentItems.map((item) => { + const assignedJobId = Number(item.jobId); + const assigned = item.type === 'file' && Number.isFinite(assignedJobId) && assignedJobId > 0; + const locked = isNodeLocked(item); + const sel = (assigned || isSelected(item.path)) && !locked; + const indet = isIndeterminate(item); + const active = activePaths.includes(item.path || ''); + const jobTitle = String(item.jobTitle || '').trim(); + return ( +
handleRowClick(e, item)} + onDoubleClick={() => item.type === 'folder' && handleOpen(item)} + role="row" + tabIndex={locked ? -1 : 0} + onKeyDown={(e) => { if (locked) return; if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }} + > + + {locked ? ( + + ) : ( + { if (el) el.indeterminate = indet; }} + onChange={(e) => handleCheckboxChange(item, e.target.checked)} + onClick={(e) => e.stopPropagation()} + aria-label={`${item.name} auswählen`} + /> + )} + + + + + + {item.name} + + + {item.type === 'folder' + ? + : (item.detectedMediaType ? mediaTypeBadge(item.detectedMediaType) : Datei)} + + + {formatBytes(item.size)} + + + {locked ? ( + + + #{item.jobId} läuft + + ) : assigned ? ( + + #{item.jobId}{jobTitle ? ` | ${jobTitle}` : ''} + + ) : ( + - + )} + +
+ ); + })} + + {/* Footer: Auswahl-Anzahl */} + {selectedPaths.length > 0 && ( +
+ + + + + {getRootSelections(selectedPaths).length} ausgewählt +
+ )} +
+
+ + {/* Untere Leiste (ganzeBreite) */} +
+ {currentItems.length} Einträge{rawDir ? ` · ${rawDir}` : ''} +
+
+ )} + + {/* ── Dialoge ─────────────────────────────────────────────────────────── */} + + {/* Aus Job entfernen */} + { setActiveModal(''); setJobUnassignTarget(null); }} + style={{ width: '420px' }} + footer={( +
+
+ )} + modal + > +

+ Soll die Datei aus dem Job  + + {jobUnassignTarget?.jobTitle || (jobUnassignTarget?.jobId ? `#${jobUnassignTarget.jobId}` : '-')} + +  entfernt werden? +

+
+ + {/* Neuer Ordner */} + { setActiveModal(''); setNewFolderName(''); }} + style={{ width: '380px' }} + footer={ +
+
+ } + modal + > +
+ + setNewFolderName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFolder(); }} + autoFocus + style={{ width: '100%', marginTop: 6 }} + placeholder={`In: ${currentPath || '/'}`} + /> +
+
+ + {/* Umbenennen */} + { setActiveModal(''); setRenameName(''); }} + style={{ width: '380px' }} + footer={ +
+
+ } + modal + > +
+ + setRenameName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); }} + autoFocus + style={{ width: '100%', marginTop: 6 }} + /> +
+
+ + {/* Löschen */} + setActiveModal('')} + style={{ width: '380px' }} + footer={ +
+
+ } + modal + > +

+ {rootSelected.length === 1 + ? <>{getNodeByPath(tree, rootSelected[0])?.name} wirklich löschen? + : <>{rootSelected.length} Einträge wirklich löschen?} +

+
+ + {/* Verschieben */} + setActiveModal('')} + style={{ width: '420px' }} + footer={ +
+
+ } + modal + > +
+ + { + const src = rootSelected[0] || ''; + return f.value !== src && !f.value.startsWith(src + '/'); + })} + onChange={(e) => setMoveTarget(e.value)} + style={{ width: '100%', marginTop: 6 }} + placeholder="Zielordner auswählen …" + /> +
+
+
+ ); +} diff --git a/frontend/src/components/ConverterJobCard.jsx b/frontend/src/components/ConverterJobCard.jsx new file mode 100644 index 0000000..d40d862 --- /dev/null +++ b/frontend/src/components/ConverterJobCard.jsx @@ -0,0 +1,1579 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from 'primereact/button'; +import { Dialog } from 'primereact/dialog'; +import { Dropdown } from 'primereact/dropdown'; +import { InputNumber } from 'primereact/inputnumber'; +import { InputText } from 'primereact/inputtext'; +import { ProgressBar } from 'primereact/progressbar'; +import { Slider } from 'primereact/slider'; +import { Tag } from 'primereact/tag'; +import { Message } from 'primereact/message'; +import { api } from '../api/client'; +import MetadataSelectionDialog from './MetadataSelectionDialog'; +import CdMetadataDialog from './CdMetadataDialog'; +import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas'; +import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; +import { confirmModal } from '../utils/confirmModal'; + +const VIDEO_OUTPUT_FORMATS = [ + { label: 'MKV', value: 'mkv' }, + { label: 'MP4', value: 'mp4' }, + { label: 'M4V', value: 'm4v' } +]; + +// ── Shared helpers (mirrored from CdRipConfigPanel) ─────────────────────────── + +function normalizeTrackText(value) { + return String(value || '') + .normalize('NFC') + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function isFieldVisible(field, values) { + if (!field.showWhen) return true; + return values[field.showWhen.field] === field.showWhen.value; +} + +function FormatField({ field, value, onChange }) { + if (field.type === 'slider') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, e.value)} + min={field.min} + max={field.max} + step={field.step || 1} + /> +
+ ); + } + if (field.type === 'select') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, e.value)} + /> +
+ ); + } + return null; +} + +function normalizeScriptId(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null; +} + +function normalizeChainId(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null; +} + +function normalizeIdList(values, kind = 'script') { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value); + if (normalized === null) continue; + const key = String(normalized); + if (seen.has(key)) continue; + seen.add(key); + output.push(normalized); + } + return output; +} + +function buildEncodeItemsFromConfig(config, phase) { + const source = config && typeof config === 'object' ? config : {}; + const prefix = phase === 'post' ? 'post' : 'pre'; + const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : []; + const fromExplicit = explicitItems + .map((item) => { + const type = String(item?.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') return null; + const id = type === 'chain' + ? normalizeChainId(item?.id ?? item?.chainId) + : normalizeScriptId(item?.id ?? item?.scriptId); + if (!id) return null; + return { type, id }; + }) + .filter(Boolean); + if (fromExplicit.length > 0) return fromExplicit; + const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script'); + const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain'); + return [ + ...scriptIds.map((id) => ({ type: 'script', id })), + ...chainIds.map((id) => ({ type: 'chain', id })) + ]; +} + + +function normalizeTrackStageStatus(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') return 'done'; + if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') return 'in_progress'; + if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') return 'error'; + return 'pending'; +} + +function trackStatusTagMeta(value) { + const normalized = normalizeTrackStageStatus(value); + if (normalized === 'done') return { label: 'Fertig', severity: 'success' }; + if (normalized === 'in_progress') return { label: 'Läuft', severity: 'info' }; + if (normalized === 'error') return { label: 'Fehler', severity: 'danger' }; + return { label: 'Offen', severity: 'secondary' }; +} + +// ── Helper: derive audio tracks from plan ───────────────────────────────────── + +function deriveAudioTracks(plan) { + if (Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0) { + return plan.inputPaths.map((p, i) => ({ + position: i + 1, + filePath: p, + defaultTitle: p.split('/').pop().replace(/\.[^.]+$/, '') + })); + } + const p = plan?.inputPath || ''; + if (p && !plan?.isFolder) { + return [{ position: 1, filePath: p, defaultTitle: p.split('/').pop().replace(/\.[^.]+$/, '') }]; + } + return []; +} + +function normalizePositiveInt(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function buildVideoMetadataPayload(sourceMetadata, fallbackTitle = '') { + const source = sourceMetadata && typeof sourceMetadata === 'object' ? sourceMetadata : {}; + const normalized = { + title: String(source?.title || fallbackTitle || '').trim() || null, + year: Number.isFinite(Number(source?.year)) ? Math.trunc(Number(source.year)) : null, + imdbId: String(source?.imdbId || '').trim() || null, + poster: String(source?.poster || '').trim() || null + }; + + const metadataProvider = String(source?.metadataProvider || '').trim() || null; + const workflowKind = String(source?.workflowKind || '').trim() || null; + const providerId = String(source?.providerId || '').trim() || null; + const metadataKind = String(source?.metadataKind || '').trim() || null; + const seasonName = String(source?.seasonName || '').trim() || null; + const imdbRating = String(source?.imdbRating || '').trim() || null; + const tmdbId = normalizePositiveInt(source?.tmdbId); + const seasonNumber = normalizePositiveInt(source?.seasonNumber); + const discNumber = normalizePositiveInt(source?.discNumber); + const episodeCount = Number(source?.episodeCount || 0); + const voteAverageRaw = Number(source?.voteAverage || 0); + const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0 + ? Number(voteAverageRaw.toFixed(1)) + : null; + const episodes = Array.isArray(source?.episodes) ? source.episodes : []; + const tmdbDetails = source?.tmdbDetails && typeof source.tmdbDetails === 'object' && !Array.isArray(source.tmdbDetails) + ? source.tmdbDetails + : null; + + if (metadataProvider) normalized.metadataProvider = metadataProvider; + if (workflowKind) normalized.workflowKind = workflowKind; + if (providerId) normalized.providerId = providerId; + if (metadataKind) normalized.metadataKind = metadataKind; + if (tmdbId) normalized.tmdbId = tmdbId; + if (seasonNumber) normalized.seasonNumber = seasonNumber; + if (seasonName) normalized.seasonName = seasonName; + if (discNumber) normalized.discNumber = discNumber; + if (Number.isFinite(episodeCount) && episodeCount > 0) normalized.episodeCount = Math.trunc(episodeCount); + if (episodes.length > 0) normalized.episodes = episodes; + if (voteAverage !== null) normalized.voteAverage = voteAverage; + if (imdbRating) normalized.imdbRating = imdbRating; + if (tmdbDetails) normalized.tmdbDetails = tmdbDetails; + + return normalized; +} + +// ── Status helpers ──────────────────────────────────────────────────────────── + +function mediaTypeBadge(type) { + const map = { + video: { label: 'Video', severity: 'info' }, + audio: { label: 'Audio', severity: 'success' }, + iso: { label: 'ISO', severity: 'warning' } + }; + const m = map[type] || { label: type || '?', severity: 'secondary' }; + return ; +} + +function isActiveStatus(status) { + return ['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK'].includes(String(status || '').toUpperCase()); +} + +function isTerminal(status) { + return ['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase()); +} + +// ── Inline-Konfiguration ────────────────────────────────────────────────────── + +function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsChanged }) { + const converterMediaType = plan?.converterMediaType || 'video'; + const isVideo = converterMediaType === 'video' || converterMediaType === 'iso'; + const isAudio = converterMediaType === 'audio'; + const planMetadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {}; + const planTracks = Array.isArray(plan?.tracks) ? plan.tracks : []; + const planMusicBrainz = plan?.musicBrainz && typeof plan.musicBrainz === 'object' ? plan.musicBrainz : {}; + + // ── Format state ──────────────────────────────────────────────────────────── + const audioFormatValues = new Set(CD_FORMATS.map((e) => e.value)); + const videoFormatValues = new Set(VIDEO_OUTPUT_FORMATS.map((e) => e.value)); + const initialOutputFormat = String(plan?.outputFormat || (isAudio ? 'flac' : 'mkv')).trim().toLowerCase(); + const [outputFormat, setOutputFormat] = useState( + isAudio + ? (audioFormatValues.has(initialOutputFormat) ? initialOutputFormat : 'flac') + : (videoFormatValues.has(initialOutputFormat) ? initialOutputFormat : 'mkv') + ); + const [audioFormatOptions, setAudioFormatOptions] = useState(() => ({ + ...getDefaultFormatOptions(initialOutputFormat), + ...(plan?.audioFormatOptions && typeof plan.audioFormatOptions === 'object' ? plan.audioFormatOptions : {}) + })); + + // Sync format options defaults when format changes + useEffect(() => { + if (!isAudio) return; + setAudioFormatOptions((prev) => ({ + ...getDefaultFormatOptions(outputFormat), + ...prev + })); + }, [outputFormat, isAudio]); + + // ── Video presets ─────────────────────────────────────────────────────────── + const [userPresets, setUserPresets] = useState([]); + const [hbPresets, setHbPresets] = useState([]); + const [selectedUserPreset, setSelectedUserPreset] = useState( + Number.isFinite(Number(plan?.userPreset?.id)) ? Math.trunc(Number(plan.userPreset.id)) : null + ); + const [selectedHbPreset, setSelectedHbPreset] = useState( + String(plan?.userPreset?.handbrakePreset || '').trim() + ); + + // ── Audio tracks ──────────────────────────────────────────────────────────── + const audioTrackInputPathsKey = Array.isArray(plan?.inputPaths) ? plan.inputPaths.join('|') : ''; + const audioTracks = useMemo( + () => deriveAudioTracks(plan), + // eslint-disable-next-line react-hooks/exhaustive-deps + [plan?.inputPath, plan?.isFolder, audioTrackInputPathsKey] + ); + + // ── Audio metadata ────────────────────────────────────────────────────────── + const [albumTitle, setAlbumTitle] = useState( + String(planMetadata?.albumTitle || job.title || job.detected_title || '').trim() + ); + const [albumArtist, setAlbumArtist] = useState(String(planMetadata?.albumArtist || '').trim()); + const [albumYear, setAlbumYear] = useState(() => { + const y = Number(planMetadata?.albumYear); + return Number.isFinite(y) ? Math.trunc(y) : null; + }); + const [coverUrl, setCoverUrl] = useState( + String(planMetadata?.coverUrl || job?.poster_url || '').trim() || null + ); + const [trackFields, setTrackFields] = useState(() => { + const map = {}; + for (const { position, defaultTitle } of audioTracks) { + const savedTrack = planTracks.find((t) => Number(t?.position) === Number(position)) || {}; + map[position] = { + title: String(savedTrack?.title || defaultTitle || '').trim(), + artist: String(savedTrack?.artist || '').trim() + }; + } + return map; + }); + + // ── MusicBrainz ───────────────────────────────────────────────────────────── + const [selectedMb, setSelectedMb] = useState( + planMusicBrainz?.selected && typeof planMusicBrainz.selected === 'object' + ? planMusicBrainz.selected + : null + ); + const [mbQuery] = useState(String(planMusicBrainz?.query || '').trim()); + const [mbResults] = useState(Array.isArray(planMusicBrainz?.results) ? planMusicBrainz.results : []); + + // ── Video metadata ────────────────────────────────────────────────────────── + const [videoMetadata, setVideoMetadata] = useState(() => ({ + ...buildVideoMetadataPayload( + planMetadata, + String(job.title || job.detected_title || '').trim() + ) + })); + + // ── Script / Chain catalog ────────────────────────────────────────────────── + const [scriptCatalog, setScriptCatalog] = useState([]); + const [chainCatalog, setChainCatalog] = useState([]); + const [preItems, setPreItems] = useState(() => buildEncodeItemsFromConfig(plan, 'pre')); + const [postItems, setPostItems] = useState(() => buildEncodeItemsFromConfig(plan, 'post')); + + useEffect(() => { + if (!isAudio) return; + let cancelled = false; + const loadCatalog = async () => { + try { + const [scriptsRes, chainsRes] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]); + if (cancelled) return; + setScriptCatalog( + scriptsRes.status === 'fulfilled' + ? (Array.isArray(scriptsRes.value?.scripts) ? scriptsRes.value.scripts : []).map((s) => ({ id: s.id, name: s.name })) + : [] + ); + setChainCatalog( + chainsRes.status === 'fulfilled' + ? (Array.isArray(chainsRes.value?.chains) ? chainsRes.value.chains : []).map((c) => ({ id: c.id, name: c.name })) + : [] + ); + } catch { + if (!cancelled) { setScriptCatalog([]); setChainCatalog([]); } + } + }; + void loadCatalog(); + return () => { cancelled = true; }; + }, [isAudio]); + + // ── Misc state ────────────────────────────────────────────────────────────── + const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); + const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false); + const [searchDialogBusy, setSearchDialogBusy] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [draftSaving, setDraftSaving] = useState(false); + const [draftError, setDraftError] = useState(null); + const saveTimeoutRef = useRef(null); + const initializedRef = useRef(false); + const latestSavedRef = useRef(null); + + // ── Video presets loader ──────────────────────────────────────────────────── + useEffect(() => { + if (!isVideo) return; + api.getUserPresets?.('video') + .then((d) => setUserPresets(Array.isArray(d?.presets) ? d.presets : [])) + .catch(() => setUserPresets([])); + api.getHandBrakePresets?.() + .then((d) => setHbPresets(Array.isArray(d?.presets) ? d.presets : [])) + .catch(() => setHbPresets([])); + }, [isVideo]); + + // ── Sync trackFields when audioTracks change ──────────────────────────────── + useEffect(() => { + if (!isAudio || audioTracks.length === 0) return; + setTrackFields((prev) => { + const next = {}; + for (const { position, defaultTitle } of audioTracks) { + const savedTrack = planTracks.find((t) => Number(t?.position) === Number(position)) || {}; + const existing = prev[position] || null; + next[position] = { + title: String(existing?.title || savedTrack?.title || defaultTitle || '').trim(), + artist: String(existing?.artist || savedTrack?.artist || '').trim() + }; + } + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(next); + if (prevKeys.length === nextKeys.length) { + const equal = nextKeys.every((key) => ( + String(prev[key]?.title || '') === String(next[key]?.title || '') + && String(prev[key]?.artist || '') === String(next[key]?.artist || '') + )); + if (equal) return prev; + } + return next; + }); + }, [isAudio, audioTracks, planTracks]); + + // ── Draft payload ─────────────────────────────────────────────────────────── + const draftPayload = useMemo(() => { + let userPreset = null; + if (selectedUserPreset) { + const preset = userPresets.find((p) => Number(p?.id) === Number(selectedUserPreset)); + userPreset = preset + ? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' } + : { id: selectedUserPreset }; + } else if (selectedHbPreset) { + userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' }; + } + + const tracks = isAudio + ? audioTracks.map(({ position, defaultTitle }) => { + const f = trackFields[position] || {}; + return { + position, + title: String(f.title || defaultTitle || '').trim() || defaultTitle || `Track ${position}`, + artist: String(f.artist || '').trim() || null + }; + }) + : null; + + const metadata = isAudio + ? { + albumTitle: String(albumTitle || '').trim() || job.detected_title || '', + albumArtist: String(albumArtist || '').trim() || null, + albumYear: Number.isFinite(Number(albumYear)) ? Math.trunc(Number(albumYear)) : null, + mbId: selectedMb?.mbId || null, + coverUrl: coverUrl || null + } + : { + ...buildVideoMetadataPayload( + videoMetadata, + String(job.title || job.detected_title || '').trim() + ) + }; + + return { + converterMediaType, + outputFormat, + userPreset, + audioFormatOptions: isAudio ? audioFormatOptions : null, + metadata, + tracks, + musicBrainz: isAudio + ? { query: mbQuery, selected: selectedMb || null, results: Array.isArray(mbResults) ? mbResults.slice(0, 50) : [] } + : null, + preEncodeItems: isAudio ? preItems : null, + postEncodeItems: isAudio ? postItems : null + }; + }, [ + selectedUserPreset, selectedHbPreset, userPresets, isAudio, audioTracks, trackFields, + albumTitle, albumArtist, albumYear, coverUrl, selectedMb, converterMediaType, outputFormat, + audioFormatOptions, mbQuery, mbResults, job.detected_title, job.title, videoMetadata, + preItems, postItems + ]); + + const draftSerialized = useMemo(() => JSON.stringify(draftPayload), [draftPayload]); + + useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + latestSavedRef.current = draftSerialized; + return; + } + if (busy || draftSerialized === latestSavedRef.current) return; + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = setTimeout(async () => { + setDraftSaving(true); + setDraftError(null); + try { + await api.converterUpdateJobConfig(job.id, draftPayload); + latestSavedRef.current = draftSerialized; + } catch (err) { + setDraftError(err?.message || 'Draft konnte nicht gespeichert werden.'); + } finally { + setDraftSaving(false); + } + }, 650); + return () => { + if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; } + }; + }, [busy, draftPayload, draftSerialized, job.id]); + + // ── Dialog contexts ───────────────────────────────────────────────────────── + const metadataDialogContext = useMemo(() => ({ + jobId: job.id, + detectedTitle: videoMetadata?.title || job.title || job.detected_title || '', + selectedMetadata: { + ...videoMetadata, + title: videoMetadata?.title || job.title || job.detected_title || '', + year: videoMetadata?.year || null, + imdbId: videoMetadata?.imdbId || null, + poster: videoMetadata?.poster || null + }, + metadataCandidates: [] + }), [job.id, job.title, job.detected_title, videoMetadata]); + + const cdMetadataDialogContext = useMemo(() => ({ + jobId: job.id, + tracks: audioTracks.map(({ position, defaultTitle }) => ({ + position, + title: String(trackFields[position]?.title || defaultTitle || '').trim() || `Track ${position}`, + artist: String(trackFields[position]?.artist || '').trim() || null + })) + }), [job.id, audioTracks, trackFields]); + + // ── Dialog handlers ───────────────────────────────────────────────────────── + const handleMetadataSearch = async (query, options = {}) => { + try { + const filterRaw = String(options?.resultFilter || '').trim().toLowerCase(); + const includeMovies = filterRaw !== 'series'; + const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie'; + const [movieResponse, seriesResponse] = await Promise.all([ + includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }), + includeSeries ? api.searchTmdbSeries(query) : Promise.resolve({ results: [] }) + ]); + const movieRows = Array.isArray(movieResponse?.results) + ? movieResponse.results.map((row) => ({ + ...row, + workflowKind: 'film', + metadataKind: 'movie', + resultType: 'movie' + })) + : []; + const seriesRows = Array.isArray(seriesResponse?.results) + ? seriesResponse.results.map((row) => ({ + ...row, + workflowKind: 'series', + metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series', + resultType: 'series' + })) + : []; + return [...movieRows, ...seriesRows]; + } catch { return []; } + }; + + const handleMetadataSubmit = async (payload) => { + setSearchDialogBusy(true); + try { + setVideoMetadata(buildVideoMetadataPayload(payload, String(job.title || job.detected_title || '').trim())); + setMetadataDialogVisible(false); + } finally { + setSearchDialogBusy(false); + } + }; + + const handleMusicBrainzSearchDialog = async (query, options = {}) => { + try { + const response = await api.searchMusicBrainz(query, { + trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null + }); + return Array.isArray(response?.results) ? response.results : []; + } catch { return []; } + }; + + const handleMusicBrainzReleaseFetch = async (mbId) => { + try { + const response = await api.getMusicBrainzRelease(mbId); + return response?.release || null; + } catch { return null; } + }; + + const handleCdMetadataSubmit = async (payload) => { + setSearchDialogBusy(true); + try { + const title = String(payload?.title || '').trim(); + const artist = String(payload?.artist || '').trim(); + const year = Number(payload?.year); + const tracks = Array.isArray(payload?.tracks) ? payload.tracks : []; + if (title) setAlbumTitle(title); + setAlbumArtist(artist); + setAlbumYear(Number.isFinite(year) ? Math.trunc(year) : null); + setSelectedMb(payload?.mbId ? { + mbId: payload.mbId, + title: title || null, + artist: artist || null, + year: Number.isFinite(year) ? Math.trunc(year) : null + } : null); + setCoverUrl(String(payload?.coverUrl || '').trim() || null); + if (tracks.length > 0) { + setTrackFields((prev) => { + const next = { ...prev }; + tracks.forEach((track, index) => { + const position = Number.isFinite(Number(track?.position)) && Number(track.position) > 0 + ? Math.trunc(Number(track.position)) + : (index + 1); + next[position] = { + title: String(track?.title || next[position]?.title || `Track ${position}`).trim(), + artist: String(track?.artist || next[position]?.artist || '').trim() + }; + }); + return next; + }); + } + setCdMetadataDialogVisible(false); + } finally { + setSearchDialogBusy(false); + } + }; + + // ── Track field handlers ──────────────────────────────────────────────────── + const handleTrackField = (position, key, value) => { + setTrackFields((prev) => ({ ...prev, [position]: { ...(prev[position] || {}), [key]: value } })); + }; + + const handleDeleteTrack = async (filePath) => { + try { + await api.converterRemoveInputFromJob(job.id, filePath); + onInputsChanged?.(job.id); + } catch (err) { + // silently ignore — UI will refresh via onInputsChanged + } + }; + + // ── Encode item handlers ──────────────────────────────────────────────────── + const moveItem = (phase, index, direction) => { + const updater = phase === 'post' ? setPostItems : setPreItems; + updater((prev) => { + const list = Array.isArray(prev) ? [...prev] : []; + const from = Number(index); + const to = from + (direction === 'up' ? -1 : 1); + if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) return list; + const [moved] = list.splice(from, 1); + list.splice(to, 0, moved); + return list; + }); + }; + + const addItem = (phase, type) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const updater = phase === 'post' ? setPostItems : setPreItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const selectedIds = new Set( + current + .filter((item) => item?.type === normalizedType) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog; + const candidate = (Array.isArray(catalog) ? catalog : []) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .find((id) => id !== null && !selectedIds.has(String(id))); + if (candidate === undefined || candidate === null) return current; + return [...current, { type: normalizedType, id: candidate }]; + }); + }; + + const changeItem = (phase, index, type, nextId) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId); + if (normalizedId === null) return; + const updater = phase === 'post' ? setPostItems : setPreItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) return current; + const duplicate = current.some((item, itemIndex) => { + if (itemIndex === rowIndex) return false; + if (item?.type !== normalizedType) return false; + const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id); + return existingId !== null && String(existingId) === String(normalizedId); + }); + if (duplicate) return current; + const next = [...current]; + next[rowIndex] = { type: normalizedType, id: normalizedId }; + return next; + }); + }; + + const removeItem = (phase, index) => { + const updater = phase === 'post' ? setPostItems : setPreItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) return current; + return current.filter((_, itemIndex) => itemIndex !== rowIndex); + }); + }; + + // ── Start handler ─────────────────────────────────────────────────────────── + const handleStart = async () => { + setBusy(true); + setError(null); + try { + let userPreset = null; + if (selectedUserPreset) { + const found = userPresets.find((p) => p.id === selectedUserPreset); + if (found) userPreset = { id: found.id, handbrakePreset: found.handbrake_preset || null, extraArgs: found.extra_args || '' }; + } else if (selectedHbPreset) { + userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' }; + } + + const tracks = isAudio + ? audioTracks.map(({ position, defaultTitle }) => { + const f = trackFields[position] || {}; + return { + position, + title: (f.title || '').trim() || defaultTitle, + artist: (f.artist || '').trim() || albumArtist.trim() || null + }; + }) + : undefined; + + const metadata = isAudio + ? { + albumTitle: albumTitle.trim() || job.detected_title || '', + albumArtist: albumArtist.trim() || null, + albumYear: albumYear || null, + mbId: selectedMb?.mbId || null, + coverUrl: coverUrl || null + } + : { + ...buildVideoMetadataPayload( + videoMetadata, + String(job.title || job.detected_title || '').trim() + ) + }; + + await api.startConverterJob(job.id, { + converterMediaType, + outputFormat, + userPreset, + audioFormatOptions: isAudio ? audioFormatOptions : null, + metadata, + tracks, + preEncodeItems: isAudio ? preItems : null, + postEncodeItems: isAudio ? postItems : null + }); + + onStarted?.(job.id); + } catch (err) { + setError(err.message || 'Fehler beim Starten.'); + setBusy(false); + } + }; + + // ── Format fields (audio) ─────────────────────────────────────────────────── + const audioSchema = CD_FORMAT_SCHEMAS[outputFormat] || { fields: [] }; + const visibleAudioFields = audioSchema.fields.filter((f) => isFieldVisible(f, audioFormatOptions)); + + // ── Encode item section renderer ──────────────────────────────────────────── + const renderEncodeSection = (phase, items) => { + const label = phase === 'post' ? 'Post-Rip Ausführungen (optional)' : 'Pre-Rip Ausführungen (optional)'; + const hint = phase === 'post' + ? 'Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.' + : 'Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.'; + + return ( +
+

{label}

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {items.length === 0 ? ( + Keine {phase === 'post' ? 'Post' : 'Pre'}-Rip Ausführungen ausgewählt. + ) : null} + {items.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + items.filter((e, i) => e?.type === 'script' && i !== rowIndex) + .map((e) => normalizeScriptId(e?.id)).filter((id) => id !== null).map((id) => String(id)) + ); + const usedChainIds = new Set( + items.filter((e, i) => e?.type === 'chain' && i !== rowIndex) + .map((e) => normalizeChainId(e?.id)).filter((id) => id !== null).map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((e) => ({ + label: e?.name || `Skript #${e?.id}`, + value: normalizeScriptId(e?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(e?.id))) + })).filter((e) => e.value !== null); + const chainOptions = chainCatalog.map((e) => ({ + label: e?.name || `Kette #${e?.id}`, + value: normalizeChainId(e?.id), + disabled: usedChainIds.has(String(normalizeChainId(e?.id))) + })).filter((e) => e.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeItem(phase, rowIndex, 'script', e.value)} + className="full-width" disabled={busy} + /> + ) : ( + changeItem(phase, rowIndex, 'chain', e.value)} + className="full-width" disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > items.filter((e) => e?.type === 'script').length ? ( +
+ {hint} +
+ ); + }; + + // ── Render ────────────────────────────────────────────────────────────────── + if (isAudio) { + return ( +
+ {error && } + +

Converter Konfiguration

+ + {/* Album-Metadaten */} +
+ Album-Metadaten + {coverUrl && ( +
+
+ {albumTitle +
+
+ )} +
+ setAlbumTitle(e.target.value)} + placeholder="Album" + disabled={busy} + /> + setAlbumArtist(e.target.value)} + placeholder="Interpret" + disabled={busy} + /> + setAlbumYear(e.value || null)} + placeholder="Jahr" + useGrouping={false} + min={1900} max={2100} + disabled={busy} + /> +
+
+ + {/* Ausgabeformat */} +
+ + setOutputFormat(e.value)} + disabled={busy} + /> +
+ + {/* Format-spezifische Felder */} + {visibleAudioFields.map((field) => ( + setAudioFormatOptions((prev) => ({ ...prev, [key]: value }))} + /> + ))} + + {/* Track-Auswahl */} + {audioTracks.length > 0 && ( +
+
+ Tracks ({audioTracks.length}) +
+
+ + + + + + + + + + + + {audioTracks.map(({ position, filePath, defaultTitle }) => { + const f = trackFields[position] || {}; + return ( + + + + + + + + ); + })} + +
NrInterpretTitelLänge
+ {String(position).padStart(2, '0')} + handleTrackField(position, 'artist', e.target.value)} + placeholder={normalizeTrackText(albumArtist) || 'Interpret'} + disabled={busy} + /> + + handleTrackField(position, 'title', e.target.value)} + placeholder={defaultTitle} + disabled={busy} + /> + -
+
+
+ )} + + {plan?.isFolder && audioTracks.length === 0 && ( +

+ Track-Liste wird beim Start aus dem Ordner gelesen. +

+ )} + + {/* Pre / Post Ausführungen */} +
+ {renderEncodeSection('pre', preItems)} + {renderEncodeSection('post', postItems)} +
+ + {/* Aktionen */} +
+
+ + {draftError && ( + {draftError} + )} + {!draftError && ( + + {draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'} + + )} + + setCdMetadataDialogVisible(false)} + onSubmit={handleCdMetadataSubmit} + onSearch={handleMusicBrainzSearchDialog} + onFetchRelease={handleMusicBrainzReleaseFetch} + busy={searchDialogBusy} + /> +
+ ); + } + + // ── Video render (unchanged layout) ───────────────────────────────────────── + return ( +
+ {error && } + + {isVideo && ( +
+
+ )} + +
+
+ +
+ {mediaTypeBadge(converterMediaType)} + {converterMediaType === 'iso' && ( + MakeMKV → HandBrake + )} +
+
+
+ + setOutputFormat(e.value)} + style={{ width: '100%' }} + /> +
+
+ + {isVideo && ( +
+
+ + ({ label: p.name, value: p.id })) + ]} + onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }} + style={{ width: '100%' }} + /> +
+ {!selectedUserPreset && ( +
+ + ({ label: p.category ? `[${p.category}] ${p.name}` : p.name, value: p.name })) + ]} + onChange={(e) => setSelectedHbPreset(e.value)} + filter + placeholder="Preset auswählen …" + style={{ width: '100%' }} + /> +
+ )} +
+ )} + +
+ {draftError ? ( + {draftError} + ) : ( + + {draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'} + + )} +
+ + setMetadataDialogVisible(false)} + onSubmit={handleMetadataSubmit} + onSearch={handleMetadataSearch} + busy={searchDialogBusy} + /> +
+ ); +} + +// ── Job-Karte ───────────────────────────────────────────────────────────────── + +export default function ConverterJobCard({ + job, + jobProgress, + isExpanded, + onExpand, + onCollapse, + onStarted, + onDeleted, + onCancelled, + onOpenDetails, + onInputsChanged +}) { + const [cancelBusy, setCancelBusy] = useState(false); + const [deleteBusy, setDeleteBusy] = useState(false); + const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false); + + const plan = (() => { + try { return JSON.parse(job.encode_plan_json || '{}'); } catch { return {}; } + })(); + + const jobIdLabel = Number.isFinite(Number(job?.id)) ? `Job #${Math.trunc(Number(job.id))}` : 'Job'; + const ripperTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-'; + const customTitle = String(job?.title || '').trim(); + const title = customTitle ? `${ripperTitleId} | ${customTitle}` : ripperTitleId; + const status = job.status || 'UNKNOWN'; + const statusLabel = getStatusLabel(status); + const statusSeverity = getStatusSeverity(status); + const converterMediaType = plan?.converterMediaType; + const isAudio = converterMediaType === 'audio'; + const active = isActiveStatus(status); + const terminal = isTerminal(status); + const isReady = String(status).toUpperCase() === 'READY_TO_START'; + + const liveProgress = jobProgress?.progress ?? null; + const liveEta = jobProgress?.eta || null; + const liveStatusText = jobProgress?.statusText || null; + const progressValue = liveProgress !== null ? Math.trunc(liveProgress) : null; + + // ── Audio details (for active/terminal state) ───────────────────────────── + const audioTracks = isAudio ? deriveAudioTracks(plan) : []; + const planTracks = Array.isArray(plan?.tracks) ? plan.tracks : []; + const planMetadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {}; + const coverUrl = String(planMetadata?.coverUrl || plan?.musicBrainz?.selected?.coverUrl || job?.poster_url || '').trim() || null; + + const albumTitle = String(planMetadata?.albumTitle || job.title || job.detected_title || '').trim() || '-'; + const albumArtist = String(planMetadata?.albumArtist || '').trim() || '-'; + const albumYear = planMetadata?.albumYear || '-'; + const mbId = String(planMetadata?.mbId || '').trim() || '-'; + const outputFormatLabel = (CD_FORMATS.find((f) => f.value === plan?.outputFormat)?.label) || (plan?.outputFormat ? String(plan.outputFormat).toUpperCase() : '-'); + const outputPath = String(job.output_path || '').trim() || '-'; + + // Derive pre/post script names for summary + const preItems = buildEncodeItemsFromConfig(plan, 'pre'); + const postItems = buildEncodeItemsFromConfig(plan, 'post'); + const preNames = [ + ...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((s) => String(s?.name || '').trim()).filter(Boolean) : []), + ...preItems.filter((i) => i.type === 'script').map((i) => `Skript #${i.id}`) + ]; + const postNames = [ + ...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((s) => String(s?.name || '').trim()).filter(Boolean) : []), + ...postItems.filter((i) => i.type === 'script').map((i) => `Skript #${i.id}`) + ]; + const preChainNames = [ + ...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((c) => String(c?.name || '').trim()).filter(Boolean) : []), + ...preItems.filter((i) => i.type === 'chain').map((i) => `Kette #${i.id}`) + ]; + const postChainNames = [ + ...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((c) => String(c?.name || '').trim()).filter(Boolean) : []), + ...postItems.filter((i) => i.type === 'chain').map((i) => `Kette #${i.id}`) + ]; + + const isFinishedOk = terminal && !['ERROR', 'CANCELLED'].includes(String(status).toUpperCase()); + const completedEncodeCount = (() => { + if (isFinishedOk) return audioTracks.length; + if (!active || audioTracks.length === 0) return 0; + // "Audio: N/M — filename" is emitted by the backend at the START of track N. + // N-1 tracks are done, track N is in progress. + const statusMatch = liveStatusText?.match(/^Audio:\s*(\d+)\s*\//); + if (statusMatch) { + const pos = parseInt(statusMatch[1], 10); + return Math.max(0, Math.min(audioTracks.length, pos - 1)); + } + // Fallback: use progress percentage (Math.round avoids floor precision loss) + if (progressValue !== null) { + return Math.min(audioTracks.length, Math.round((progressValue / 100) * audioTracks.length)); + } + return 0; + })(); + + // ── Action handlers ─────────────────────────────────────────────────────── + const handleCancel = async () => { + const statusUpper = String(status || '').toUpperCase(); + const confirmText = statusUpper === 'READY_TO_START' + ? `${jobIdLabel} wirklich abbrechen?\nDer Job wird auf "Abgebrochen" gesetzt.` + : `${jobIdLabel} wirklich abbrechen?`; + const confirmed = await confirmModal({ + header: 'Job abbrechen', + message: confirmText, + acceptLabel: 'Abbrechen', + rejectLabel: 'Zurück', + danger: true + }); + if (!confirmed) return; + setCancelBusy(true); + try { + await api.cancelConverterJob(job.id); + onCancelled?.(job.id); + } catch (err) { + console.error('Cancel error:', err); + } finally { + setCancelBusy(false); + } + }; + + const handleDelete = () => setDeleteConfirmVisible(true); + + const handleDeleteConfirmed = async () => { + setDeleteConfirmVisible(false); + setDeleteBusy(true); + try { + await api.deleteConverterJob(job.id); + onDeleted?.(job.id); + } catch (err) { + console.error('Delete error:', err); + window.alert(`Fehler beim Löschen:\n${err?.message || 'Unbekannter Fehler'}`); + } finally { + setDeleteBusy(false); + } + }; + + // ── Eingeklappt ─────────────────────────────────────────────────────────── + if (!isExpanded) { + const fileCount = Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0 + ? plan.inputPaths.length + : null; + return ( +