diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..4a6d001 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Bash(ls /home/michael/ripster/*.sh)", + "Bash(ls /home/michael/ripster/backend/*.sh)", + "Bash(systemctl list-units --type=service)", + "Bash(pip install -q -r requirements-docs.txt)", + "Bash(mkdocs build --strict)", + "Read(//mnt/external/media/**)", + "WebFetch(domain:www.makemkv.com)", + "Bash(node --check backend/src/services/pipelineService.js)", + "Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)", + "Bash(find /home/michael/ripster -name *.db)", + "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, category, label, type, default_value, options_json FROM settings_schema WHERE category=''Laufwerk'' ORDER BY order_index\")", + "Bash(node --check /home/michael/ripster/backend/src/services/diskDetectionService.js)", + "Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)", + "Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)", + "Bash(node --check /home/michael/ripster/backend/src/db/database.js)", + "Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)", + "Bash(sqlite3 /home/michael/ripster/backend/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")", + "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")", + "Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e\\) for e in d.get\\(''''blockdevices'''',[]\\)]\")", + "Bash(lsblk -o NAME,TYPE,MODEL)", + "Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)", + "Bash(grep -n \"inferMediaProfile\" /home/michael/ripster/backend/src/services/*.js)", + "Bash(grep -rn \"PROCESS_LOG\\\\|LOG_UPDATE\\\\|processLog\" /home/michael/ripster/backend/src --include=*.js)", + "Bash(grep -n \"buildHandBrakeConfig\" /home/michael/ripster/backend/src/services/*.js)", + "Bash(grep -n \"class.*Plugin\\\\|get id\\(\\)\\\\|get name\\(\\)\" /home/michael/ripster/backend/src/plugins/*.js)", + "Bash(/home/michael/ripster/db/schema.sql:*)", + "Read(//home/michael/**)", + "Bash(find /home/michael/ripster/AddOns/klangkiste -name *.jsx -o -name *.js -o -name *.vue)", + "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, label, order_index FROM settings_schema WHERE key LIKE ''converter%'' ORDER BY order_index\")", + "Bash(find /home/michael/ripster/backend -name *.db)", + "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT count\\(*\\) FROM settings_schema\")", + "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key FROM settings_schema WHERE key LIKE ''%converter%''\")", + "Bash(grep -n \"renderMediaTree\\\\|renderMediaContent\\\\|activeSection === ''media''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)", + "Bash(grep -n \"activeModal === ''new-folder''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)", + "Bash(sqlite3 /home/michael/ripster_data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")", + "Bash(sqlite3 /opt/ripster_data/ripster.db \"SELECT id, status, last_state FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")", + "Read(//opt/**)", + "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path, substr\\(encode_plan_json,1,500\\) as plan FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")", + "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state FROM jobs ORDER BY id DESC LIMIT 10;\")", + "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE title LIKE ''%Kill%'' OR id IN \\(69, 212, 213\\) ORDER BY id DESC;\")", + "Bash(find /home/michael/ripster -name *.log -newer /home/michael/ripster/debug/markus.log)", + "Bash(pm2 logs:*)", + "Bash(journalctl -u ripster --no-pager -n 50)", + "Bash(pm2 list:*)", + "Bash(journalctl -u ripster-smoketest-http --no-pager -n 40)" + ] + } +} 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/Bildschirmfoto 2026-04-12 um 20.43.33.png b/Bildschirmfoto 2026-04-12 um 20.43.33.png new file mode 100644 index 0000000..a770fac Binary files /dev/null and b/Bildschirmfoto 2026-04-12 um 20.43.33.png differ diff --git a/Bildschirmfoto 2026-04-12 um 20.45.16.png b/Bildschirmfoto 2026-04-12 um 20.45.16.png new file mode 100644 index 0000000..5d20187 Binary files /dev/null and b/Bildschirmfoto 2026-04-12 um 20.45.16.png differ diff --git a/Bildschirmfoto 2026-04-12 um 21.03.46.png b/Bildschirmfoto 2026-04-12 um 21.03.46.png new file mode 100644 index 0000000..098f1b9 Binary files /dev/null and b/Bildschirmfoto 2026-04-12 um 21.03.46.png differ diff --git a/Bildschirmfoto 2026-04-12 um 21.11.19.png b/Bildschirmfoto 2026-04-12 um 21.11.19.png new file mode 100644 index 0000000..a8143aa Binary files /dev/null and b/Bildschirmfoto 2026-04-12 um 21.11.19.png differ diff --git a/README.md b/README.md index c56094a..3607cc8 100644 --- a/README.md +++ b/README.md @@ -1 +1,257 @@ -# ripster +# Ripster + +Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Plugin-basierte Architektur mit MakeMKV + HandBrake + FFmpeg, inklusive Metadaten-Auswahl, Track-Review, Queue, Skripten/Ketten und Job-Historie. + +--- + +## Was Ripster kann + +### Disc-Ripping & Encoding +- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket) +- Medienprofil-Erkennung (Blu-ray/DVD/CD/Sonstiges) aus Device-/Filesystem-Heuristik +- Metadaten-Suche und Zuordnung über OMDb +- MakeMKV-Analyse und Rip (`mkv` oder `backup`) mit profilspezifischen Settings +- HandBrake-Review und Encoding mit Track-Auswahl, User-Presets, Extra-Args +- **Audio-CD-Ripping** mit cdparanoia + Encoding nach FLAC/MP3/Opus/Vorbis (experimentell) + +### Audiobook-Verarbeitung +- **AAX/Audible-Dateien** verarbeiten (Activation Bytes, DRM-Handling) +- FFmpeg-basiertes Kapitel-Splitting +- Ausgabeformate: M4B, MP3, FLAC mit Metadaten +- MusicBrainz-Metadaten-Lookup + +### Datei-Converter +- **Generische Audio/Video-Dateien** konvertieren (MKV, MP4, FLAC, MP3, u. v. m.) +- Datei-Explorer mit Upload, Umbenennen, Verschieben, Löschen +- Automatischer Verzeichnis-Scan (Polling konfigurierbar) + +### Automatisierung & Verwaltung +- Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten) +- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`) +- Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung) +- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Ripper +- Download-Queue: Ausgabedateien als ZIP herunterladen +- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import +- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Ripper + +## Tech-Stack + +- Backend: Node.js, Express, SQLite, WebSocket (`ws`) – **Plugin-Architektur** +- Frontend: React, Vite, PrimeReact +- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`, `ffmpeg`/`ffprobe`, `cdparanoia` + +## Dokumentation + +- Ausführliche Dokumentation: https://mboehmlaender.github.io/ripster/ + +## Voraussetzungen + +- Debian 11/12 oder Ubuntu 22.04/24.04 +- root-Rechte + Internetzugang +- optisches Laufwerk (oder gemountete Quelle) für Disc-Ripping +- Netzwerk-Zugang für Audiobook-Upload und Datei-Converter optional + +## Schnellstart (`install.sh`) + +Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich): + +```bash +wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh +sudo bash install.sh +``` + +Alternativ direkt per Pipe: + +```bash +curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash +``` + +`install.sh` übernimmt u. a.: + +- Node.js 20 (falls nötig) +- Basistools inkl. `mediainfo`, `ffmpeg`, `ffprobe` +- CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`) +- MakeMKV +- HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding) +- nginx +- Repository-Checkout, npm-Install, Frontend-Build, systemd-Service (`ripster-backend`) + +Danach ist Ripster unter `http://` erreichbar. + +Wichtige Optionen: + +```bash +sudo bash install.sh --branch dev # Branch wählen (Default im Skript: dev) +sudo bash install.sh --dir /opt/ripster # Installationspfad +sudo bash install.sh --user ripster # Service-User +sudo bash install.sh --port 3001 # Backend-Port +sudo bash install.sh --host 192.168.1.10 # Host/IP für nginx/CORS +sudo bash install.sh --no-makemkv # MakeMKV überspringen +sudo bash install.sh --no-handbrake # HandBrake überspringen +sudo bash install.sh --no-nginx # nginx-Setup überspringen +sudo bash install.sh --reinstall # Update (Daten bleiben erhalten) +sudo bash install.sh --help # Hilfe anzeigen +``` + +## Konfiguration + +### UI-Settings (empfohlen) + +Die meisten Einstellungen werden in der App unter `Settings` gepflegt und in SQLite gespeichert: + +- Pfade: `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` (jeweils mit Blu-ray/DVD/Audiobook-Varianten) +- Pfade Converter: `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)` +- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`, `FFmpeg Kommando`, `FFprobe Kommando` +- Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat) +- Audiobook: Output-Template, RAW-Template +- Converter: erlaubte Endungen, Auto-Scan-Intervall, Output-Templates +- Queue/Monitoring: `Parallele Jobs`, `Hardware Monitoring aktiviert`, `Hardware Monitoring Intervall (ms)` +- Benachrichtigungen: PushOver + +### Umgebungsvariablen + +Backend (`backend/src/config.js`): + +- `PORT` (Default: `3001`) +- `DB_PATH` (Default: `backend/data/ripster.db`) +- `LOG_DIR` (Default: `backend/logs`) +- `CORS_ORIGIN` (Default: `*`) +- `LOG_LEVEL` (`debug|info|warn|error`, Default: `info`) + +Frontend (Vite): + +- `VITE_API_BASE` (Default: `/api`) +- `VITE_WS_URL` (optional, überschreibt automatische WS-URL) +- optional für Remote-Dev: `VITE_PUBLIC_ORIGIN`, `VITE_ALLOWED_HOSTS`, `VITE_HMR_PROTOCOL`, `VITE_HMR_HOST`, `VITE_HMR_CLIENT_PORT` + +## Logs und Daten + +Log-Ziel ist primär der in den Settings gepflegte `log_dir`. + +- Backend-Logs: `/backend/backend-latest.log` und Tagesdateien +- Job-Logs: `/job-.process.log` +- DB: `backend/data/ripster.db` + +Hinweis: Beim DB-Init wird das Schema geprüft und fehlende Elemente werden migriert. + +## Projektstruktur + +```text +ripster/ + backend/ + src/ + plugins/ # Plugin-System (BluRay, DVD, CD, Audiobook, Converter) + routes/ + services/ + db/ + utils/ + frontend/ + src/ + pages/ # Ripper, Settings, History, Converter, Downloads, Database + components/ + api/ + db/schema.sql + start.sh + install.sh + install-dev.sh +``` + +## API-Überblick + +**Health** +- `GET /api/health` + +**Pipeline** +- `GET /api/pipeline/state` +- `POST /api/pipeline/analyze` +- `POST /api/pipeline/rescan-disc` +- `POST /api/pipeline/select-metadata` +- `POST /api/pipeline/start/:jobId` +- `POST /api/pipeline/confirm-encode/:jobId` +- `POST /api/pipeline/cancel` +- `POST /api/pipeline/retry/:jobId` +- `POST /api/pipeline/reencode/:jobId` +- `POST /api/pipeline/restart-review/:jobId` +- `POST /api/pipeline/restart-encode/:jobId` +- `POST /api/pipeline/resume-ready/:jobId` +- `GET /api/pipeline/queue` +- `POST /api/pipeline/queue/reorder` +- `POST /api/pipeline/queue/entry` +- `DELETE /api/pipeline/queue/entry/:entryId` + +**Converter** +- `GET /api/converter/tree` +- `GET /api/converter/browse` +- `POST /api/converter/scan` +- `POST /api/converter/create-jobs` +- `POST /api/converter/upload` +- `POST /api/converter/jobs/from-selection` +- `GET /api/converter/jobs` +- `GET /api/converter/jobs/:jobId` +- `POST /api/converter/jobs/:jobId/start` +- `POST /api/converter/jobs/:jobId/cancel` +- `DELETE /api/converter/jobs/:jobId` +- `DELETE /api/converter/files` +- `POST /api/converter/files/rename` +- `POST /api/converter/files/move` +- `POST /api/converter/files/folder` + +**Downloads** +- `GET /api/downloads` +- `GET /api/downloads/summary` +- `POST /api/downloads/history/:jobId` +- `GET /api/downloads/:id/file` +- `DELETE /api/downloads/:id` + +**History** +- `GET /api/history` +- `GET /api/history/:id` +- `GET /api/history/database` +- `GET /api/history/orphan-raw` +- `POST /api/history/orphan-raw/import` +- `POST /api/history/:id/omdb/assign` +- `POST /api/history/:id/delete-files` +- `POST /api/history/:id/delete` + +**Settings** +- `GET /api/settings` +- `PUT /api/settings/:key` +- `PUT /api/settings` +- `GET/POST/PUT/DELETE /api/settings/scripts...` +- `GET/POST/PUT/DELETE /api/settings/script-chains...` +- `GET/POST/PUT/DELETE /api/settings/user-presets...` +- `POST /api/settings/pushover/test` + +**Cron-Jobs** +- `GET /api/crons` +- `POST /api/crons` +- `GET /api/crons/:id` +- `PUT /api/crons/:id` +- `DELETE /api/crons/:id` +- `GET /api/crons/:id/logs` +- `POST /api/crons/:id/run` +- `POST /api/crons/validate-expression` + +**Runtime-Aktivitäten** +- `GET /api/activities` +- `POST /api/activities/:id/cancel` +- `POST /api/activities/:id/next-step` +- `POST /api/activities/clear-recent` + +## Troubleshooting + +- WebSocket verbindet nicht: + - prüfen, ob Frontend über Vite-Proxy läuft (`/ws` -> Backend) + - bei Reverse-Proxy Upgrade-Header für `/ws` setzen +- Keine Disc erkannt: + - in den Settings `Laufwerksmodus` auf `Explizites Device` stellen und `Device Pfad` setzen (z. B. `/dev/sr0`) +- HandBrake/MakeMKV Fehler: + - CLI-Binaries im `PATH` prüfen + - Preset-Name mit `HandBrakeCLI -z` prüfen +- Startfehler wegen Schema: + - `db/schema.sql` vorhanden halten + +## Sicherheit + +- Keine echten Tokens/Passwörter ins Repository committen. +- Lokale Secrets in `.env` oder in Settings pflegen, aber nicht versionieren. 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..c0a8da3 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3526 @@ +{ + "name": "ripster-backend", + "version": "0.13.1-4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ripster-backend", + "version": "0.13.1-4", + "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..99facb9 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,23 @@ +{ + "name": "ripster-backend", + "version": "0.13.1-4", + "private": true, + "type": "commonjs", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "archiver": "^7.0.1", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "multer": "^2.1.1", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7", + "ws": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } +} diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..8cf14e0 --- /dev/null +++ b/backend/src/config.js @@ -0,0 +1,34 @@ +const path = require('path'); + +const rootDir = path.resolve(__dirname, '..'); +const rawDbPath = process.env.DB_PATH || path.join(rootDir, 'data', 'ripster.db'); +const rawLogDir = process.env.LOG_DIR || path.join(rootDir, 'logs'); +const resolvedDbPath = path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath); +const dataDir = path.dirname(resolvedDbPath); + +function resolveOutputPath(envValue, ...subParts) { + const raw = String(envValue || '').trim(); + if (raw) { + return path.isAbsolute(raw) ? raw : path.resolve(rootDir, raw); + } + return path.join(dataDir, ...subParts); +} + +module.exports = { + port: process.env.PORT ? Number(process.env.PORT) : 3001, + dbPath: resolvedDbPath, + dataDir, + corsOrigin: process.env.CORS_ORIGIN || '*', + logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir), + logLevel: process.env.LOG_LEVEL || 'info', + defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'), + defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'), + defaultSeriesDir: resolveOutputPath(process.env.DEFAULT_SERIES_DIR, 'output', 'series'), + defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'), + defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'), + defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'), + defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads'), + defaultConverterRawDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_RAW_DIR, 'output', 'converter-raw'), + defaultConverterMovieDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_MOVIE_DIR, 'output', 'converted-movies'), + defaultConverterAudioDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_AUDIO_DIR, 'output', 'converted-audio') +}; diff --git a/backend/src/db/database.js b/backend/src/db/database.js new file mode 100644 index 0000000..244adec --- /dev/null +++ b/backend/src/db/database.js @@ -0,0 +1,1276 @@ +const fs = require('fs'); +const path = require('path'); +const sqlite3 = require('sqlite3'); +const { open } = require('sqlite'); +const { dbPath } = require('../config'); +const logger = require('../services/logger').child('DB'); +const { errorToMeta } = require('../utils/errorMeta'); +const { setLogRootDir, getJobLogDir } = require('../services/logPathService'); + +const schemaFilePath = path.resolve(__dirname, '../../../db/schema.sql'); +const LEGACY_PROFILE_SETTING_MIGRATIONS = [ + { + legacyKey: 'mediainfo_extra_args', + profileKeys: ['mediainfo_extra_args_bluray', 'mediainfo_extra_args_dvd'] + }, + { + legacyKey: 'makemkv_rip_mode', + profileKeys: ['makemkv_rip_mode_bluray', 'makemkv_rip_mode_dvd'] + }, + { + legacyKey: 'makemkv_analyze_extra_args', + profileKeys: ['makemkv_analyze_extra_args_bluray', 'makemkv_analyze_extra_args_dvd'] + }, + { + legacyKey: 'makemkv_rip_extra_args', + profileKeys: ['makemkv_rip_extra_args_bluray', 'makemkv_rip_extra_args_dvd'] + }, + { + legacyKey: 'handbrake_preset', + profileKeys: ['handbrake_preset_bluray', 'handbrake_preset_dvd'] + }, + { + legacyKey: 'handbrake_extra_args', + profileKeys: ['handbrake_extra_args_bluray', 'handbrake_extra_args_dvd'] + }, + { + legacyKey: 'output_extension', + profileKeys: ['output_extension_bluray', 'output_extension_dvd'] + }, + { + legacyKey: 'output_template', + profileKeys: ['output_template_bluray', 'output_template_dvd'] + } +]; +const INSTALL_PATH_SETTING_DEFAULTS = [ + { + key: 'log_dir', + pathParts: ['logs'], + legacyDefaults: ['data/logs', './data/logs'] + } +]; + +let dbInstance; + +function nowFileStamp() { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +function isSqliteCorruptionError(error) { + if (!error) { + return false; + } + + const code = String(error.code || '').toUpperCase(); + const msg = String(error.message || '').toLowerCase(); + + return ( + code === 'SQLITE_CORRUPT' || + msg.includes('database disk image is malformed') || + msg.includes('file is not a database') + ); +} + +function moveIfExists(sourcePath, targetPath) { + if (!fs.existsSync(sourcePath)) { + return false; + } + fs.renameSync(sourcePath, targetPath); + return true; +} + +function quarantineCorruptDatabaseFiles() { + const dir = path.dirname(dbPath); + const base = path.basename(dbPath); + const stamp = nowFileStamp(); + const archiveDir = path.join(dir, 'corrupt-backups'); + + fs.mkdirSync(archiveDir, { recursive: true }); + + const moved = []; + const candidates = [ + dbPath, + `${dbPath}-wal`, + `${dbPath}-shm` + ]; + + for (const sourcePath of candidates) { + const fileName = path.basename(sourcePath); + const targetPath = path.join(archiveDir, `${fileName}.${stamp}.corrupt`); + if (moveIfExists(sourcePath, targetPath)) { + moved.push({ + from: sourcePath, + to: targetPath + }); + } + } + + logger.warn('recovery:quarantine-complete', { + dbPath, + base, + movedCount: moved.length, + moved + }); +} + +function quoteIdentifier(identifier) { + return `"${String(identifier || '').replace(/"/g, '""')}"`; +} + +function normalizeSqlType(value) { + return String(value || '').trim().replace(/\s+/g, ' ').toUpperCase(); +} + +function normalizeDefault(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value).trim().replace(/\s+/g, ' ').toUpperCase(); +} + +function sameTableShape(current = [], desired = []) { + if (current.length !== desired.length) { + return false; + } + for (let i = 0; i < current.length; i += 1) { + const left = current[i]; + const right = desired[i]; + if (!left || !right) { + return false; + } + if (String(left.name || '') !== String(right.name || '')) { + return false; + } + if (normalizeSqlType(left.type) !== normalizeSqlType(right.type)) { + return false; + } + if (Number(left.notnull || 0) !== Number(right.notnull || 0)) { + return false; + } + if (Number(left.pk || 0) !== Number(right.pk || 0)) { + return false; + } + if (normalizeDefault(left.dflt_value) !== normalizeDefault(right.dflt_value)) { + return false; + } + } + return true; +} + +function sameForeignKeys(current = [], desired = []) { + if (current.length !== desired.length) { + return false; + } + for (let i = 0; i < current.length; i += 1) { + const left = current[i]; + const right = desired[i]; + if (!left || !right) { + return false; + } + if (Number(left.id || 0) !== Number(right.id || 0)) { + return false; + } + if (Number(left.seq || 0) !== Number(right.seq || 0)) { + return false; + } + if (String(left.table || '') !== String(right.table || '')) { + return false; + } + if (String(left.from || '') !== String(right.from || '')) { + return false; + } + if (String(left.to || '') !== String(right.to || '')) { + return false; + } + if (String(left.on_update || '') !== String(right.on_update || '')) { + return false; + } + if (String(left.on_delete || '') !== String(right.on_delete || '')) { + return false; + } + if (String(left.match || '') !== String(right.match || '')) { + return false; + } + } + return true; +} + +async function tableExists(db, tableName) { + const row = await db.get( + `SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`, + [tableName] + ); + return Boolean(row); +} + +async function getTableInfo(db, tableName) { + return db.all(`PRAGMA table_info(${quoteIdentifier(tableName)})`); +} + +async function getForeignKeyInfo(db, tableName) { + return db.all(`PRAGMA foreign_key_list(${quoteIdentifier(tableName)})`); +} + +async function readConfiguredLogDirSetting(db) { + const hasSchemaTable = await tableExists(db, 'settings_schema'); + const hasValuesTable = await tableExists(db, 'settings_values'); + if (!hasSchemaTable || !hasValuesTable) { + return null; + } + + try { + const row = await db.get( + ` + SELECT + COALESCE(v.value, s.default_value, '') AS value + FROM settings_schema s + LEFT JOIN settings_values v ON v.key = s.key + WHERE s.key = ? + LIMIT 1 + `, + ['log_dir'] + ); + const value = String(row?.value || '').trim(); + return value || null; + } catch (error) { + logger.warn('log-root:read-setting-failed', { + error: error?.message || String(error) + }); + return null; + } +} + +async function configureRuntimeLogRootFromSettings(db, options = {}) { + const ensure = Boolean(options.ensure); + const configured = await readConfiguredLogDirSetting(db); + let resolved = setLogRootDir(configured); + if (ensure) { + try { + fs.mkdirSync(resolved, { recursive: true }); + } catch (error) { + const fallbackResolved = setLogRootDir(null); + try { + fs.mkdirSync(fallbackResolved, { recursive: true }); + } catch (_fallbackError) { + // ignored: logger itself is hardened and may still write to console only + } + logger.warn('log-root:ensure-failed', { + configured: configured || null, + resolved, + fallbackResolved, + error: error?.message || String(error) + }); + resolved = fallbackResolved; + } + } + return { + configured, + resolved + }; +} + +async function loadSchemaModel() { + if (!fs.existsSync(schemaFilePath)) { + const error = new Error(`Schema-Datei fehlt: ${schemaFilePath}`); + error.code = 'SCHEMA_FILE_MISSING'; + throw error; + } + + const schemaSql = fs.readFileSync(schemaFilePath, 'utf-8'); + const memDb = await open({ + filename: ':memory:', + driver: sqlite3.Database + }); + + try { + await memDb.exec(schemaSql); + const tables = await memDb.all(` + SELECT name, sql + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ORDER BY rowid ASC + `); + const indexes = await memDb.all(` + SELECT name, tbl_name AS tableName, sql + FROM sqlite_master + WHERE type = 'index' + AND name NOT LIKE 'sqlite_%' + AND sql IS NOT NULL + ORDER BY rowid ASC + `); + const tableInfos = {}; + const tableForeignKeys = {}; + for (const table of tables) { + tableInfos[table.name] = await getTableInfo(memDb, table.name); + tableForeignKeys[table.name] = await getForeignKeyInfo(memDb, table.name); + } + + return { + schemaSql, + tables, + indexes, + tableInfos, + tableForeignKeys + }; + } finally { + await memDb.close(); + } +} + +async function rebuildTable(db, tableName, createSql) { + const oldName = `${tableName}__old_${Date.now()}`; + const tableNameQuoted = quoteIdentifier(tableName); + const oldNameQuoted = quoteIdentifier(oldName); + const beforeInfo = await getTableInfo(db, tableName); + + await db.exec(`ALTER TABLE ${tableNameQuoted} RENAME TO ${oldNameQuoted}`); + await db.exec(createSql); + + const afterInfo = await getTableInfo(db, tableName); + const beforeColumns = new Set(beforeInfo.map((column) => String(column.name))); + const commonColumns = afterInfo + .map((column) => String(column.name)) + .filter((name) => beforeColumns.has(name)); + + if (commonColumns.length > 0) { + const columnList = commonColumns.map((name) => quoteIdentifier(name)).join(', '); + await db.exec(` + INSERT INTO ${tableNameQuoted} (${columnList}) + SELECT ${columnList} + FROM ${oldNameQuoted} + `); + } + + await db.exec(`DROP TABLE ${oldNameQuoted}`); +} + +async function syncSchemaToModel(db, model) { + const desiredTables = Array.isArray(model?.tables) ? model.tables : []; + const desiredIndexes = Array.isArray(model?.indexes) ? model.indexes : []; + const desiredTableInfo = model?.tableInfos && typeof model.tableInfos === 'object' + ? model.tableInfos + : {}; + const desiredTableForeignKeys = model?.tableForeignKeys && typeof model.tableForeignKeys === 'object' + ? model.tableForeignKeys + : {}; + + const currentTables = await db.all(` + SELECT name, sql + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ORDER BY rowid ASC + `); + const currentByName = new Map(currentTables.map((table) => [table.name, table])); + const desiredTableNameSet = new Set(desiredTables.map((table) => table.name)); + + for (const table of desiredTables) { + const tableName = String(table.name || ''); + const createSql = String(table.sql || '').trim(); + if (!tableName || !createSql) { + continue; + } + + if (!currentByName.has(tableName)) { + await db.exec(createSql); + logger.info('schema:create-table', { table: tableName }); + continue; + } + + const currentInfo = await getTableInfo(db, tableName); + const wantedInfo = Array.isArray(desiredTableInfo[tableName]) ? desiredTableInfo[tableName] : []; + const currentFks = await getForeignKeyInfo(db, tableName); + const wantedFks = Array.isArray(desiredTableForeignKeys[tableName]) ? desiredTableForeignKeys[tableName] : []; + const shapeMatches = sameTableShape(currentInfo, wantedInfo); + const foreignKeysMatch = sameForeignKeys(currentFks, wantedFks); + if (!shapeMatches || !foreignKeysMatch) { + await rebuildTable(db, tableName, createSql); + logger.warn('schema:rebuild-table', { + table: tableName, + reason: !shapeMatches ? 'shape-mismatch' : 'foreign-key-mismatch' + }); + } + } + + for (const table of currentTables) { + if (desiredTableNameSet.has(table.name)) { + continue; + } + await db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(table.name)}`); + logger.warn('schema:drop-table', { table: table.name }); + } + + const currentIndexes = await db.all(` + SELECT name, tbl_name AS tableName, sql + FROM sqlite_master + WHERE type = 'index' + AND name NOT LIKE 'sqlite_%' + AND sql IS NOT NULL + ORDER BY rowid ASC + `); + const desiredIndexNameSet = new Set(desiredIndexes.map((index) => index.name)); + + for (const index of currentIndexes) { + if (desiredIndexNameSet.has(index.name)) { + continue; + } + await db.exec(`DROP INDEX IF EXISTS ${quoteIdentifier(index.name)}`); + logger.warn('schema:drop-index', { index: index.name, table: index.tableName }); + } + + for (const index of desiredIndexes) { + let sql = String(index.sql || '').trim(); + if (!sql) { + continue; + } + if (/^CREATE\s+UNIQUE\s+INDEX\s+/i.test(sql)) { + sql = sql.replace(/^CREATE\s+UNIQUE\s+INDEX\s+/i, 'CREATE UNIQUE INDEX IF NOT EXISTS '); + } else if (/^CREATE\s+INDEX\s+/i.test(sql)) { + sql = sql.replace(/^CREATE\s+INDEX\s+/i, 'CREATE INDEX IF NOT EXISTS '); + } + await db.exec(sql); + } +} + +async function exportLegacyJobLogsToFiles(db) { + const hasJobLogsTable = await tableExists(db, 'job_logs'); + if (!hasJobLogsTable) { + return; + } + + const rows = await db.all(` + SELECT job_id, source, message, timestamp + FROM job_logs + ORDER BY job_id ASC, id ASC + `); + if (!Array.isArray(rows) || rows.length === 0) { + logger.info('legacy-job-logs:export:skip-empty'); + return; + } + + const targetDir = getJobLogDir(); + fs.mkdirSync(targetDir, { recursive: true }); + const streams = new Map(); + + try { + for (const row of rows) { + const jobId = Number(row?.job_id); + if (!Number.isFinite(jobId) || jobId <= 0) { + continue; + } + const key = String(Math.trunc(jobId)); + if (!streams.has(key)) { + const filePath = path.join(targetDir, `job-${key}.process.log`); + const stream = fs.createWriteStream(filePath, { + flags: 'w', + encoding: 'utf-8' + }); + streams.set(key, stream); + } + const line = `[${String(row?.timestamp || '')}] [${String(row?.source || 'SYSTEM')}] ${String(row?.message || '')}\n`; + streams.get(key).write(line); + } + } finally { + await Promise.all( + [...streams.values()].map( + (stream) => + new Promise((resolve) => { + stream.end(resolve); + }) + ) + ); + } + + logger.warn('legacy-job-logs:exported', { + lines: rows.length, + jobs: streams.size, + targetDir + }); +} + +async function applySchemaModel(db, model) { + await db.exec('PRAGMA foreign_keys = OFF;'); + await db.exec('BEGIN'); + try { + await syncSchemaToModel(db, model); + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } finally { + await db.exec('PRAGMA foreign_keys = ON;'); + } +} + +async function openAndPrepareDatabase() { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + logger.info('init:open', { dbPath }); + + dbInstance = await open({ + filename: dbPath, + driver: sqlite3.Database + }); + + await dbInstance.exec('PRAGMA journal_mode = WAL;'); + await dbInstance.exec('PRAGMA foreign_keys = ON;'); + const initialLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true }); + logger.info('log-root:initialized', { + configured: initialLogRoot.configured || null, + resolved: initialLogRoot.resolved + }); + await exportLegacyJobLogsToFiles(dbInstance); + const schemaModel = await loadSchemaModel(); + await applySchemaModel(dbInstance, schemaModel); + + await seedFromSchemaFile(dbInstance); + await syncInstallPathSettingDefaults(dbInstance); + await migrateLegacyProfiledToolSettings(dbInstance); + await migrateOutputTemplates(dbInstance); + await removeDeprecatedSettings(dbInstance); + await migrateSettingsSchemaMetadata(dbInstance); + await ensurePipelineStateRow(dbInstance); + 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', + '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' + ]; + for (const key of deprecatedKeys) { + const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]); + const valuesResult = await db.run('DELETE FROM settings_values WHERE key = ?', [key]); + if (schemaResult?.changes > 0 || valuesResult?.changes > 0) { + logger.info('migrate:remove-deprecated-setting', { key }); + } + } + + // Reset raw_dir_cd if it still holds the old hardcoded absolute path from a prior install + await db.run( + `UPDATE settings_values SET value = NULL, updated_at = CURRENT_TIMESTAMP + WHERE key = 'raw_dir_cd' AND value = '/opt/ripster/backend/data/output/cd'` + ); +} + +// Aktualisiert settings_schema-Metadaten (required, description, validation_json) +// für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten. +const SETTINGS_SCHEMA_METADATA_UPDATES = [ + { + key: 'handbrake_preset_bluray', + required: 0, + description: 'Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet.', + validation_json: '{}' + }, + { + key: 'handbrake_preset_dvd', + required: 0, + description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.', + validation_json: '{}' + }, + { + 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}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{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}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', + 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_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' } +]; + +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' + }); + } + + // 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 ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`); + + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_chapter_template_audiobook', 'Pfade', 'Kapitel Template (Audiobook)', 'string', 1, 'Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}', '[]', '{"minLength":1}', 7355)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_chapter_template_audiobook', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('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_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_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)` + ); + 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}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}')`); + + 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', '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)` + ); + 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', '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)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`); + + 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_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)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`); + + 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_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`); + + 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')`); + + // 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(` + 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..9f66233 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,121 @@ +require('dotenv').config(); + +const http = require('http'); +const express = require('express'); +const cors = require('cors'); +const { port, corsOrigin } = require('./config'); +const { initDatabase } = require('./db/database'); +const errorHandler = require('./middleware/errorHandler'); +const requestLogger = require('./middleware/requestLogger'); +const settingsRoutes = require('./routes/settingsRoutes'); +const pipelineRoutes = require('./routes/pipelineRoutes'); +const historyRoutes = require('./routes/historyRoutes'); +const downloadRoutes = require('./routes/downloadRoutes'); +const cronRoutes = require('./routes/cronRoutes'); +const runtimeRoutes = require('./routes/runtimeRoutes'); +const converterRoutes = require('./routes/converterRoutes'); +const wsService = require('./services/websocketService'); +const pipelineService = require('./services/pipelineService'); +const converterScanService = require('./services/converterScanService'); +const cronService = require('./services/cronService'); +const downloadService = require('./services/downloadService'); +const diskDetectionService = require('./services/diskDetectionService'); +const hardwareMonitorService = require('./services/hardwareMonitorService'); +const coverArtRecoveryService = require('./services/coverArtRecoveryService'); +const logger = require('./services/logger').child('BOOT'); +const { errorToMeta } = require('./utils/errorMeta'); +const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService'); + +async function start() { + logger.info('backend:start:init'); + await initDatabase(); + await pipelineService.init(); + await converterScanService.startPolling(); + await cronService.init(); + await downloadService.init(); + await coverArtRecoveryService.init(); + + const app = express(); + app.use(cors({ origin: corsOrigin })); + app.use(express.json({ limit: '2mb' })); + app.use(requestLogger); + + app.get('/api/health', (req, res) => { + res.json({ ok: true, now: new Date().toISOString() }); + }); + + app.use('/api/settings', settingsRoutes); + app.use('/api/pipeline', pipelineRoutes); + app.use('/api/history', historyRoutes); + app.use('/api/downloads', downloadRoutes); + app.use('/api/crons', cronRoutes); + app.use('/api/runtime', runtimeRoutes); + app.use('/api/converter', converterRoutes); + app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true })); + + app.use(errorHandler); + + const server = http.createServer(app); + wsService.init(server); + await hardwareMonitorService.init(); + + diskDetectionService.on('discInserted', (device) => { + logger.info('disk:inserted:event', { device }); + pipelineService.onDiscInserted(device).catch((error) => { + logger.error('pipeline:onDiscInserted:failed', { error: errorToMeta(error), device }); + wsService.broadcast('PIPELINE_ERROR', { message: error.message }); + }); + }); + + diskDetectionService.on('discRemoved', (device) => { + logger.info('disk:removed:event', { device }); + pipelineService.onDiscRemoved(device).catch((error) => { + logger.error('pipeline:onDiscRemoved:failed', { error: errorToMeta(error), device }); + wsService.broadcast('PIPELINE_ERROR', { message: error.message }); + }); + }); + + diskDetectionService.on('error', (error) => { + logger.error('diskDetection:error:event', { error: errorToMeta(error) }); + wsService.broadcast('DISK_DETECTION_ERROR', { message: error.message }); + }); + + diskDetectionService.start(); + + server.listen(port, () => { + logger.info('backend:listening', { port }); + // Bestehende Job-Bilder im Hintergrund migrieren (blockiert nicht den Start) + migrateExistingThumbnails().catch(() => {}); + }); + + const shutdown = () => { + logger.warn('backend:shutdown:received'); + converterScanService.stopPolling(); + diskDetectionService.stop(); + coverArtRecoveryService.stop(); + hardwareMonitorService.stop(); + cronService.stop(); + server.close(() => { + logger.warn('backend:shutdown:completed'); + process.exit(0); + }); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + process.on('uncaughtException', (error) => { + logger.error('process:uncaughtException', { error: errorToMeta(error) }); + }); + + process.on('unhandledRejection', (reason) => { + logger.error('process:unhandledRejection', { + reason: reason instanceof Error ? errorToMeta(reason) : String(reason) + }); + }); +} + +start().catch((error) => { + logger.error('backend:start:failed', { error: errorToMeta(error) }); + process.exit(1); +}); 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..0186ea9 --- /dev/null +++ b/backend/src/middleware/requestLogger.js @@ -0,0 +1,53 @@ +const { randomUUID } = require('crypto'); +const logger = require('../services/logger').child('HTTP'); + +function truncate(value, maxLen = 1500) { + if (value === undefined) { + return undefined; + } + + let str; + if (typeof value === 'string') { + str = value; + } else { + try { + str = JSON.stringify(value); + } catch (error) { + str = '[unserializable-body]'; + } + } + if (str.length <= maxLen) { + return str; + } + + return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`; +} + +module.exports = function requestLogger(req, res, next) { + const reqId = randomUUID(); + const startedAt = Date.now(); + + req.reqId = reqId; + + logger.info('request:start', { + reqId, + method: req.method, + url: req.originalUrl, + ip: req.ip, + query: req.query, + body: truncate(req.body) + }); + + res.on('close', () => { + if (!res.writableEnded) { + logger.warn('request:aborted', { + reqId, + method: req.method, + url: req.originalUrl, + durationMs: Date.now() - startedAt + }); + } + }); + + next(); +}; 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..c5fe9ec --- /dev/null +++ b/backend/src/plugins/ConverterPlugin.js @@ -0,0 +1,667 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { SourcePlugin } = require('./PluginBase'); +const { spawnTrackedProcess } = require('../services/processRunner'); +const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers'); +const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files'); + +const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']); +const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']); +const ISO_EXTENSIONS = new Set(['iso']); + +const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); +const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']); + +/** + * Erkennt den Medientyp anhand der Dateiendung. + * @returns {'video'|'audio'|'iso'|null} + */ +function detectMediaTypeFromPath(filePath) { + const ext = path.extname(String(filePath || '')).slice(1).toLowerCase(); + if (ISO_EXTENSIONS.has(ext)) return 'iso'; + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; + return null; +} + +function isAudioExt(ext) { + return AUDIO_EXTENSIONS.has(String(ext).toLowerCase()); +} + +/** + * Alle Audio-Dateien in einem Verzeichnis auflisten (nicht rekursiv). + */ +function listAudioFilesInDir(dirPath) { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + return entries + .filter((e) => e.isFile() && isAudioExt(path.extname(e.name).slice(1))) + .map((e) => e.name) + .sort(); + } catch (_err) { + return []; + } +} + +/** + * ffmpeg-Args zum Konvertieren einer einzelnen Audio-Datei. + */ +function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {}) { + const args = ['-y', '-i', inputFile]; + + if (format === 'flac') { + const level = Math.max(0, Math.min(12, Number(opts.flacCompression ?? 5))); + args.push('-codec:a', 'flac', '-compression_level', String(level)); + } else if (format === 'mp3') { + const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase(); + args.push('-codec:a', 'libmp3lame'); + if (mode === 'vbr') { + args.push('-q:a', String(Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4))))); + } else { + args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`); + } + } else if (format === 'opus') { + args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`); + } else if (format === 'ogg') { + args.push('-codec:a', 'libvorbis', '-q:a', String(Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6))))); + } else if (format === 'wav') { + args.push('-codec:a', 'pcm_s16le'); + } else { + args.push('-codec:a', 'copy'); + } + + // Metadata tags + const trackTitle = opts.trackTitle ? String(opts.trackTitle).trim() : null; + const trackArtist = (opts.trackArtist || opts.albumArtist) + ? String(opts.trackArtist || opts.albumArtist).trim() : null; + const albumTitle = opts.albumTitle ? String(opts.albumTitle).trim() : null; + const albumYear = opts.albumYear ? String(opts.albumYear).trim() : null; + const trackNumber = opts.trackNumber != null ? String(opts.trackNumber) : null; + + if (trackTitle) args.push('-metadata', `title=${trackTitle}`); + if (trackArtist) args.push('-metadata', `artist=${trackArtist}`); + if (albumTitle) args.push('-metadata', `album=${albumTitle}`); + if (albumYear) args.push('-metadata', `date=${albumYear}`); + if (trackNumber) args.push('-metadata', `track=${trackNumber}`); + + args.push(outputFile); + return { cmd: ffmpegCmd, args }; +} + +/** + * Source-Plugin für den Converter. + * + * Unterstützte Eingaben: + * - Video-Dateien (mkv, mp4, avi, mov, ...): HandBrake-Encode + * - Audio-Dateien (flac, mp3, wav, ...): ffmpeg-Encode + * - Audio-Ordner: alle enthaltenen Audio-Dateien mit ffmpeg + * - ISO: MakeMKV-Rip → HandBrake-Encode + * + * Erkennung via mediaProfile === 'converter'. + * + * Erforderliche Felder in ctx.extra für encode(): + * inputPath - Absoluter Pfad zur Eingabedatei/-ordner (nach Rip: raw_path) + * outputDir - Ausgabeverzeichnis (für Audio/ISO-Ordner) oder + * outputPath - Ausgabedatei (für einzelne Video/Audio-Dateien) + * encodePlan - aus encode_plan_json + * isCancelled - () => boolean + * onProcessHandle - (handle) => void [optional] + */ +class ConverterPlugin extends SourcePlugin { + get id() { return 'converter'; } + get name() { return 'Converter'; } + get priority() { return 3; } + + detect(discInfo) { + const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase(); + return profile === 'converter'; + } + + /** + * Analysiert die Eingabedatei/-ordner. + * - Video/ISO: HandBrake --scan für Track-Info + * - Audio: ffprobe für Metadaten + * - Audio-Ordner: Dateiliste + ffprobe auf erste Datei + */ + async analyze(inputPath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js' + }); + + const actualInput = ctx.extra?.filePath || inputPath; + const encodePlan = ctx.extra?.encodePlan || {}; + const converterMediaType = encodePlan.converterMediaType + || detectMediaTypeFromPath(actualInput); + + ctx.logger.info('converter:analyze:start', { + jobId: job?.id, inputPath: actualInput, converterMediaType + }); + + const settings = await ctx.settings.getSettingsMap(); + const ffprobeCmd = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; + const handbrakeCmd = String(settings?.handbrake_command || 'HandBrakeCLI').trim() || 'HandBrakeCLI'; + + let analysisResult = { converterMediaType, inputPath: actualInput }; + + if (converterMediaType === 'video' || converterMediaType === 'iso') { + // HandBrake --scan für Track-Informationen + try { + const scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(actualInput); + const captured = await this._runCaptured(scanConfig.cmd, scanConfig.args, { jobId: job?.id }); + const hbInfo = this._parseHandBrakeScan(captured.stdout); + analysisResult.handbrakeInfo = hbInfo; + ctx.logger.info('converter:analyze:handbrake-scan', { + jobId: job?.id, + titleCount: hbInfo?.TitleList?.length || 0 + }); + } catch (scanError) { + ctx.logger.warn('converter:analyze:handbrake-scan-failed', { + jobId: job?.id, error: scanError?.message + }); + } + } + + if (converterMediaType === 'audio') { + const stat = fs.statSync(actualInput); + if (stat.isDirectory()) { + // Ordner mit Audio-Dateien + const audioFiles = listAudioFilesInDir(actualInput); + analysisResult.isFolder = true; + analysisResult.audioFiles = audioFiles; + // Erster Track: ffprobe für Metadaten + if (audioFiles.length > 0) { + const firstFile = path.join(actualInput, audioFiles[0]); + try { + const meta = await this._ffprobeMetadata(ffprobeCmd, firstFile, { jobId: job?.id }); + analysisResult.folderMeta = meta; + } catch (_err) { + // Metadaten optional + } + } + ctx.logger.info('converter:analyze:audio-folder', { + jobId: job?.id, fileCount: audioFiles.length + }); + } else { + // Einzelne Audio-Datei + analysisResult.isFolder = false; + try { + const meta = await this._ffprobeMetadata(ffprobeCmd, actualInput, { jobId: job?.id }); + analysisResult.fileMeta = meta; + } catch (_err) { + // Metadaten optional + } + ctx.logger.info('converter:analyze:audio-file', { jobId: job?.id }); + } + } + + return analysisResult; + } + + /** + * Rip-Schritt: + * - Video/Audio-Dateien: No-Op (Datei liegt bereits vor) + * - ISO: MakeMKV backup → raw_path + */ + async rip(job, ctx) { + ctx.markExecution('rip', { + pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js' + }); + + const encodePlan = ctx.extra?.encodePlan || {}; + const converterMediaType = encodePlan.converterMediaType; + + if (converterMediaType !== 'iso') { + ctx.logger.info('converter:rip:no-op', { jobId: job?.id, converterMediaType }); + return; + } + + // ISO: MakeMKV backup + const { + inputPath, + rawJobDir, + isCancelled, + onProcessHandle + } = ctx.extra || {}; + + if (!inputPath) { + throw new Error('ConverterPlugin.rip(): ctx.extra.inputPath fehlt'); + } + if (!rawJobDir) { + throw new Error('ConverterPlugin.rip(): ctx.extra.rawJobDir fehlt'); + } + + ensureDir(rawJobDir); + + const settings = await ctx.settings.getSettingsMap(); + const makemkvCmd = String(settings?.makemkv_command || 'makemkvcon').trim() || 'makemkvcon'; + const registrationKey = String(settings?.makemkv_registration_key || '').trim() || null; + + if (registrationKey) { + try { + await this._runCaptured(makemkvCmd, ['reg', registrationKey], { jobId: job?.id }); + } catch (_err) { + // Registrierung ist optional + } + } + + const args = ['--noscan', '--decrypt', '-r', 'backup', `iso:${inputPath}`, rawJobDir]; + + ctx.logger.info('converter:rip:iso:start', { + jobId: job?.id, inputPath, rawJobDir, cmd: makemkvCmd + }); + ctx.emitProgress(0, 'ISO: MakeMKV Backup läuft …'); + + const runInfo = await _spawnAndWait({ + cmd: makemkvCmd, args, + jobId: job?.id, + progressParser: parseMakeMkvProgress, + onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText), + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.rip', stage: 'RIPPING' } + }); + + ctx.logger.info('converter:rip:iso:done', { + jobId: job?.id, exitCode: runInfo?.exitCode ?? null + }); + + ctx.extra.ripRunInfo = runInfo; + } + + /** + * Encode-Schritt: + * - Video: HandBrakeCLI + * - Audio Einzeldatei: ffmpeg + * - Audio Ordner: ffmpeg je Datei + */ + async encode(job, ctx) { + ctx.markExecution('encode', { + pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js' + }); + + const { + inputPath, + outputPath, + outputDir, + encodePlan = {}, + isCancelled, + onProcessHandle + } = ctx.extra || {}; + + const converterMediaType = encodePlan.converterMediaType; + + if (!inputPath) { + throw new Error('ConverterPlugin.encode(): ctx.extra.inputPath fehlt'); + } + + const settings = await ctx.settings.getSettingsMap(); + + if (converterMediaType === 'video' || converterMediaType === 'iso') { + await this._encodeVideo({ + job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle + }); + } else if (converterMediaType === 'audio') { + await this._encodeAudio({ + job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle + }); + } else { + throw new Error(`ConverterPlugin.encode(): Unbekannter converterMediaType: ${converterMediaType}`); + } + } + + // ── Video-Encode (HandBrake) ───────────────────────────────────────────── + + async _encodeVideo({ job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle }) { + if (!outputPath) { + throw new Error('ConverterPlugin._encodeVideo(): outputPath fehlt'); + } + + ensureDir(path.dirname(outputPath)); + + const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, { + trackSelection: encodePlan.trackSelection || null, + titleId: encodePlan.handBrakeTitleId || null, + mediaProfile: null, + settingsMap: settings, + userPreset: encodePlan.userPreset || null + }); + + ctx.logger.info('converter:encode:video:start', { + jobId: job?.id, cmd: handBrakeConfig.cmd, titleId: encodePlan.handBrakeTitleId || null + }); + ctx.emitProgress(0, 'Converter: Video Encoding läuft …'); + + const runInfo = await _spawnAndWait({ + cmd: handBrakeConfig.cmd, args: handBrakeConfig.args, + jobId: job?.id, + progressParser: parseHandBrakeProgress, + onProgress: (pct, statusText, eta) => ctx.emitProgress( + Math.round(pct), + statusText || `Encoding ${pct}%`, + eta ?? null + ), + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'HANDBRAKE', + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + + ctx.logger.info('converter:encode:video:done', { + jobId: job?.id, outputPath, exitCode: runInfo.exitCode + }); + + ctx.extra.encodeRunInfo = runInfo; + } + + // ── Audio-Encode (ffmpeg) ──────────────────────────────────────────────── + + async _encodeAudio({ job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle }) { + const ffmpegCmd = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg'; + const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase(); + const formatOptions = encodePlan.audioFormatOptions || {}; + const isFolder = Boolean(encodePlan.isFolder); + const isSharedAudio = Boolean(encodePlan.isSharedAudio) + || (Array.isArray(encodePlan.inputPaths) && encodePlan.inputPaths.length > 0 && !isFolder); + + // Metadata from user config + const planMetadata = encodePlan.metadata || {}; + const planTracks = Array.isArray(encodePlan.tracks) ? encodePlan.tracks : []; + + if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) { + throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`); + } + + /** Build per-track metadata opts merged with album-level metadata */ + function trackMetaOpts(position, defaultBaseName) { + const trackMeta = planTracks.find((t) => t.position === position) || {}; + return { + ...formatOptions, + trackTitle: trackMeta.title || defaultBaseName || null, + trackArtist: trackMeta.artist || planMetadata.albumArtist || null, + albumTitle: planMetadata.albumTitle || null, + albumArtist: planMetadata.albumArtist || null, + albumYear: planMetadata.albumYear ? String(planMetadata.albumYear) : null, + trackNumber: position + }; + } + + /** Build output filename with optional track number + title */ + function buildOutputFileName(position, trackMeta, defaultBaseName) { + const numStr = String(position).padStart(2, '0'); + const title = trackMeta?.title ? sanitizeFileName(trackMeta.title) : null; + const templateRaw = String(encodePlan.audioTrackTemplate || '').trim(); + if (templateRaw) { + const rendered = renderTemplate(templateRaw, { + trackNr: numStr, + trackNumber: String(position), + title: title || sanitizeFileName(defaultBaseName) || `Track ${numStr}`, + artist: sanitizeFileName(trackMeta?.artist || planMetadata.albumArtist || '') || 'unknown', + album: sanitizeFileName(planMetadata.albumTitle || '') || 'unknown', + year: planMetadata.albumYear || 'unknown' + }); + const sanitized = sanitizeFileName(rendered); + if (sanitized) { + return `${sanitized}.${outputFormat}`; + } + } + if (title) return `${numStr} - ${title}.${outputFormat}`; + return `${sanitizeFileName(defaultBaseName) || numStr}.${outputFormat}`; + } + + if (isSharedAudio) { + // Freigegebene Audio-Dateien (mehrere Einzeldateien in einem Job) + const inputPaths = encodePlan.inputPaths || []; + if (inputPaths.length === 0) { + throw new Error('ConverterPlugin._encodeAudio(): inputPaths leer für shared-audio-Job'); + } + if (!outputDir) { + throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für shared-audio-Job'); + } + ensureDir(outputDir); + + ctx.logger.info('converter:encode:audio:shared:start', { + jobId: job?.id, fileCount: inputPaths.length, outputDir, format: outputFormat + }); + + for (let i = 0; i < inputPaths.length; i++) { + if (typeof isCancelled === 'function' && isCancelled()) { + const err = new Error('Job wurde vom Benutzer abgebrochen.'); + err.statusCode = 409; + throw err; + } + + const inputFile = inputPaths[i]; + const fileName = path.basename(inputFile); + const baseName = path.basename(fileName, path.extname(fileName)); + const position = i + 1; + const trackMeta = planTracks.find((t) => t.position === position) || {}; + const outputFileName = buildOutputFileName(position, trackMeta, baseName); + const outputFile = path.join(outputDir, outputFileName); + + ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile }); + ctx.emitProgress( + Math.round((i / inputPaths.length) * 100), + `Audio: ${position}/${inputPaths.length} — ${fileName}` + ); + + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputFile, outputFile, outputFormat, + trackMetaOpts(position, baseName) + ); + + await _spawnAndWait({ + cmd: encodeConfig.cmd, args: encodeConfig.args, + jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + } + + ctx.logger.info('converter:encode:audio:shared:done', { + jobId: job?.id, fileCount: inputPaths.length, outputDir + }); + + ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: inputPaths.length }; + } else if (isFolder) { + // Ordner-Modus: alle Audio-Dateien im Ordner konvertieren + const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath); + + if (!outputDir) { + throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für Ordner-Job'); + } + ensureDir(outputDir); + + ctx.logger.info('converter:encode:audio:folder:start', { + jobId: job?.id, inputPath, outputDir, format: outputFormat, fileCount: audioFiles.length + }); + + for (let i = 0; i < audioFiles.length; i++) { + if (typeof isCancelled === 'function' && isCancelled()) { + const err = new Error('Job wurde vom Benutzer abgebrochen.'); + err.statusCode = 409; + throw err; + } + + const fileName = audioFiles[i]; + const inputFile = path.join(inputPath, fileName); + const baseName = path.basename(fileName, path.extname(fileName)); + const position = i + 1; + const trackMeta = planTracks.find((t) => t.position === position) || {}; + const outputFileName = buildOutputFileName(position, trackMeta, baseName); + const outputFile = path.join(outputDir, outputFileName); + + ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile }); + ctx.emitProgress( + Math.round((i / audioFiles.length) * 100), + `Audio: ${position}/${audioFiles.length} — ${fileName}` + ); + + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputFile, outputFile, outputFormat, + trackMetaOpts(position, baseName) + ); + + await _spawnAndWait({ + cmd: encodeConfig.cmd, args: encodeConfig.args, + jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + } + + ctx.logger.info('converter:encode:audio:folder:done', { + jobId: job?.id, fileCount: audioFiles.length, outputDir + }); + + ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: audioFiles.length }; + } else { + // Einzelne Datei + if (!outputPath) { + throw new Error('ConverterPlugin._encodeAudio(): outputPath fehlt'); + } + ensureDir(path.dirname(outputPath)); + + ctx.logger.info('converter:encode:audio:file:start', { + jobId: job?.id, inputPath, outputPath, format: outputFormat + }); + ctx.emitProgress(0, `Audio: Encoding läuft …`); + + const baseName = path.basename(inputPath, path.extname(inputPath)); + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputPath, outputPath, outputFormat, + trackMetaOpts(1, baseName) + ); + + await _spawnAndWait({ + cmd: encodeConfig.cmd, args: encodeConfig.args, + jobId: job?.id, + appendLogLine: ctx?.extra?.appendLogLine, + logSource: 'FFMPEG', + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + + ctx.logger.info('converter:encode:audio:file:done', { + jobId: job?.id, outputPath + }); + + ctx.extra.encodeResult = { outputPath, format: outputFormat }; + } + } + + // ── Hilfsmethoden ─────────────────────────────────────────────────────── + + async _runCaptured(cmd, args, options = {}) { + return new Promise((resolve, reject) => { + const { spawn } = require('child_process'); + let stdout = ''; + let stderr = ''; + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); }); + child.on('error', reject); + child.on('close', (code) => resolve({ exitCode: code, stdout, stderr })); + }); + } + + _parseHandBrakeScan(rawOutput) { + try { + const jsonStart = rawOutput.indexOf('{'); + if (jsonStart < 0) return null; + return JSON.parse(rawOutput.slice(jsonStart)); + } catch (_err) { + return null; + } + } + + async _ffprobeMetadata(ffprobeCmd, filePath, options = {}) { + const args = [ + '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath + ]; + const result = await this._runCaptured(ffprobeCmd, args, options); + try { + return JSON.parse(result.stdout); + } catch (_err) { + return null; + } + } + + getSettingsSchema() { + return []; + } +} + +async function _spawnAndWait({ + cmd, + args, + jobId, + progressParser, + onProgress, + appendLogLine, + logSource = 'PROCESS', + isCancelled, + onProcessHandle, + context +}) { + if (typeof isCancelled === 'function' && isCancelled()) { + const err = new Error('Job wurde vom Benutzer abgebrochen.'); + err.statusCode = 409; + throw err; + } + + const handleLine = (stream, line) => { + if (progressParser && typeof onProgress === 'function') { + const progress = progressParser(line); + if (progress?.percent != null) { + onProgress(progress.percent, progress.detail || null, progress.eta ?? null); + } + } + if (typeof appendLogLine === 'function') { + try { + appendLogLine(logSource, line, stream); + } catch (_error) { + // ignore log append errors in process stream + } + } + }; + + const handle = spawnTrackedProcess({ + cmd, + args, + onStdoutLine: (line) => handleLine('stdout', line), + onStderrLine: (line) => handleLine('stderr', line), + context: context || { jobId } + }); + + if (typeof onProcessHandle === 'function') { + onProcessHandle(handle); + } + + if (typeof isCancelled === 'function' && isCancelled()) { + handle.cancel(); + } + + try { + const result = await handle.promise; + return result; + } catch (error) { + if (typeof isCancelled === 'function' && isCancelled()) { + const cancelError = new Error('Job wurde vom Benutzer abgebrochen.'); + cancelError.statusCode = 409; + throw cancelError; + } + throw error; + } +} + +module.exports = { ConverterPlugin }; 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..85f74a5 --- /dev/null +++ b/backend/src/plugins/DVDSeriesPlugin.js @@ -0,0 +1,105 @@ +'use strict'; + +const { SourcePlugin } = require('./PluginBase'); +const dvdSeriesScanService = require('../services/dvdSeriesScanService'); +const tmdbService = require('../services/tmdbService'); + +/** + * Companion-Plugin für Serien-DVDs. + * + * Dieses Plugin ersetzt den bestehenden DVDPlugin-Flow nicht. Es wird in einem + * späteren Schritt explizit aus dem DVD-Flow aufgerufen, sobald ein Prescan eine + * serientypische Titelstruktur vermuten lässt. + */ +class DVDSeriesPlugin extends SourcePlugin { + get id() { + return 'dvd_series'; + } + + get name() { + return 'DVD Series'; + } + + detect() { + return false; + } + + async analyze(_devicePath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'DVDSeriesPlugin.js' + }); + + const scanText = String(ctx.extra?.scanText || '').trim(); + if (!scanText) { + return { + parsedScan: null, + seriesAnalysis: null, + providerConfigured: await tmdbService.isConfigured() + }; + } + + const result = dvdSeriesScanService.analyzeHandBrakeScan(scanText, ctx.extra?.seriesOptions || {}); + const seriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([ + { + value: result?.parsed?.discTitle || '', + source: 'handbrake_disc_title' + }, + { + value: ctx.extra?.detectedTitle || '', + source: 'detected_title' + } + ]); + return { + parsedScan: result.parsed, + seriesAnalysis: result.analysis, + seriesLookupHint, + providerConfigured: await tmdbService.isConfigured() + }; + } + + async review(_job, ctx) { + ctx.markExecution('review', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'DVDSeriesPlugin.js' + }); + return null; + } + + async rip() { + throw new Error('DVDSeriesPlugin.rip() ist ein Companion-Plugin und wird nicht direkt ausgeführt.'); + } + + async encode() { + throw new Error('DVDSeriesPlugin.encode() ist ein Companion-Plugin und wird nicht direkt ausgeführt.'); + } + + async searchSeries(query, options = {}) { + return tmdbService.searchSeries(query, options); + } + + async searchSeriesWithSeason(query, seasonNumber, options = {}) { + return tmdbService.searchSeriesWithSeason(query, seasonNumber, options); + } + + getSettingsSchema() { + return [ + { + key: 'tmdb_api_read_access_token', + category: 'Metadaten', + label: 'TMDb Read Access Token', + type: 'string', + required: 0, + description: 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', + default_value: null, + options_json: '[]', + validation_json: '{}', + order_index: 401 + } + ]; + } +} + +module.exports = { DVDSeriesPlugin }; 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..c415a9b --- /dev/null +++ b/backend/src/plugins/VideoDiscPlugin.js @@ -0,0 +1,530 @@ +'use strict'; + +const path = require('path'); +const { SourcePlugin } = require('./PluginBase'); +const omdbService = require('../services/omdbService'); +const { spawnTrackedProcess } = require('../services/processRunner'); +const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers'); +const { ensureDir } = require('../utils/files'); + +/** + * Gemeinsame Basisklasse für Blu-ray und DVD Plugins. + * + * Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien: + * analyze() → OMDB-Suche + Disc-Metadaten vorbereiten + * review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator) + * rip() → makemkvcon backup/mkv + * encode() → HandBrakeCLI + * + * Subklassen müssen implementieren: + * get id() → 'bluray' | 'dvd' + * get name() → Anzeigename + * get mediaProfile() → 'bluray' | 'dvd' + * detect(discInfo) → boolean + * + * ctx.extra-Felder für rip(): + * rawJobDir - Absoluter Pfad des RAW-Ausgabeordners + * deviceInfo - { path, index, mediaProfile } vom DiskDetectionService + * selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus) + * ripMode - 'backup' | 'mkv' (Default: aus Settings) + * backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD) + * isCancelled - () => boolean + * onProcessHandle - (handle) => void [optional] + * + * ctx.extra-Felder für encode(): + * inputPath - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis + * outputPath - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix) + * encodePlan - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review + * isCancelled - () => boolean + * onProcessHandle - (handle) => void [optional] + * + * ctx.extra-Felder für review(): + * deviceInfo - Disc-Device (für Pre-Rip-Scan) ODER + * rawJobDir - RAW-Ordner (für Post-Rip-Scan) + * reviewMode - 'pre_rip' | 'rip' (Default: 'pre_rip') + */ +class VideoDiscPlugin extends SourcePlugin { + /** + * Gibt das Media-Profil zurück, das settingsService-Methoden verwenden. + * Muss von Subklassen implementiert werden. + * @returns {'bluray'|'dvd'} + */ + get mediaProfile() { + throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`); + } + + /** + * Führt eine OMDB-Suche für den erkannten Disc-Titel durch. + * Gibt { detectedTitle, omdbCandidates } zurück. + * + * @param {string} devicePath + * @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten) + * @param {PluginContext} ctx + * @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>} + */ + async analyze(devicePath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'VideoDiscPlugin.js' + }); + + const discInfo = ctx.extra?.discInfo || {}; + const detectedTitle = String( + discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc' + ).trim(); + + ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle }); + + const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => { + ctx.logger.warn(`${this.id}:analyze:omdb-failed`, { + jobId: job?.id, + error: error?.message || String(error) + }); + return []; + }); + + ctx.logger.info(`${this.id}:analyze:done`, { + jobId: job?.id, + detectedTitle, + omdbCandidates: omdbCandidates.length + }); + + return { detectedTitle, omdbCandidates }; + } + + /** + * Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück. + * Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview). + * + * ctx.extra.reviewMode: + * 'pre_rip' → Scan direkt vom Laufwerk (deviceInfo) + * 'rip' → Scan aus dem RAW-Ordner (rawJobDir) + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise<{scanLines: string[], runInfo: object}|null>} + */ + async review(job, ctx) { + ctx.markExecution('review', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'VideoDiscPlugin.js' + }); + + const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase(); + const deviceInfo = ctx.extra?.deviceInfo || null; + const rawJobDir = ctx.extra?.rawJobDir || null; + + ctx.logger.info(`${this.id}:review:start`, { + jobId: job?.id, + reviewMode, + deviceInfo: deviceInfo?.path || null, + rawJobDir: rawJobDir || null + }); + + const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile); + + let scanConfig; + if (reviewMode === 'rip' && rawJobDir) { + // Post-Rip scan: scan from the ripped RAW folder + scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, { + mediaProfile: this.mediaProfile, + settingsMap: settings + }); + } else { + // Pre-Rip scan: scan directly from disc device + scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, { + mediaProfile: this.mediaProfile, + settingsMap: settings + }); + } + + ctx.logger.info(`${this.id}:review:scan-command`, { + jobId: job?.id, + cmd: scanConfig.cmd, + args: scanConfig.args + }); + + const scanLines = []; + const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; + const reviewStage = String(ctx.extra?.reviewStage || 'MEDIAINFO_CHECK').trim().toUpperCase() || 'MEDIAINFO_CHECK'; + const reviewSource = String(ctx.extra?.reviewSource || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN'; + const reviewSilent = Boolean(ctx.extra?.reviewSilent); + let runInfo; + if (runCommandFn) { + try { + runInfo = await runCommandFn({ + jobId: job?.id, + stage: reviewStage, + source: reviewSource, + cmd: scanConfig.cmd, + args: scanConfig.args, + collectLines: scanLines, + // HandBrake scan details (duration/chapters/audio/subtitles) are often + // emitted on stderr. Collect both streams for reliable disc heuristics. + collectStderrLines: true, + silent: reviewSilent + }); + } catch (cmdError) { + // runCommand collected stdout lines via callbacks before throwing. + // Recover the runInfo from the error so the caller can still parse + // whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON). + runInfo = cmdError?.runInfo || { + status: 'ERROR', + exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1, + errorMessage: cmdError?.message || String(cmdError) + }; + } + } else { + runInfo = await _runCommandCaptured({ + cmd: scanConfig.cmd, + args: scanConfig.args, + onStdoutLine: (line) => scanLines.push(line), + onStderrLine: (line) => scanLines.push(line), + context: { jobId: job?.id, source: `${this.id}Plugin.review` } + }); + } + + ctx.logger.info(`${this.id}:review:scan-done`, { + jobId: job?.id, + exitCode: runInfo.exitCode, + lineCount: scanLines.length + }); + + // Return raw scan data — the Orchestrator (or legacy pipelineService) + // processes this with buildDiscScanReview() / parseMediainfoJsonOutput(). + return { + scanLines, + runInfo, + reviewMode, + mediaProfile: this.mediaProfile, + sourceArg: scanConfig.sourceArg || null + }; + } + + /** + * Rippt die Disc mit makemkvcon in den rawJobDir. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} runInfo vom makemkvcon-Prozess + */ + async rip(job, ctx) { + ctx.markExecution('rip', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'VideoDiscPlugin.js' + }); + + const { + rawJobDir, + deviceInfo, + selectedTitleId = null, + backupOutputBase = null, + disableMinLengthFilter = false, + isCancelled, + onProcessHandle + } = ctx.extra || {}; + + if (!rawJobDir) { + throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`); + } + if (!deviceInfo) { + throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`); + } + + ensureDir(rawJobDir); + + const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile); + const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, { + selectedTitleId, + mediaProfile: this.mediaProfile, + settingsMap: settings, + backupOutputBase, + disableMinLengthFilter + }); + + const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv') + .trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; + + ctx.logger.info(`${this.id}:rip:start`, { + jobId: job?.id, + cmd: ripConfig.cmd, + args: ripConfig.args, + ripMode, + rawJobDir, + selectedTitleId, + disableMinLengthFilter + }); + + ctx.emitProgress(0, ripMode === 'backup' + ? `${this.name}: Backup läuft …` + : `${this.name}: Ripping läuft …` + ); + + // Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand() + // des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log, + // Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests. + const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; + let runInfo; + if (runCommandFn) { + runInfo = await runCommandFn({ + jobId: job?.id, + stage: 'RIPPING', + source: 'MAKEMKV_RIP', + cmd: ripConfig.cmd, + args: ripConfig.args, + parser: parseMakeMkvProgress + }); + } else { + runInfo = await _spawnAndWait({ + cmd: ripConfig.cmd, + args: ripConfig.args, + cwd: rawJobDir, + jobId: job?.id, + progressParser: parseMakeMkvProgress, + onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText), + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' } + }); + } + + ctx.logger.info(`${this.id}:rip:done`, { + jobId: job?.id, + rawJobDir, + exitCode: runInfo?.exitCode ?? runInfo?.code ?? null + }); + + ctx.extra.ripRunInfo = runInfo; + return runInfo; + } + + /** + * Encodiert die gerippten Dateien mit HandBrakeCLI. + * + * @param {object} job + * @param {PluginContext} ctx + * @returns {Promise} 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..d8913e3 --- /dev/null +++ b/backend/src/routes/converterRoutes.js @@ -0,0 +1,410 @@ +'use strict'; + +const express = require('express'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const multer = require('multer'); +const asyncHandler = require('../middleware/asyncHandler'); +const pipelineService = require('../services/pipelineService'); +const converterScanService = require('../services/converterScanService'); +const historyService = require('../services/historyService'); +const logger = require('../services/logger').child('CONVERTER_ROUTE'); + +/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */ +function resolveTarget(rawDir, input) { + const rel = converterScanService.normalizeRelPath(input != null ? String(input) : ''); + if (rel === null) return { error: 'Ungültiger Pfad' }; + const absolute = path.join(rawDir, rel || '.'); + return { rel: rel || '', absolute }; +} + +const router = express.Router(); + +const converterUpload = multer({ + dest: path.join(os.tmpdir(), 'ripster-converter-uploads') +}); + +// ── Scan ────────────────────────────────────────────────────────────────── + +/** + * GET /api/converter/tree + * Vollständiger FS-Verzeichnisbaum des converter_raw_dir (keine DB). + */ +router.get( + '/tree', + asyncHandler(async (req, res) => { + logger.debug('get:tree'); + const result = await converterScanService.getTree(); + res.json(result); + }) +); + +/** + * GET /api/converter/browse?parent=relPath + * File-Explorer (DB-basiert, wird weiterhin für Job-Zuweisung gebraucht). + */ +router.get( + '/browse', + asyncHandler(async (req, res) => { + const parent = req.query.parent ? String(req.query.parent).trim() : null; + logger.debug('get:browse', { parent }); + const entries = await converterScanService.getEntries(parent); + const rawDir = await converterScanService.getRawDir(); + res.json({ entries, rawDir }); + }) +); + +/** + * POST /api/converter/scan + * Manuellen Scan des converter_raw_dir auslösen. + */ +router.post( + '/scan', + asyncHandler(async (req, res) => { + logger.info('post:scan'); + const result = await converterScanService.scan(); + res.json({ result }); + }) +); + +// ── Jobs erstellen ──────────────────────────────────────────────────────── + +/** + * POST /api/converter/create-jobs + * Body: { entries: [{ relPath, converterMediaType }] } + * Erstellt Jobs aus Scan-Einträgen (File-Explorer-Auswahl). + */ +router.post( + '/create-jobs', + asyncHandler(async (req, res) => { + const entries = Array.isArray(req.body?.entries) ? req.body.entries : []; + if (entries.length === 0) { + const error = new Error('Keine Einträge ausgewählt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:create-jobs', { count: entries.length }); + + const jobs = []; + for (const entry of entries) { + const relPath = String(entry?.relPath || '').trim(); + if (!relPath) continue; + const job = await pipelineService.createFileJob({ + kind: 'converter_entry', + relPath, + options: { + converterMediaType: entry?.converterMediaType || null + } + }); + jobs.push(job); + } + res.json({ jobs }); + }) +); + +/** + * POST /api/converter/upload + * Datei(en) hochladen → Unterordner anlegen (kein Job-Anlegen). + * Gibt { folders: [{folderRelPath, fileCount}] } zurück. + */ +router.post( + '/upload', + converterUpload.array('files', 50), + asyncHandler(async (req, res) => { + const files = Array.isArray(req.files) ? req.files : []; + if (files.length === 0) { + const error = new Error('Keine Dateien hochgeladen.'); + error.statusCode = 400; + throw error; + } + const folderName = req.body?.folderName ? String(req.body.folderName).trim() : null; + logger.info('post:upload', { + fileCount: files.length, + folderName, + files: files.map((f) => ({ name: f.originalname, size: f.size })) + }); + const result = await pipelineService.uploadConverterFiles(files, { folderName }); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/from-selection + * Body: { relPaths: string[], audioMode: 'individual'|'shared' } + * Erstellt Jobs aus im File-Explorer ausgewählten Dateien. + */ +router.post( + '/jobs/from-selection', + asyncHandler(async (req, res) => { + const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : []; + const audioMode = String(req.body?.audioMode || 'individual'); + if (relPaths.length === 0) { + const error = new Error('Keine Dateien ausgewählt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:jobs:from-selection', { count: relPaths.length, audioMode }); + const jobs = await pipelineService.createConverterJobsFromSelection(relPaths, audioMode); + res.json({ jobs }); + }) +); + +/** + * POST /api/converter/jobs/:jobId/assign-files + * Body: { relPaths: string[] } + * Fügt ausgewählte Dateien einem bestehenden (nicht gestarteten) Converter-Job hinzu. + */ +router.post( + '/jobs/:jobId/assign-files', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : []; + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (relPaths.length === 0) { + return res.status(400).json({ detail: 'Keine Dateien übergeben.' }); + } + logger.info('post:jobs:assign-files', { jobId, count: relPaths.length }); + const result = await pipelineService.assignConverterFilesToJob(jobId, relPaths); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/remove-file + * Body: { relPath: string } + * Entfernt eine Datei aus einem bestehenden Converter-Job. + */ +router.post( + '/jobs/:jobId/remove-file', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const relPath = String(req.body?.relPath || '').trim(); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (!relPath) { + return res.status(400).json({ detail: 'relPath fehlt.' }); + } + logger.info('post:jobs:remove-file', { jobId, relPath }); + const result = await pipelineService.removeConverterFileFromJob(jobId, relPath); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/remove-input + * Body: { inputPath: string } + * Entfernt eine Datei aus einem Converter-Job anhand des absoluten Input-Pfads. + */ +router.post( + '/jobs/:jobId/remove-input', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const inputPath = String(req.body?.inputPath || '').trim(); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (!inputPath) { + return res.status(400).json({ detail: 'inputPath fehlt.' }); + } + logger.info('post:jobs:remove-input', { jobId, inputPath }); + const result = await pipelineService.removeConverterInputFromJob(jobId, inputPath); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/config + * Body: partial config draft (outputFormat, presets, metadata, tracks, MusicBrainz-UI-Stand) + * Speichert den Draft für READY_TO_START Jobs persistent im encode_plan_json. + */ +router.post( + '/jobs/:jobId/config', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + logger.debug('post:jobs:config', { jobId }); + const result = await pipelineService.updateConverterJobConfig(jobId, req.body || {}); + res.json(result); + }) +); + +// ── Datei-Operationen (reines FS, keine DB) ─────────────────────────────── + +/** + * DELETE /api/converter/files + * Body: { relPath } + * Datei oder Ordner löschen (fs.rmSync, ohne DB). + */ +router.delete( + '/files', + asyncHandler(async (req, res) => { + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' }); + const relPath = String(req.body?.relPath || '').trim(); + if (!relPath) return res.status(400).json({ detail: 'relPath fehlt.' }); + const target = resolveTarget(rawDir, relPath); + if (target.error || !target.rel) return res.status(400).json({ detail: target.error || 'Ungültiger Pfad.' }); + logger.info('delete:files', { relPath }); + fs.rmSync(target.absolute, { recursive: true, force: true }); + res.json({ ok: true }); + }) +); + +/** + * POST /api/converter/files/rename + * Body: { relPath, newName } + * Umbenennen (fs.renameSync, ohne DB). + */ +router.post( + '/files/rename', + asyncHandler(async (req, res) => { + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' }); + const relPath = String(req.body?.relPath || '').trim(); + const newName = String(req.body?.newName || '').trim(); + if (!relPath || !newName) return res.status(400).json({ detail: 'relPath und newName erforderlich.' }); + if (newName.includes('/') || newName.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' }); + const source = resolveTarget(rawDir, relPath); + if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' }); + const parentAbs = path.dirname(source.absolute); + const destAbs = path.join(parentAbs, newName); + logger.info('post:files:rename', { relPath, newName }); + fs.renameSync(source.absolute, destAbs); + res.json({ ok: true }); + }) +); + +/** + * POST /api/converter/files/move + * Body: { relPath, targetParentRelPath } + * Verschieben (fs.renameSync, ohne DB). targetParentRelPath = '' → Root. + */ +router.post( + '/files/move', + asyncHandler(async (req, res) => { + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' }); + const relPath = String(req.body?.relPath || '').trim(); + if (!relPath) return res.status(400).json({ detail: 'relPath erforderlich.' }); + const targetParentRelPath = req.body?.targetParentRelPath != null ? String(req.body.targetParentRelPath) : ''; + const source = resolveTarget(rawDir, relPath); + if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' }); + const targetParent = resolveTarget(rawDir, targetParentRelPath); + if (targetParent.error) return res.status(400).json({ detail: targetParent.error }); + const name = path.basename(source.absolute); + const destAbs = path.join(targetParent.absolute, name); + logger.info('post:files:move', { relPath, targetParentRelPath }); + fs.renameSync(source.absolute, destAbs); + res.json({ ok: true }); + }) +); + +/** + * POST /api/converter/files/folder + * Body: { parentRelPath, name } + * Neuen Ordner anlegen (fs.mkdirSync, ohne DB). + */ +router.post( + '/files/folder', + asyncHandler(async (req, res) => { + const rawDir = await converterScanService.getRawDir(); + if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' }); + const parentRelPath = req.body?.parentRelPath != null ? String(req.body.parentRelPath) : ''; + const name = String(req.body?.name || '').trim(); + if (!name || name.includes('/') || name.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' }); + const parent = resolveTarget(rawDir, parentRelPath); + if (parent.error) return res.status(400).json({ detail: parent.error }); + logger.info('post:files:folder', { parentRelPath, name }); + fs.mkdirSync(path.join(parent.absolute, name), { recursive: true }); + res.json({ ok: true }); + }) +); + +// ── Job-Status ──────────────────────────────────────────────────────────── + +/** + * GET /api/converter/jobs + * Alle Converter-Jobs zurückgeben. + */ +router.get( + '/jobs', + asyncHandler(async (req, res) => { + const jobs = await pipelineService.getConverterJobs(); + res.json({ jobs }); + }) +); + +/** + * GET /api/converter/jobs/:jobId + * Einzelnen Converter-Job abrufen. + */ +router.get( + '/jobs/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + res.json({ job }); + }) +); + +/** + * POST /api/converter/jobs/:jobId/start + * Job mit finaler Konfiguration starten. + * Body: { converterMediaType, outputFormat, userPreset, trackSelection, handBrakeTitleId, audioFormatOptions } + */ +router.post( + '/jobs/:jobId/start', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const config = req.body || {}; + logger.info('post:jobs:start', { + jobId, + converterMediaType: config.converterMediaType, + outputFormat: config.outputFormat + }); + const result = await pipelineService.startConverterJob(jobId, config); + res.json({ result }); + }) +); + +/** + * POST /api/converter/jobs/:jobId/cancel + * Job abbrechen. + */ +router.post( + '/jobs/:jobId/cancel', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:jobs:cancel', { jobId }); + const result = await pipelineService.cancel(jobId); + res.json({ result }); + }) +); + +/** + * DELETE /api/converter/jobs/:jobId + * Job aus der DB löschen. + */ +router.delete( + '/jobs/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('delete:jobs', { jobId }); + await historyService.deleteJob(jobId, 'none', { includeRelated: false }); + await converterScanService.clearAssignmentsForJob(jobId); + res.json({ ok: true }); + }) +); + +module.exports = router; 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..ffa9378 --- /dev/null +++ b/backend/src/routes/historyRoutes.js @@ -0,0 +1,247 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const historyService = require('../services/historyService'); +const pipelineService = require('../services/pipelineService'); +const logger = require('../services/logger').child('HISTORY_ROUTE'); + +const router = express.Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + const parsedLimit = Number(req.query.limit); + const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.trunc(parsedLimit) + : null; + const statuses = String(req.query.statuses || '') + .split(',') + .map((value) => String(value || '').trim()) + .filter(Boolean); + const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase()); + const includeChildren = ['1', 'true', 'yes'].includes(String(req.query.includeChildren || '').toLowerCase()); + logger.info('get:jobs', { + reqId: req.reqId, + status: req.query.status, + statuses: statuses.length > 0 ? statuses : null, + search: req.query.search, + limit, + lite, + includeChildren + }); + + const jobs = await historyService.getJobs({ + status: req.query.status, + statuses, + search: req.query.search, + limit, + includeFsChecks: !lite, + includeChildren + }); + + res.json({ jobs }); + }) +); + +router.get( + '/orphan-raw', + asyncHandler(async (req, res) => { + logger.info('get:orphan-raw', { reqId: req.reqId }); + const result = await historyService.getOrphanRawFolders(); + res.json(result); + }) +); + +router.post( + '/orphan-raw/import', + asyncHandler(async (req, res) => { + const rawPath = String(req.body?.rawPath || '').trim(); + logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath }); + const job = await historyService.importOrphanRawFolder(rawPath); + const uiReset = await pipelineService.resetFrontendState('history_orphan_import'); + res.json({ job, uiReset }); + }) +); + +router.post( + '/:id/omdb/assign', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const payload = req.body || {}; + logger.info('post:job:omdb:assign', { + reqId: req.reqId, + id, + imdbId: payload?.imdbId || null, + hasTitle: Boolean(payload?.title), + hasYear: Boolean(payload?.year) + }); + + const job = await historyService.assignOmdbMetadata(id, payload); + + // Rename raw/output folders to reflect new metadata (best-effort, non-blocking) + pipelineService.renameJobFolders(id).catch((err) => { + logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message }); + }); + + res.json({ job }); + }) +); + +router.post( + '/:id/cd/assign', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const payload = req.body || {}; + logger.info('post:job:cd:assign', { + reqId: req.reqId, + id, + mbId: payload?.mbId || null, + hasTitle: Boolean(payload?.title), + hasArtist: Boolean(payload?.artist), + trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0 + }); + + const job = await historyService.assignCdMetadata(id, payload); + + // Rename raw/output folders to reflect new metadata (best-effort, non-blocking) + pipelineService.renameJobFolders(id).catch((err) => { + logger.warn('post:job:cd:assign:rename-failed', { id, error: err.message }); + }); + + res.json({ job }); + }) +); + +router.post( + '/:id/error/ack', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + logger.info('post:job:error:ack', { reqId: req.reqId, id }); + const job = await historyService.acknowledgeJobError(id); + res.json({ job }); + }) +); + +router.post( + '/:id/delete-files', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const target = String(req.body?.target || 'both'); + + logger.warn('post:delete-files', { + reqId: req.reqId, + id, + target + }); + + const result = await historyService.deleteJobFiles(id, target); + res.json(result); + }) +); + +router.get( + '/:id/delete-preview', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase()); + + logger.info('get:delete-preview', { + reqId: req.reqId, + id, + includeRelated + }); + + const preview = await historyService.getJobDeletePreview(id, { includeRelated }); + res.json({ preview }); + }) +); + +router.post( + '/:id/delete', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const target = String(req.body?.target || 'none'); + const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase()); + const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase()); + const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined + ? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase()) + : !resetDriveState; + const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths) + ? req.body.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + + logger.warn('post:delete-job', { + reqId: req.reqId, + id, + target, + includeRelated, + resetDriveState, + keepDetectedDevice, + selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0 + }); + + const preview = resetDriveState + ? await historyService.getJobDeletePreview(id, { includeRelated }) + : null; + const relatedDevicePaths = Array.isArray(preview?.relatedJobs) + ? preview.relatedJobs + .map((row) => String(row?.discDevice || '').trim()) + .filter(Boolean) + : []; + const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths }); + await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], { + resetDriveState, + devicePaths: relatedDevicePaths + }).catch((error) => { + logger.warn('post:delete-job:cleanup-failed', { id, error: error?.message || String(error) }); + }); + const uiReset = await pipelineService.resetFrontendState('history_delete', { + keepDetectedDevice + }); + res.json({ ...result, uiReset }); + }) +); + +router.get( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase()); + const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase()); + const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase()); + const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase()); + const parsedTail = Number(req.query.logTailLines); + const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 + ? Math.trunc(parsedTail) + : null; + const includeFsChecks = !(lite || includeLiveLog); + + logger.info('get:job-detail', { + reqId: req.reqId, + id, + includeLiveLog, + includeLogs, + includeAllLogs, + logTailLines, + lite, + includeFsChecks + }); + const job = await historyService.getJobWithLogs(id, { + includeLiveLog, + includeLogs, + includeAllLogs, + logTailLines, + includeFsChecks + }); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + res.json({ job }); + }) +); + +module.exports = router; diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js new file mode 100644 index 0000000..d2a7c2c --- /dev/null +++ b/backend/src/routes/pipelineRoutes.js @@ -0,0 +1,656 @@ +const express = require('express'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const multer = require('multer'); +const asyncHandler = require('../middleware/asyncHandler'); +const pipelineService = require('../services/pipelineService'); +const historyService = require('../services/historyService'); +const diskDetectionService = require('../services/diskDetectionService'); +const hardwareMonitorService = require('../services/hardwareMonitorService'); +const settingsService = require('../services/settingsService'); +const logger = require('../services/logger').child('PIPELINE_ROUTE'); +const activationBytesService = require('../services/activationBytesService'); +const { defaultAudiobookDir } = require('../config'); +const { getDb } = require('../db/database'); + +const router = express.Router(); +const audiobookUpload = multer({ + dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads') +}); + +const AUDIOBOOK_TREE_MAX_DEPTH = 8; + +function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) { + if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) { + return []; + } + const absDir = relPath ? path.join(rootDir, relPath) : rootDir; + let dirents = []; + try { + dirents = fs.readdirSync(absDir, { withFileTypes: true }); + } catch (_error) { + return []; + } + + dirents.sort((left, right) => { + const leftOrder = left.isDirectory() ? 0 : 1; + const rightOrder = right.isDirectory() ? 0 : 1; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }); + }); + + const nodes = []; + for (const dirent of dirents) { + if (dirent.name.startsWith('.')) { + continue; + } + const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name; + const childAbsPath = path.join(rootDir, childRelPath); + if (dirent.isDirectory()) { + const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1); + const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0); + let mtime = null; + try { + mtime = fs.statSync(childAbsPath).mtime.toISOString(); + } catch (_error) { + mtime = null; + } + nodes.push({ + name: dirent.name, + type: 'folder', + path: childRelPath, + size, + mtime, + children + }); + continue; + } + if (dirent.isFile()) { + let size = 0; + let mtime = null; + try { + const stat = fs.statSync(childAbsPath); + size = Number(stat?.size || 0); + mtime = stat?.mtime ? stat.mtime.toISOString() : null; + } catch (_error) { + size = 0; + mtime = null; + } + nodes.push({ + name: dirent.name, + type: 'file', + path: childRelPath, + size, + mtime + }); + } + } + return nodes; +} + +router.get( + '/state', + asyncHandler(async (req, res) => { + logger.debug('get:state', { reqId: req.reqId }); + res.json({ + pipeline: pipelineService.getSnapshot(), + hardwareMonitoring: hardwareMonitorService.getSnapshot() + }); + }) +); + +router.post( + '/analyze', + asyncHandler(async (req, res) => { + const devicePath = String(req.body?.devicePath || '').trim() || null; + logger.info('post:analyze', { reqId: req.reqId, devicePath }); + const result = await pipelineService.analyzeDisc(devicePath); + res.json({ result }); + }) +); + +router.get( + '/cd/drives', + asyncHandler(async (req, res) => { + logger.debug('get:cd:drives', { reqId: req.reqId }); + const snapshot = pipelineService.getSnapshot(); + res.json({ cdDrives: snapshot.cdDrives || {} }); + }) +); + +router.post( + '/rescan-disc', + asyncHandler(async (req, res) => { + logger.info('post:rescan-disc', { reqId: req.reqId }); + const result = await diskDetectionService.rescanAndEmit(); + res.json({ result }); + }) +); + +router.post( + '/rescan-drive', + asyncHandler(async (req, res) => { + const devicePath = String(req.body?.devicePath || '').trim(); + logger.info('post:rescan-drive', { reqId: req.reqId, devicePath }); + if (!devicePath) { + const err = new Error('devicePath ist erforderlich'); + err.statusCode = 400; + throw err; + } + const result = await diskDetectionService.rescanDriveAndEmit(devicePath); + res.json({ result }); + }) +); + +router.get( + '/omdb/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + logger.info('get:omdb:search', { reqId: req.reqId, query }); + const results = await pipelineService.searchOmdb(String(query)); + res.json({ results }); + }) +); + +router.get( + '/tmdb/series/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + const seasonNumber = req.query.season || null; + logger.info('get:tmdb:series-search', { reqId: req.reqId, query, seasonNumber }); + const results = await pipelineService.searchTmdbSeries(String(query), seasonNumber); + res.json({ results }); + }) +); + +router.get( + '/cd/musicbrainz/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query }); + const results = await pipelineService.searchMusicBrainz(String(query)); + res.json({ results }); + }) +); + +router.get( + '/cd/musicbrainz/release/:mbId', + asyncHandler(async (req, res) => { + const mbId = String(req.params.mbId || '').trim(); + if (!mbId) { + const error = new Error('mbId fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('get:cd:musicbrainz:release', { reqId: req.reqId, mbId }); + const release = await pipelineService.getMusicBrainzReleaseById(mbId); + res.json({ release }); + }) +); + +router.post( + '/cd/select-metadata', + asyncHandler(async (req, res) => { + const { jobId, title, artist, year, mbId, coverUrl, tracks } = req.body; + if (!jobId) { + const error = new Error('jobId fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:cd:select-metadata', { reqId: req.reqId, jobId, title, artist, year, mbId }); + const job = await pipelineService.selectCdMetadata({ + jobId: Number(jobId), + title, + artist, + year, + mbId, + coverUrl, + tracks + }); + res.json({ job }); + }) +); + +router.post( + '/cd/start/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const ripConfig = req.body || {}; + logger.info('post:cd:start', { + reqId: req.reqId, + jobId, + format: ripConfig.format, + selectedPreEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPreEncodeScriptIds) + ? ripConfig.selectedPreEncodeScriptIds.length + : 0, + selectedPostEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPostEncodeScriptIds) + ? ripConfig.selectedPostEncodeScriptIds.length + : 0, + selectedPreEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPreEncodeChainIds) + ? ripConfig.selectedPreEncodeChainIds.length + : 0, + selectedPostEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPostEncodeChainIds) + ? ripConfig.selectedPostEncodeChainIds.length + : 0 + }); + const result = await pipelineService.enqueueOrStartCdAction( + jobId, + ripConfig, + () => pipelineService.startCdRip(jobId, ripConfig) + ); + res.json({ result }); + }) +); + +router.post( + '/audiobook/upload', + audiobookUpload.single('file'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('Upload-Datei fehlt.'); + error.statusCode = 400; + throw error; + } + logger.info('post:audiobook:upload', { + reqId: req.reqId, + originalName: req.file.originalname, + sizeBytes: Number(req.file.size || 0), + mimeType: String(req.file.mimetype || '').trim() || null, + tempPath: String(req.file.path || '').trim() || null + }); + const result = await pipelineService.createFileJob({ + kind: 'audiobook_upload', + file: req.file, + options: { + format: req.body?.format, + startImmediately: req.body?.startImmediately + } + }); + res.json({ result }); + }) +); + +router.get( + '/audiobook/pending-activation', + asyncHandler(async (req, res) => { + const db = await getDb(); + // Jobs die eine Checksum haben, aber noch keine Activation Bytes im Cache + const pending = await db.all(` + SELECT j.id AS jobId, j.aax_checksum AS checksum + FROM jobs j + WHERE j.aax_checksum IS NOT NULL + AND j.status NOT IN ('DONE', 'ERROR', 'CANCELLED') + AND NOT EXISTS ( + SELECT 1 FROM aax_activation_bytes ab WHERE ab.checksum = j.aax_checksum + ) + ORDER BY j.created_at DESC + `); + res.json({ pending }); + }) +); + +router.post( + '/audiobook/start/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const config = req.body || {}; + logger.info('post:audiobook:start', { + reqId: req.reqId, + jobId, + format: config?.format, + formatOptions: config?.formatOptions && typeof config.formatOptions === 'object' + ? config.formatOptions + : null + }); + const result = await pipelineService.startAudiobookWithConfig(jobId, config); + res.json({ result }); + }) +); + +router.get( + '/audiobook/jobs', + asyncHandler(async (_req, res) => { + const jobs = await pipelineService.getAudiobookJobs(); + res.json({ jobs }); + }) +); + +router.get( + '/audiobook/output-tree', + asyncHandler(async (_req, res) => { + const settings = await settingsService.getEffectiveSettingsMap('audiobook'); + const configuredOutputDir = String(settings?.movie_dir || '').trim(); + const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null; + const outputDir = rawOutputDir + ? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir)) + : null; + + if (!outputDir || !fs.existsSync(outputDir)) { + res.json({ outputDir, tree: null }); + return; + } + + const children = buildAudiobookOutputTree(outputDir, '', 0); + const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0); + res.json({ + outputDir, + tree: { + name: path.basename(outputDir) || 'audiobooks', + type: 'folder', + path: '', + size, + mtime: null, + children + } + }); + }) +); + +router.post( + '/select-metadata', + asyncHandler(async (req, res) => { + const { + jobId, + title, + year, + imdbId, + poster, + fromOmdb, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds, + metadataProvider, + providerId, + tmdbId, + metadataKind, + seasonNumber, + seasonName, + episodeCount, + episodes, + discNumber + } = req.body; + + if (!jobId) { + const error = new Error('jobId fehlt.'); + error.statusCode = 400; + throw error; + } + + logger.info('post:select-metadata', { + reqId: req.reqId, + jobId, + title, + year, + imdbId, + poster, + fromOmdb, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, + metadataProvider, + providerId, + tmdbId, + seasonNumber, + discNumber + }); + + const job = await pipelineService.selectMetadata({ + jobId: Number(jobId), + title, + year, + imdbId, + poster, + fromOmdb, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds, + metadataProvider, + providerId, + tmdbId, + metadataKind, + seasonNumber, + seasonName, + episodeCount, + episodes, + discNumber + }); + + res.json({ job }); + }) +); + +router.post( + '/start/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:start-job', { reqId: req.reqId, jobId }); + const result = await pipelineService.startPreparedJob(jobId); + res.json({ result }); + }) +); + +router.post( + '/confirm-encode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null; + const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null; + const selectedTrackSelection = req.body?.selectedTrackSelection ?? null; + const episodeAssignments = req.body?.episodeAssignments ?? null; + const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds; + const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds; + const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds; + const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds; + const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate); + const selectedUserPresetId = req.body?.selectedUserPresetId ?? null; + const selectedHandBrakePreset = req.body?.selectedHandBrakePreset ?? null; + logger.info('post:confirm-encode', { + reqId: req.reqId, + jobId, + selectedEncodeTitleId, + selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0, + selectedTrackSelectionProvided: Boolean(selectedTrackSelection), + episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'), + skipPipelineStateUpdate, + selectedUserPresetId, + selectedHandBrakePreset, + selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds) + ? selectedPostEncodeScriptIds.length + : 0, + selectedPreEncodeScriptIdsCount: Array.isArray(selectedPreEncodeScriptIds) + ? selectedPreEncodeScriptIds.length + : 0, + selectedPostEncodeChainIdsCount: Array.isArray(selectedPostEncodeChainIds) + ? selectedPostEncodeChainIds.length + : 0, + selectedPreEncodeChainIdsCount: Array.isArray(selectedPreEncodeChainIds) + ? selectedPreEncodeChainIds.length + : 0 + }); + const job = await pipelineService.confirmEncodeReview(jobId, { + selectedEncodeTitleId, + selectedEncodeTitleIds, + selectedTrackSelection, + episodeAssignments, + selectedPostEncodeScriptIds, + selectedPreEncodeScriptIds, + selectedPostEncodeChainIds, + selectedPreEncodeChainIds, + skipPipelineStateUpdate, + selectedUserPresetId, + selectedHandBrakePreset + }); + res.json({ job }); + }) +); + +router.post( + '/cancel', + asyncHandler(async (req, res) => { + const rawJobId = req.body?.jobId; + const jobId = rawJobId === null || rawJobId === undefined || String(rawJobId).trim() === '' + ? null + : Number(rawJobId); + logger.warn('post:cancel', { reqId: req.reqId, jobId }); + const result = await pipelineService.cancel(jobId); + res.json({ result }); + }) +); + +router.post( + '/retry/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:retry', { reqId: req.reqId, jobId }); + const result = await pipelineService.retry(jobId); + res.json({ result }); + }) +); + +router.post( + '/resume-ready/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:resume-ready', { reqId: req.reqId, jobId }); + const job = await pipelineService.resumeReadyToEncodeJob(jobId); + res.json({ job }); + }) +); + +router.get( + '/output-folders/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const folders = await historyService.getJobOutputFoldersForLineage(jobId); + res.json({ folders }); + }) +); + +router.post( + '/delete-output-folders/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const folderPaths = Array.isArray(req.body?.folderPaths) ? req.body.folderPaths : []; + logger.info('post:delete-output-folders', { reqId: req.reqId, jobId, count: folderPaths.length }); + const result = await historyService.deleteSpecificOutputFolders(jobId, folderPaths); + res.json({ result }); + }) +); + +router.post( + '/reencode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + logger.info('post:reencode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); + const result = await pipelineService.reencodeFromRaw(jobId, { keepBoth, deleteFolders }); + res.json({ result }); + }) +); + +router.post( + '/restart-review/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob); + logger.info('post:restart-review', { + reqId: req.reqId, + jobId, + keepBoth, + reuseCurrentJob, + deleteFolderCount: deleteFolders.length + }); + const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders, reuseCurrentJob }); + res.json({ result }); + }) +); + +router.post( + '/restart-encode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all'; + logger.info('post:restart-encode', { + reqId: req.reqId, + jobId, + keepBoth, + restartMode, + deleteFolderCount: deleteFolders.length + }); + const result = await pipelineService.restartEncodeWithLastSettings(jobId, { + keepBoth, + deleteFolders, + restartMode + }); + res.json({ result }); + }) +); + +router.post( + '/restart-cd-review/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + logger.info('post:restart-cd-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); + const result = await pipelineService.restartCdReviewFromRaw(jobId, { keepBoth, deleteFolders }); + res.json({ result }); + }) +); + +router.get( + '/queue', + asyncHandler(async (req, res) => { + logger.debug('get:queue', { reqId: req.reqId }); + const queue = await pipelineService.getQueueSnapshot(); + res.json({ queue }); + }) +); + +router.post( + '/queue/reorder', + asyncHandler(async (req, res) => { + // Accept orderedEntryIds (new) or orderedJobIds (legacy fallback for job-only queues). + const orderedEntryIds = Array.isArray(req.body?.orderedEntryIds) + ? req.body.orderedEntryIds + : (Array.isArray(req.body?.orderedJobIds) ? req.body.orderedJobIds : []); + logger.info('post:queue:reorder', { reqId: req.reqId, orderedEntryIds }); + const queue = await pipelineService.reorderQueue(orderedEntryIds); + res.json({ queue }); + }) +); + +router.post( + '/queue/entry', + asyncHandler(async (req, res) => { + const { type, scriptId, chainId, waitSeconds, insertAfterEntryId } = req.body || {}; + logger.info('post:queue:entry', { reqId: req.reqId, type }); + const result = await pipelineService.enqueueNonJobEntry( + type, + { scriptId, chainId, waitSeconds }, + insertAfterEntryId ?? null + ); + const queue = await pipelineService.getQueueSnapshot(); + res.json({ result, queue }); + }) +); + +router.delete( + '/queue/entry/:entryId', + asyncHandler(async (req, res) => { + const entryId = req.params.entryId; + logger.info('delete:queue:entry', { reqId: req.reqId, entryId }); + const queue = await pipelineService.removeQueueEntry(entryId); + res.json({ queue }); + }) +); + +module.exports = router; 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..72f560c --- /dev/null +++ b/backend/src/routes/settingsRoutes.js @@ -0,0 +1,544 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const settingsService = require('../services/settingsService'); +const scriptService = require('../services/scriptService'); +const scriptChainService = require('../services/scriptChainService'); +const notificationService = require('../services/notificationService'); +const pipelineService = require('../services/pipelineService'); +const wsService = require('../services/websocketService'); +const hardwareMonitorService = require('../services/hardwareMonitorService'); +const userPresetService = require('../services/userPresetService'); +const activationBytesService = require('../services/activationBytesService'); +const diskDetectionService = require('../services/diskDetectionService'); +const coverArtRecoveryService = require('../services/coverArtRecoveryService'); +const logger = require('../services/logger').child('SETTINGS_ROUTE'); + +const { getDb } = require('../db/database'); + +const router = express.Router(); + +function isSensitiveSettingKey(key) { + const normalized = String(key || '').trim().toLowerCase(); + if (!normalized) { + return false; + } + return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized); +} + +router.get( + '/', + asyncHandler(async (req, res) => { + logger.debug('get:settings', { reqId: req.reqId }); + const categories = await settingsService.getCategorizedSettings(); + res.json({ categories }); + }) +); + +router.get( + '/effective-paths', + asyncHandler(async (req, res) => { + logger.debug('get:settings:effective-paths', { reqId: req.reqId }); + const paths = await settingsService.getEffectivePaths(); + res.json(paths); + }) +); + +router.get( + '/handbrake-presets', + asyncHandler(async (req, res) => { + logger.debug('get:settings:handbrake-presets', { reqId: req.reqId }); + const presets = await settingsService.getHandBrakePresetOptions(); + res.json(presets); + }) +); + +router.get( + '/scripts', + asyncHandler(async (req, res) => { + logger.debug('get:settings:scripts', { reqId: req.reqId }); + const scripts = await scriptService.listScripts(); + res.json({ scripts }); + }) +); + +router.post( + '/scripts', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:settings:scripts:create', { + reqId: req.reqId, + name: String(payload?.name || '').trim() || null, + scriptBodyLength: String(payload?.scriptBody || '').length + }); + const script = await scriptService.createScript(payload); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'created', id: script.id }); + res.status(201).json({ script }); + }) +); + +router.post( + '/scripts/reorder', + asyncHandler(async (req, res) => { + const orderedScriptIds = Array.isArray(req.body?.orderedScriptIds) ? req.body.orderedScriptIds : []; + logger.info('post:settings:scripts:reorder', { + reqId: req.reqId, + count: orderedScriptIds.length + }); + const scripts = await scriptService.reorderScripts(orderedScriptIds); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'reordered', count: scripts.length }); + res.json({ scripts }); + }) +); + +router.put( + '/scripts/:id', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:settings:scripts:update', { + reqId: req.reqId, + scriptId, + name: String(payload?.name || '').trim() || null, + scriptBodyLength: String(payload?.scriptBody || '').length + }); + const script = await scriptService.updateScript(scriptId, payload); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'updated', id: script.id }); + res.json({ script }); + }) +); + +router.delete( + '/scripts/:id', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + logger.info('delete:settings:scripts', { + reqId: req.reqId, + scriptId + }); + const removed = await scriptService.deleteScript(scriptId); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + +router.post( + '/scripts/:id/test', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + logger.info('post:settings:scripts:test', { + reqId: req.reqId, + scriptId + }); + const result = await scriptService.testScript(scriptId); + res.json({ result }); + }) +); + +router.post( + '/script-chains/:id/test', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + logger.info('post:settings:script-chains:test', { reqId: req.reqId, chainId }); + const result = await scriptChainService.executeChain(chainId, { source: 'settings_test', mode: 'test' }); + res.json({ result }); + }) +); + +router.get( + '/script-chains', + asyncHandler(async (req, res) => { + logger.debug('get:settings:script-chains', { reqId: req.reqId }); + const chains = await scriptChainService.listChains(); + res.json({ chains }); + }) +); + +router.post( + '/script-chains', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:settings:script-chains:create', { reqId: req.reqId, name: payload?.name }); + const chain = await scriptChainService.createChain(payload); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'created', id: chain.id }); + res.status(201).json({ chain }); + }) +); + +router.post( + '/script-chains/reorder', + asyncHandler(async (req, res) => { + const orderedChainIds = Array.isArray(req.body?.orderedChainIds) ? req.body.orderedChainIds : []; + logger.info('post:settings:script-chains:reorder', { + reqId: req.reqId, + count: orderedChainIds.length + }); + const chains = await scriptChainService.reorderChains(orderedChainIds); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'reordered', count: chains.length }); + res.json({ chains }); + }) +); + +router.get( + '/script-chains/:id', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + logger.debug('get:settings:script-chains:one', { reqId: req.reqId, chainId }); + const chain = await scriptChainService.getChainById(chainId); + res.json({ chain }); + }) +); + +router.put( + '/script-chains/:id', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:settings:script-chains:update', { reqId: req.reqId, chainId, name: payload?.name }); + const chain = await scriptChainService.updateChain(chainId, payload); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'updated', id: chain.id }); + res.json({ chain }); + }) +); + +router.delete( + '/script-chains/:id', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + logger.info('delete:settings:script-chains', { reqId: req.reqId, chainId }); + const removed = await scriptChainService.deleteChain(chainId); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + +router.post( + '/coverart/recover', + asyncHandler(async (req, res) => { + logger.info('post:settings:coverart:recover', { reqId: req.reqId }); + const result = await coverArtRecoveryService.runNow({ + trigger: 'manual', + force: true, + logFailures: true + }); + res.json({ + result, + scheduler: coverArtRecoveryService.getStatus() + }); + }) +); + +router.put( + '/:key', + asyncHandler(async (req, res) => { + const { key } = req.params; + const { value } = req.body; + + logger.info('put:setting', { + reqId: req.reqId, + key, + value: isSensitiveSettingKey(key) ? '[redacted]' : value + }); + const updated = await settingsService.setSettingValue(key, value); + let reviewRefresh = null; + try { + reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave([key]); + if (reviewRefresh?.triggered) { + logger.info('put:setting:review-refresh-started', { + reqId: req.reqId, + key, + jobId: reviewRefresh.jobId + }); + } + } catch (error) { + logger.warn('put:setting:review-refresh-failed', { + reqId: req.reqId, + key, + error: { + name: error?.name, + message: error?.message + } + }); + reviewRefresh = { + triggered: false, + reason: 'refresh_error', + message: error?.message || 'unknown' + }; + } + try { + await hardwareMonitorService.handleSettingsChanged([key]); + } catch (error) { + logger.warn('put:setting:hardware-monitor-refresh-failed', { + reqId: req.reqId, + key, + error: { + name: error?.name, + message: error?.message + } + }); + } + try { + await coverArtRecoveryService.handleSettingsChanged([key]); + } catch (error) { + logger.warn('put:setting:coverart-scheduler-refresh-failed', { + reqId: req.reqId, + key, + error: { + name: error?.name, + message: error?.message + } + }); + } + wsService.broadcast('SETTINGS_UPDATED', updated); + + res.json({ setting: updated, reviewRefresh }); + }) +); + +router.put( + '/', + asyncHandler(async (req, res) => { + const { settings } = req.body || {}; + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + const error = new Error('settings fehlt oder ist ungültig.'); + error.statusCode = 400; + throw error; + } + + logger.info('put:settings:bulk', { reqId: req.reqId, count: Object.keys(settings).length }); + const changes = await settingsService.setSettingsBulk(settings); + let reviewRefresh = null; + try { + reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave(changes.map((item) => item.key)); + if (reviewRefresh?.triggered) { + logger.info('put:settings:bulk:review-refresh-started', { + reqId: req.reqId, + jobId: reviewRefresh.jobId, + relevantKeys: reviewRefresh.relevantKeys + }); + } + } catch (error) { + logger.warn('put:settings:bulk:review-refresh-failed', { + reqId: req.reqId, + error: { + name: error?.name, + message: error?.message + } + }); + reviewRefresh = { + triggered: false, + reason: 'refresh_error', + message: error?.message || 'unknown' + }; + } + try { + await hardwareMonitorService.handleSettingsChanged(changes.map((item) => item.key)); + } catch (error) { + logger.warn('put:settings:bulk:hardware-monitor-refresh-failed', { + reqId: req.reqId, + error: { + name: error?.name, + message: error?.message + } + }); + } + try { + await coverArtRecoveryService.handleSettingsChanged(changes.map((item) => item.key)); + } catch (error) { + logger.warn('put:settings:bulk:coverart-scheduler-refresh-failed', { + reqId: req.reqId, + error: { + name: error?.name, + message: error?.message + } + }); + } + wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) }); + + res.json({ changes, reviewRefresh }); + }) +); + +// ── User Presets ────────────────────────────────────────────────────────────── + +router.get( + '/user-presets', + asyncHandler(async (req, res) => { + const mediaType = req.query.media_type || null; + logger.debug('get:user-presets', { reqId: req.reqId, mediaType }); + const presets = await userPresetService.listPresets(mediaType); + res.json({ presets }); + }) +); + +router.post( + '/user-presets', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name }); + const preset = await userPresetService.createPreset(payload); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id }); + res.status(201).json({ preset }); + }) +); + +router.put( + '/user-presets/:id', + asyncHandler(async (req, res) => { + const presetId = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:user-presets:update', { reqId: req.reqId, presetId }); + const preset = await userPresetService.updatePreset(presetId, payload); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id }); + res.json({ preset }); + }) +); + +router.delete( + '/user-presets/:id', + asyncHandler(async (req, res) => { + const presetId = Number(req.params.id); + logger.info('delete:user-presets', { reqId: req.reqId, presetId }); + const removed = await userPresetService.deletePreset(presetId); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + +router.post( + '/pushover/test', + asyncHandler(async (req, res) => { + const title = req.body?.title; + const message = req.body?.message; + logger.info('post:pushover:test', { + reqId: req.reqId, + hasTitle: Boolean(title), + hasMessage: Boolean(message) + }); + const result = await notificationService.sendTest({ title, message }); + res.json({ result }); + }) +); + +router.get( + '/activation-bytes', + asyncHandler(async (req, res) => { + logger.debug('get:settings:activation-bytes', { reqId: req.reqId }); + const entries = await activationBytesService.listCachedEntries(); + res.json({ entries }); + }) +); + +router.post( + '/activation-bytes', + asyncHandler(async (req, res) => { + const { checksum, activationBytes } = req.body || {}; + if (!checksum || !activationBytes) { + const error = new Error('checksum und activationBytes sind erforderlich'); + error.statusCode = 400; + throw error; + } + logger.debug('post:settings:activation-bytes', { reqId: req.reqId, checksum }); + const saved = await activationBytesService.saveActivationBytes(checksum, activationBytes); + res.json({ success: true, checksum, activationBytes: saved }); + }) +); + +// ── Optical drive scan ──────────────────────────────────────────────────────── +router.get( + '/drives', + asyncHandler(async (req, res) => { + logger.debug('get:settings:drives', { reqId: req.reqId }); + const devices = await diskDetectionService.getBlockDeviceInfo(); + const drives = devices + .filter((d) => d.type === 'rom') + .map((d) => { + const name = String(d.name || ''); + const devicePath = d.path || (name ? `/dev/${name}` : null); + const resolvedIndex = Number(d.makemkvIndex); + const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0 + ? Math.trunc(resolvedIndex) + : null; + return { + path: devicePath, + name, + model: d.model || null, + discIndex, + discArg: discIndex != null ? `disc:${discIndex}` : null + }; + }) + .filter((d) => d.path); + res.json({ drives }); + }) +); + +router.post( + '/drives/force-unlock', + asyncHandler(async (req, res) => { + const { devicePath, all } = req.body || {}; + const settings = await settingsService.getSettingsMap(); + const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase()); + if (!expertMode) { + const error = new Error('Expertenmodus erforderlich.'); + error.statusCode = 403; + throw error; + } + + const devices = await diskDetectionService.getBlockDeviceInfo(); + const drives = devices + .filter((d) => d.type === 'rom') + .map((d) => { + const name = String(d.name || ''); + return d.path || (name ? `/dev/${name}` : null); + }) + .filter(Boolean); + + const activeLockPaths = (diskDetectionService.getActiveLocks?.() || []) + .map((entry) => String(entry?.path || '').trim()) + .filter(Boolean); + + const targetPaths = all + ? Array.from(new Set([...drives, ...activeLockPaths])) + : [String(devicePath || '').trim()].filter(Boolean); + if (targetPaths.length === 0) { + const error = new Error('Kein Laufwerk angegeben.'); + error.statusCode = 400; + throw error; + } + + const results = []; + for (const target of targetPaths) { + const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' }); + results.push(result); + } + + res.json({ results }); + }) +); + +// User preferences (UI state, persisted per-installation) +router.get( + '/prefs/:key', + asyncHandler(async (req, res) => { + const { key } = req.params; + const db = await getDb(); + const row = await db.get('SELECT value FROM user_prefs WHERE key = ?', [key]); + res.json({ key, value: row ? row.value : null }); + }) +); + +router.put( + '/prefs/:key', + asyncHandler(async (req, res) => { + const { key } = req.params; + const { value } = req.body || {}; + const db = await getDb(); + await db.run( + `INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + [key, value ?? null] + ); + res.json({ key, value: value ?? null }); + }) +); + +module.exports = router; 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..1bc2646 --- /dev/null +++ b/backend/src/services/coverArtRecoveryService.js @@ -0,0 +1,393 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('COVERART_RECOVERY'); +const settingsService = require('./settingsService'); +const historyService = require('./historyService'); +const thumbnailService = require('./thumbnailService'); + +const COVERART_RECOVERY_ENABLED_KEY = 'coverart_recovery_enabled'; +const COVERART_RECOVERY_INTERVAL_HOURS_KEY = 'coverart_recovery_interval_hours'; +const DEFAULT_INTERVAL_HOURS = 6; +const MIN_INTERVAL_HOURS = 1; +const MAX_INTERVAL_HOURS = 168; +const RUNNING_JOB_STATUSES = new Set([ + 'ANALYZING', + 'RIPPING', + 'MEDIAINFO_CHECK', + 'ENCODING', + 'CD_ANALYZING', + 'CD_RIPPING', + 'CD_ENCODING' +]); + +function parseJsonSafe(raw, fallback = null) { + if (!raw) { + return fallback; + } + try { + return JSON.parse(raw); + } catch (_error) { + return fallback; + } +} + +function toBoolean(value, fallback = false) { + if (value === null || value === undefined) { + return fallback; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return fallback; + } + return ['1', 'true', 'yes', 'on'].includes(normalized); +} + +function normalizeIntervalHours(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return DEFAULT_INTERVAL_HOURS; + } + return Math.max(MIN_INTERVAL_HOURS, Math.min(MAX_INTERVAL_HOURS, Math.trunc(parsed))); +} + +function normalizeExternalUrl(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return null; + } + if (!/^https?:\/\//i.test(normalized)) { + return null; + } + return normalized; +} + +function deriveCoverArtArchiveUrl(mbId) { + const normalized = String(mbId || '').trim(); + if (!normalized) { + return null; + } + return `https://coverartarchive.org/release/${encodeURIComponent(normalized)}/front-250`; +} + +function isLikelyMusicBrainzId(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return false; + } + if (/^tt\d{6,12}$/i.test(normalized)) { + return false; + } + return /^[a-z0-9-]{8,}$/i.test(normalized); +} + +function collectCoverCandidates(row) { + const candidates = []; + const seen = new Set(); + const push = (url, source) => { + const normalized = normalizeExternalUrl(url); + if (!normalized) { + return; + } + const dedupeKey = normalized.toLowerCase(); + if (seen.has(dedupeKey)) { + return; + } + seen.add(dedupeKey); + candidates.push({ + url: normalized, + source: String(source || '').trim() || null + }); + }; + + push(row?.poster_url, 'job.poster_url'); + + const makemkvInfo = parseJsonSafe(row?.makemkv_info_json, {}); + const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + + push(selectedMetadata?.coverUrl, 'selectedMetadata.coverUrl'); + push(selectedMetadata?.poster, 'selectedMetadata.poster'); + push(selectedMetadata?.posterUrl, 'selectedMetadata.posterUrl'); + + const mbId = String( + selectedMetadata?.mbId + || selectedMetadata?.musicBrainzId + || selectedMetadata?.musicbrainzId + || selectedMetadata?.musicbrainz_id + || selectedMetadata?.music_brainz_id + || selectedMetadata?.musicbrainz + || selectedMetadata?.mbid + || row?.imdb_id + || '' + ).trim(); + if (isLikelyMusicBrainzId(mbId)) { + push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive'); + } + + const omdbInfo = parseJsonSafe(row?.omdb_json, {}); + const omdbPoster = String(omdbInfo?.Poster || '').trim(); + if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') { + push(omdbPoster, 'omdb_json.Poster'); + } + + return candidates; +} + +class CoverArtRecoveryService { + constructor() { + this.timer = null; + this.inFlight = null; + this.nextRunAt = null; + this.schedulerEnabled = false; + this.intervalHours = DEFAULT_INTERVAL_HOURS; + this.lastRunSummary = null; + } + + getStatus() { + return { + enabled: this.schedulerEnabled, + intervalHours: this.intervalHours, + nextRunAt: this.nextRunAt, + running: Boolean(this.inFlight), + lastRunSummary: this.lastRunSummary + }; + } + + stop() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.nextRunAt = null; + } + + async init() { + await this.refreshSchedule({ runStartupCheck: true }); + } + + async handleSettingsChanged(changedKeys = []) { + const normalizedKeys = Array.isArray(changedKeys) + ? changedKeys.map((key) => String(key || '').trim().toLowerCase()).filter(Boolean) + : []; + if ( + normalizedKeys.length > 0 + && !normalizedKeys.includes(COVERART_RECOVERY_ENABLED_KEY) + && !normalizedKeys.includes(COVERART_RECOVERY_INTERVAL_HOURS_KEY) + ) { + return this.getStatus(); + } + return this.refreshSchedule({ runStartupCheck: false }); + } + + async refreshSchedule(options = {}) { + const runStartupCheck = options?.runStartupCheck !== false; + this.stop(); + + const settings = await settingsService.getSettingsMap(); + this.schedulerEnabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true); + this.intervalHours = normalizeIntervalHours(settings?.[COVERART_RECOVERY_INTERVAL_HOURS_KEY]); + + logger.info('scheduler:refresh', { + enabled: this.schedulerEnabled, + intervalHours: this.intervalHours, + runStartupCheck + }); + + if (this.schedulerEnabled && runStartupCheck) { + this.runNow({ trigger: 'startup' }).catch((error) => { + logger.warn('scheduler:startup-run-failed', { + error: error?.message || String(error) + }); + }); + } + + if (this.schedulerEnabled) { + this.scheduleNextAutoRun(); + } + return this.getStatus(); + } + + scheduleNextAutoRun() { + this.stop(); + if (!this.schedulerEnabled) { + return; + } + const delayMs = this.intervalHours * 60 * 60 * 1000; + this.nextRunAt = new Date(Date.now() + delayMs).toISOString(); + this.timer = setTimeout(() => { + this.runNow({ trigger: 'auto' }) + .catch((error) => { + logger.warn('scheduler:auto-run-failed', { + error: error?.message || String(error) + }); + }) + .finally(() => { + this.scheduleNextAutoRun(); + }); + }, delayMs); + } + + async runNow(options = {}) { + if (this.inFlight) { + return this.inFlight; + } + let promise = null; + promise = this._runNowInternal(options) + .finally(() => { + if (this.inFlight === promise) { + this.inFlight = null; + } + }); + this.inFlight = promise; + return promise; + } + + async _runNowInternal(options = {}) { + const trigger = String(options?.trigger || 'manual').trim().toLowerCase() || 'manual'; + const force = Boolean(options?.force); + const logFailures = options?.logFailures !== false; + const startedMs = Date.now(); + const startedAt = new Date().toISOString(); + const settings = await settingsService.getSettingsMap(); + const enabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true); + + if (!enabled && !force) { + const skipped = { + trigger, + startedAt, + finishedAt: new Date().toISOString(), + durationMs: 0, + skipped: true, + reason: 'disabled' + }; + this.lastRunSummary = skipped; + return skipped; + } + + const db = await getDb(); + const rows = await db.all( + ` + SELECT + id, + title, + detected_title, + status, + poster_url, + imdb_id, + makemkv_info_json, + omdb_json + FROM jobs + ORDER BY COALESCE(updated_at, created_at) DESC, id DESC + ` + ); + + const summary = { + trigger, + startedAt, + finishedAt: null, + durationMs: 0, + scannedJobs: Array.isArray(rows) ? rows.length : 0, + runningSkipped: 0, + alreadyLocal: 0, + noCandidate: 0, + recovered: 0, + failed: 0, + failedJobs: [] + }; + + for (const row of rows || []) { + const jobId = Number(row?.id || 0); + if (!jobId) { + continue; + } + + const status = String(row?.status || '').trim().toUpperCase(); + if (RUNNING_JOB_STATUSES.has(status)) { + summary.runningSkipped += 1; + continue; + } + + const currentPosterUrl = String(row?.poster_url || '').trim(); + if ( + currentPosterUrl + && thumbnailService.isLocalUrl(currentPosterUrl) + && thumbnailService.localThumbnailUrlExists(currentPosterUrl) + ) { + summary.alreadyLocal += 1; + continue; + } + + const candidates = collectCoverCandidates(row); + if (candidates.length === 0) { + summary.noCandidate += 1; + continue; + } + + let recovered = false; + let lastError = null; + for (const candidate of candidates) { + const result = await historyService.cacheAndPromoteExternalPoster(jobId, candidate.url, { + source: 'Coverart', + logFailures: false + }); + if (result?.ok && result?.localUrl) { + recovered = true; + summary.recovered += 1; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Coverart nachgeladen (${trigger}): ${candidate.url}${candidate?.source ? ` [${candidate.source}]` : ''}` + ); + break; + } + lastError = String(result?.error || result?.reason || 'download_failed'); + } + + if (!recovered) { + summary.failed += 1; + const failedInfo = { + jobId, + title: String(row?.title || row?.detected_title || `Job #${jobId}`), + attemptedUrls: candidates.map((item) => item.url), + error: lastError + }; + summary.failedJobs.push(failedInfo); + if (logFailures && trigger !== 'auto') { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Coverart-Download fehlgeschlagen (${trigger}). Versuchte Links: ${failedInfo.attemptedUrls.join(', ')}${lastError ? ` | Fehler: ${lastError}` : ''}` + ); + } + } + } + + const finishedAt = new Date().toISOString(); + const durationMs = Math.max(0, Date.now() - startedMs); + const result = { + ...summary, + finishedAt, + durationMs + }; + this.lastRunSummary = result; + logger.info('recovery:done', { + trigger: result.trigger, + scannedJobs: result.scannedJobs, + recovered: result.recovered, + failed: result.failed, + alreadyLocal: result.alreadyLocal, + noCandidate: result.noCandidate, + runningSkipped: result.runningSkipped, + durationMs: result.durationMs + }); + return result; + } +} + +module.exports = new CoverArtRecoveryService(); 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..c268aa8 --- /dev/null +++ b/backend/src/services/dvdSeriesScanService.js @@ -0,0 +1,498 @@ +'use strict'; + +const TITLE_KIND = Object.freeze({ + EPISODE_CANDIDATE: 'episode_candidate', + PLAY_ALL: 'play_all', + EXTRA: 'extra', + DUPLICATE: 'duplicate', + SHORT: 'short', + UNKNOWN: 'unknown' +}); + +const DEFAULTS = Object.freeze({ + minEpisodeMinutes: 18, + maxEpisodeMinutes: 75, + minChapterCount: 3, + shortTitleMinutes: 5 +}); + +function nowIso() { + return new Date().toISOString(); +} + +function parseDurationToSeconds(rawValue) { + const text = String(rawValue || '').trim(); + const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/); + if (!match) { + return 0; + } + return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]); +} + +function roundToStep(value, step) { + const num = Number(value); + if (!Number.isFinite(num) || step <= 0) { + return 0; + } + return Math.round(num / step) * step; +} + +function median(values = []) { + const nums = (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((left, right) => left - right); + if (nums.length === 0) { + return 0; + } + const middle = Math.floor(nums.length / 2); + if (nums.length % 2 === 1) { + return nums[middle]; + } + return (nums[middle - 1] + nums[middle]) / 2; +} + +function normalizeLanguageCode(rawCode, fallbackLabel = null) { + const raw = String(rawCode || '').trim().toLowerCase(); + if (raw && raw.length === 3) { + return raw; + } + const label = String(fallbackLabel || '').trim().toLowerCase(); + if (label.startsWith('de')) return 'deu'; + if (label.startsWith('en')) return 'eng'; + if (label.startsWith('fr')) return 'fra'; + if (label.startsWith('es')) return 'spa'; + if (label.startsWith('nl')) return 'nld'; + return raw || 'und'; +} + +function normalizeSeriesLookupTitle(rawValue) { + return String(rawValue || '') + .replace(/[_./]+/g, ' ') + .replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ') + .replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ') + .replace(/\b\d{1,2}x\b/gi, ' ') + .replace(/\b(?:complete|collection|boxset)\b/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function deriveSeriesLookupHint(inputs = []) { + const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs]) + .map((entry) => { + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + return { + value: String(entry.value || '').trim(), + source: String(entry.source || '').trim() || 'unknown' + }; + } + return { + value: String(entry || '').trim(), + source: 'unknown' + }; + }) + .filter((entry) => entry.value); + + for (const entry of normalizedInputs) { + const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim(); + if (!compactValue) { + continue; + } + + const seasonMatch = compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i) + || compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i); + if (!seasonMatch) { + continue; + } + + const seasonNumber = Number(seasonMatch[1] || 0); + if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) { + continue; + } + + const discMatch = compactValue.match(/(?:^|\s)(?:disc|dvd|cd)\s*(\d{1,2})(?=\s|$)/i); + const discNumber = discMatch + ? Number(discMatch[1] || 0) || null + : null; + + const baseTitle = normalizeSeriesLookupTitle(compactValue); + if (!baseTitle) { + continue; + } + + return { + query: baseTitle, + seriesTitle: baseTitle, + seasonNumber, + discNumber, + source: entry.source, + sourceLabel: entry.value, + confidence: discNumber ? 'high' : 'medium' + }; + } + + return null; +} + +function normalizeTitleRecord(title = {}) { + const durationSeconds = Number(title.durationSeconds || 0); + const chapterCount = Number(title.chapterCount || 0); + const roundedDurationBucket = roundToStep(durationSeconds, 30); + const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : []) + .map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel)) + .filter(Boolean) + .sort(); + const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : []) + .map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel)) + .filter(Boolean) + .sort(); + + return { + index: Number(title.index || 0), + durationSeconds, + durationLabel: String(title.durationLabel || '').trim() || null, + chapterCount, + chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [], + aspectRatio: String(title.aspectRatio || '').trim() || null, + audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [], + subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [], + flags: Array.isArray(title.flags) ? title.flags : [], + roundedDurationBucket, + signature: JSON.stringify({ + duration: roundedDurationBucket, + chapterCount, + audioLanguages, + subtitleLanguages + }) + }; +} + +function buildBestEpisodeCluster(titles = [], options = {}) { + const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60)); + const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60)); + const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount)); + const candidates = titles.filter((title) => + title.durationSeconds >= minEpisodeSeconds + && title.durationSeconds <= maxEpisodeSeconds + && title.chapterCount >= minChapterCount + ); + + if (candidates.length === 0) { + return { + titles: [], + medianDurationSeconds: 0, + medianChapterCount: 0 + }; + } + + let bestCluster = []; + for (const anchor of candidates) { + const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12)); + const cluster = candidates.filter((candidate) => { + const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds); + const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount); + return durationGap <= durationTolerance && chapterGap <= 2; + }); + + if (cluster.length > bestCluster.length) { + bestCluster = cluster; + continue; + } + if (cluster.length === bestCluster.length) { + const clusterMedian = median(cluster.map((item) => item.durationSeconds)); + const bestMedian = median(bestCluster.map((item) => item.durationSeconds)); + if (clusterMedian > bestMedian) { + bestCluster = cluster; + } + } + } + + return { + titles: bestCluster, + medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)), + medianChapterCount: median(bestCluster.map((item) => item.chapterCount)) + }; +} + +function classifyTitles(titles = [], cluster = {}, options = {}) { + const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60)); + const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index))); + const duplicateFirstBySignature = new Map(); + + for (const title of titles) { + if (!duplicateFirstBySignature.has(title.signature)) { + duplicateFirstBySignature.set(title.signature, title.index); + } + } + + const playAllCandidate = (() => { + if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) { + return null; + } + const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1); + return titles + .filter((title) => + !clusterTitleIds.has(Number(title.index)) + && title.durationSeconds >= minPlayAllSeconds * 0.75 + && title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6)) + ) + .sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null; + })(); + + return titles.map((title) => { + const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index; + const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds; + const isEpisodeCandidate = clusterTitleIds.has(Number(title.index)); + const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index); + const kind = isPlayAll + ? TITLE_KIND.PLAY_ALL + : isEpisodeCandidate + ? TITLE_KIND.EPISODE_CANDIDATE + : !isFirstWithSignature + ? TITLE_KIND.DUPLICATE + : isShort + ? TITLE_KIND.SHORT + : (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN); + + let confidence = 0.2; + if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8; + if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95; + if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85; + if (kind === TITLE_KIND.SHORT) confidence = 0.9; + if (kind === TITLE_KIND.EXTRA) confidence = 0.55; + + return { + ...title, + kind, + confidence, + duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE + ? duplicateFirstBySignature.get(title.signature) + : null + }; + }); +} + +function buildDiscSignature(parsedScan = {}) { + return JSON.stringify({ + discTitle: String(parsedScan.discTitle || '').trim() || null, + discSerial: String(parsedScan.discSerial || '').trim() || null, + titleCount: Number(parsedScan.titleCount || 0), + durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : []) + .map((title) => ({ + index: Number(title.index || 0), + durationBucket: roundToStep(title.durationSeconds || 0, 30), + chapterCount: Number(title.chapterCount || 0) + })) + }); +} + +function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) { + const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length; + const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length; + const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length; + const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length; + + const reasons = []; + let confidence = 'low'; + let seriesLike = false; + + if (episodeCount >= 3) { + seriesLike = true; + confidence = playAllCount > 0 ? 'high' : 'medium'; + reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`); + } else if (episodeCount === 2) { + seriesLike = true; + confidence = 'medium'; + reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.'); + } + + if (playAllCount > 0) { + reasons.push('Ein langer Play-All-Titel wurde erkannt.'); + } + if (duplicateCount > 0) { + reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`); + } + if (cluster.medianDurationSeconds > 0) { + reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`); + } + if (extrasCount > 0) { + reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`); + } + + return { + seriesLike, + confidence, + reasons + }; +} + +function parseHandBrakeScanText(rawOutput) { + const lines = String(rawOutput || '').split(/\r?\n/); + const parsed = { + source: 'handbrake_scan_text', + generatedAt: nowIso(), + discTitle: null, + discSerial: null, + titleCount: 0, + rawLineCount: lines.length, + titles: [] + }; + + let currentTitle = null; + const ensureCurrentTitle = (index) => { + const normalizedIndex = Number(index); + if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) { + return null; + } + if (currentTitle && Number(currentTitle.index) === normalizedIndex) { + return currentTitle; + } + const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex); + if (existing) { + currentTitle = existing; + return currentTitle; + } + currentTitle = { + index: normalizedIndex, + durationLabel: null, + durationSeconds: 0, + chapterCount: 0, + chapterDurationsMs: [], + audioTracks: [], + subtitleTracks: [], + aspectRatio: null, + flags: [] + }; + parsed.titles.push(currentTitle); + return currentTitle; + }; + + for (const line of lines) { + let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i); + if (match) { + parsed.discTitle = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i); + if (match) { + parsed.discSerial = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i); + if (match) { + parsed.titleCount = Number(match[1] || 0); + continue; + } + + match = line.match(/scan:\s+scanning title\s+(\d+)/i); + if (match) { + ensureCurrentTitle(match[1]); + continue; + } + + match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i); + if (match && currentTitle) { + currentTitle.durationLabel = match[1]; + currentTitle.durationSeconds = parseDurationToSeconds(match[1]); + continue; + } + + match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i); + if (match) { + const title = ensureCurrentTitle(match[1]); + if (title) { + title.chapterCount = Number(match[2] || 0); + } + continue; + } + + match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i); + if (match && currentTitle) { + currentTitle.chapterDurationsMs.push(Number(match[1] || 0)); + continue; + } + + match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i); + if (match && currentTitle) { + const track = { + languageLabel: String(match[1] || '').trim() || null, + languageCode: normalizeLanguageCode(match[2], match[1]) + }; + if (/\[VOBSUB\]/i.test(line)) { + currentTitle.subtitleTracks.push(track); + } else { + currentTitle.audioTracks.push(track); + } + continue; + } + + match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i); + if (match && currentTitle) { + currentTitle.aspectRatio = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/scan:\s+ignoring title \(too short\)/i); + if (match && currentTitle) { + currentTitle.flags.push('ignored_too_short'); + } + } + + parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0)); + if (!parsed.titleCount) { + parsed.titleCount = parsed.titles.length; + } + return parsed; +} + +function analyzeParsedScan(parsedScan = {}, options = {}) { + const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord); + const cluster = buildBestEpisodeCluster(titles, options); + const classifiedTitles = classifyTitles(titles, cluster, options); + const summary = summarizeSeriesLikelihood(classifiedTitles, cluster); + + return { + source: parsedScan.source || 'handbrake_scan_text', + generatedAt: nowIso(), + discTitle: parsedScan.discTitle || null, + discSerial: parsedScan.discSerial || null, + titleCount: Number(parsedScan.titleCount || titles.length || 0), + discSignature: buildDiscSignature({ + discTitle: parsedScan.discTitle, + discSerial: parsedScan.discSerial, + titleCount: parsedScan.titleCount, + titles + }), + summary: { + ...summary, + titleCount: classifiedTitles.length, + episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length, + playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length, + duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length, + extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length, + typicalEpisodeMinutes: cluster.medianDurationSeconds + ? Number((cluster.medianDurationSeconds / 60).toFixed(2)) + : 0 + }, + titles: classifiedTitles + }; +} + +function analyzeHandBrakeScan(rawOutput, options = {}) { + const parsed = parseHandBrakeScanText(rawOutput); + const analysis = analyzeParsedScan(parsed, options); + return { + parsed, + analysis + }; +} + +module.exports = { + TITLE_KIND, + parseHandBrakeScanText, + analyzeParsedScan, + analyzeHandBrakeScan, + deriveSeriesLookupHint +}; diff --git a/backend/src/services/hardwareMonitorService.js b/backend/src/services/hardwareMonitorService.js new file mode 100644 index 0000000..ae3e533 --- /dev/null +++ b/backend/src/services/hardwareMonitorService.js @@ -0,0 +1,1105 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const settingsService = require('./settingsService'); +const wsService = require('./websocketService'); +const logger = require('./logger').child('HWMON'); +const { errorToMeta } = require('../utils/errorMeta'); + +const execFileAsync = promisify(execFile); + +const DEFAULT_INTERVAL_MS = 5000; +const MIN_INTERVAL_MS = 1000; +const MAX_INTERVAL_MS = 60000; +const DF_TIMEOUT_MS = 1800; +const SENSORS_TIMEOUT_MS = 1800; +const NVIDIA_SMI_TIMEOUT_MS = 1800; +const RELEVANT_SETTINGS_KEYS = new Set([ + 'hardware_monitoring_enabled', + 'hardware_monitoring_interval_ms', + 'raw_dir', + 'raw_dir_bluray', + 'raw_dir_dvd', + 'raw_dir_cd', + 'movie_dir', + 'movie_dir_bluray', + 'movie_dir_dvd', + 'log_dir', + 'external_storage_paths' +]); + +function nowIso() { + return new Date().toISOString(); +} + +function toBoolean(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return false; + } + if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') { + return true; + } + if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') { + return false; + } + return Boolean(normalized); +} + +function normalizePathSetting(value) { + return String(value || '').trim(); +} + +function 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.lastCpuTimes = null; + this.sensorsCommandAvailable = null; + this.nvidiaSmiAvailable = null; + this.lastSnapshot = { + enabled: false, + intervalMs: DEFAULT_INTERVAL_MS, + updatedAt: null, + sample: null, + error: null + }; + } + + async init() { + await this.reloadFromSettings({ + forceBroadcast: true, + forceImmediatePoll: true + }); + } + + stop() { + this.stopPolling(); + } + + getSnapshot() { + return { + enabled: Boolean(this.lastSnapshot?.enabled), + intervalMs: Number(this.lastSnapshot?.intervalMs || DEFAULT_INTERVAL_MS), + updatedAt: this.lastSnapshot?.updatedAt || null, + sample: this.lastSnapshot?.sample || null, + error: this.lastSnapshot?.error || null + }; + } + + async handleSettingsChanged(changedKeys = []) { + const normalizedKeys = (Array.isArray(changedKeys) ? changedKeys : []) + .map((key) => String(key || '').trim().toLowerCase()) + .filter(Boolean); + + if (normalizedKeys.length === 0) { + return; + } + + const relevant = normalizedKeys.some((key) => RELEVANT_SETTINGS_KEYS.has(key)); + if (!relevant) { + return; + } + + await this.reloadFromSettings({ + forceImmediatePoll: true + }); + } + + async reloadFromSettings(options = {}) { + const forceBroadcast = Boolean(options?.forceBroadcast); + const forceImmediatePoll = Boolean(options?.forceImmediatePoll); + let settingsMap = {}; + try { + settingsMap = await settingsService.getSettingsMap(); + } catch (error) { + logger.warn('settings:load:failed', { error: errorToMeta(error) }); + return this.getSnapshot(); + } + + const nextEnabled = toBoolean(settingsMap.hardware_monitoring_enabled); + const nextIntervalMs = clampIntervalMs(settingsMap.hardware_monitoring_interval_ms); + const nextPaths = this.buildMonitoredPaths(settingsMap); + const wasEnabled = this.enabled; + const intervalChanged = nextIntervalMs !== this.intervalMs; + const pathsChanged = this.pathsSignature(this.monitoredPaths) !== this.pathsSignature(nextPaths); + + this.enabled = nextEnabled; + this.intervalMs = nextIntervalMs; + this.monitoredPaths = nextPaths; + this.lastSnapshot = { + ...this.lastSnapshot, + enabled: this.enabled, + intervalMs: this.intervalMs + }; + + if (!this.enabled) { + this.stopPolling(); + this.lastSnapshot = { + enabled: false, + intervalMs: this.intervalMs, + updatedAt: nowIso(), + sample: null, + error: null + }; + this.broadcastUpdate(); + return this.getSnapshot(); + } + + if (!this.running) { + this.startPolling(); + } else if (intervalChanged || pathsChanged || forceImmediatePoll || !wasEnabled) { + this.scheduleNext(25); + } + + if (forceBroadcast || intervalChanged || !wasEnabled) { + this.broadcastUpdate(); + } + + return this.getSnapshot(); + } + + buildMonitoredPaths(settingsMap = {}) { + const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; + const bluray = settingsService.resolveEffectiveToolSettings(sourceMap, 'bluray'); + const dvd = settingsService.resolveEffectiveToolSettings(sourceMap, 'dvd'); + const cd = settingsService.resolveEffectiveToolSettings(sourceMap, 'cd'); + const blurayRawPath = normalizePathSetting(bluray?.raw_dir); + const dvdRawPath = normalizePathSetting(dvd?.raw_dir); + const cdRawPath = normalizePathSetting(cd?.raw_dir); + const blurayMoviePath = normalizePathSetting(bluray?.movie_dir); + const dvdMoviePath = normalizePathSetting(dvd?.movie_dir); + const 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.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (wasActive) { + logger.info('stop'); + } + } + + scheduleNext(delayMs) { + if (!this.running) { + return; + } + if (this.timer) { + clearTimeout(this.timer); + } + const delay = Math.max(0, Math.trunc(Number(delayMs) || this.intervalMs)); + this.timer = setTimeout(() => { + this.timer = null; + void this.pollOnce(); + }, delay); + } + + async pollOnce() { + if (!this.running || !this.enabled) { + return; + } + if (this.pollInFlight) { + this.scheduleNext(this.intervalMs); + return; + } + this.pollInFlight = true; + try { + const sample = await this.collectSample(); + this.lastSnapshot = { + enabled: true, + intervalMs: this.intervalMs, + updatedAt: nowIso(), + sample, + error: null + }; + this.broadcastUpdate(); + } catch (error) { + logger.warn('poll:failed', { error: errorToMeta(error) }); + this.lastSnapshot = { + ...this.lastSnapshot, + enabled: true, + intervalMs: this.intervalMs, + updatedAt: nowIso(), + error: error?.message || 'Hardware-Monitoring fehlgeschlagen.' + }; + this.broadcastUpdate(); + } finally { + this.pollInFlight = false; + if (this.running && this.enabled) { + this.scheduleNext(this.intervalMs); + } + } + } + + broadcastUpdate() { + wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot()); + } + + async collectSample() { + const memory = this.collectMemoryMetrics(); + const [cpu, gpu, storage] = await Promise.all([ + this.collectCpuMetrics(), + this.collectGpuMetrics(), + this.collectStorageMetrics() + ]); + + return { + cpu, + memory, + gpu, + storage + }; + } + + collectMemoryMetrics() { + const totalBytes = Number(os.totalmem() || 0); + const freeBytes = Number(os.freemem() || 0); + const usedBytes = Math.max(0, totalBytes - freeBytes); + const usagePercent = totalBytes > 0 + ? roundNumber((usedBytes / totalBytes) * 100, 1) + : null; + return { + totalBytes, + usedBytes, + freeBytes, + usagePercent + }; + } + + getCpuTimes() { + const cpus = os.cpus() || []; + return cpus.map((cpu) => { + const times = cpu?.times || {}; + const idle = Number(times.idle || 0); + const total = Object.values(times).reduce((sum, value) => sum + Number(value || 0), 0); + return { idle, total }; + }); + } + + calculateCpuUsage(currentTimes = [], previousTimes = []) { + const perCore = []; + const coreCount = Math.min(currentTimes.length, previousTimes.length); + if (coreCount <= 0) { + return { + overallUsagePercent: null, + perCore + }; + } + + let totalDelta = 0; + let idleDelta = 0; + for (let index = 0; index < coreCount; index += 1) { + const prev = previousTimes[index]; + const cur = currentTimes[index]; + const deltaTotal = Number(cur?.total || 0) - Number(prev?.total || 0); + const deltaIdle = Number(cur?.idle || 0) - Number(prev?.idle || 0); + const usage = deltaTotal > 0 + ? roundNumber(((deltaTotal - deltaIdle) / deltaTotal) * 100, 1) + : null; + perCore.push({ + index, + usagePercent: usage + }); + if (deltaTotal > 0) { + totalDelta += deltaTotal; + idleDelta += deltaIdle; + } + } + + const overallUsagePercent = totalDelta > 0 + ? roundNumber(((totalDelta - idleDelta) / totalDelta) * 100, 1) + : null; + return { + overallUsagePercent, + perCore + }; + } + + async collectCpuMetrics() { + const cpus = os.cpus() || []; + const currentTimes = this.getCpuTimes(); + const usage = this.calculateCpuUsage(currentTimes, this.lastCpuTimes || []); + this.lastCpuTimes = currentTimes; + + const tempMetrics = await this.collectCpuTemperatures(); + const tempByCoreIndex = new Map( + (tempMetrics.perCore || []).map((item) => [Number(item.index), item.temperatureC]) + ); + + const perCore = usage.perCore.map((entry) => ({ + index: entry.index, + usagePercent: entry.usagePercent, + temperatureC: tempByCoreIndex.has(entry.index) ? tempByCoreIndex.get(entry.index) : null + })); + + for (const tempEntry of tempMetrics.perCore || []) { + const index = Number(tempEntry?.index); + if (!Number.isFinite(index) || perCore.some((item) => item.index === index)) { + continue; + } + perCore.push({ + index, + usagePercent: null, + temperatureC: tempEntry.temperatureC + }); + } + perCore.sort((a, b) => a.index - b.index); + + return { + model: cpus[0]?.model || null, + logicalCoreCount: cpus.length, + loadAverage: os.loadavg().map((value) => roundNumber(value, 2)), + overallUsagePercent: usage.overallUsagePercent, + overallTemperatureC: tempMetrics.overallC, + usageAvailable: usage.overallUsagePercent !== null, + temperatureAvailable: Boolean(tempMetrics.available), + temperatureSource: tempMetrics.source, + perCore + }; + } + + async collectCpuTemperatures() { + const sensors = await this.collectTempsViaSensors(); + if (sensors.available) { + return sensors; + } + + const hwmon = this.collectTempsViaHwmon(); + if (hwmon.available) { + return hwmon; + } + + const thermalZones = this.collectTempsViaThermalZones(); + if (thermalZones.available) { + return thermalZones; + } + + return { + source: 'none', + overallC: null, + perCore: [], + available: false + }; + } + + async collectTempsViaSensors() { + if (this.sensorsCommandAvailable === false) { + return { + source: 'sensors', + overallC: null, + perCore: [], + available: false + }; + } + + try { + const { stdout } = await execFileAsync('sensors', ['-j'], { + timeout: SENSORS_TIMEOUT_MS, + maxBuffer: 2 * 1024 * 1024 + }); + this.sensorsCommandAvailable = true; + const parsed = JSON.parse(String(stdout || '{}')); + const candidates = collectTemperatureCandidates(parsed); + const preferred = preferCpuTemperatureCandidates(candidates); + return { + source: 'sensors', + ...mapTemperatureCandidates(preferred) + }; + } catch (error) { + if (isCommandMissingError(error)) { + this.sensorsCommandAvailable = false; + } + logger.debug('cpu-temp:sensors:failed', { error: errorToMeta(error) }); + return { + source: 'sensors', + overallC: null, + perCore: [], + available: false + }; + } + } + + collectTempsViaHwmon() { + const hwmonRoot = '/sys/class/hwmon'; + if (!fs.existsSync(hwmonRoot)) { + return { + source: 'hwmon', + overallC: null, + perCore: [], + available: false + }; + } + + const candidates = []; + let dirs = []; + try { + dirs = fs.readdirSync(hwmonRoot, { withFileTypes: true }); + } catch (_error) { + dirs = []; + } + + for (const dir of dirs) { + if (!dir.isDirectory()) { + continue; + } + const basePath = path.join(hwmonRoot, dir.name); + const sensorName = readTextFileSafe(path.join(basePath, 'name')) || dir.name; + let files = []; + try { + files = fs.readdirSync(basePath); + } catch (_error) { + files = []; + } + const tempInputFiles = files.filter((file) => /^temp\d+_input$/i.test(file)); + + for (const fileName of tempInputFiles) { + const tempValue = normalizeTempC(readTextFileSafe(path.join(basePath, fileName))); + if (tempValue === null) { + continue; + } + const labelFile = fileName.replace('_input', '_label'); + const label = readTextFileSafe(path.join(basePath, labelFile)) || fileName; + candidates.push({ + label: `${sensorName} / ${label}`, + value: tempValue + }); + } + } + + return { + source: 'hwmon', + ...mapTemperatureCandidates(preferCpuTemperatureCandidates(candidates)) + }; + } + + collectTempsViaThermalZones() { + const thermalRoot = '/sys/class/thermal'; + if (!fs.existsSync(thermalRoot)) { + return { + source: 'thermal_zone', + overallC: null, + perCore: [], + available: false + }; + } + + let files = []; + try { + files = fs.readdirSync(thermalRoot, { withFileTypes: true }); + } catch (_error) { + files = []; + } + + const candidates = []; + for (const dir of files) { + if (!dir.isDirectory() || !dir.name.startsWith('thermal_zone')) { + continue; + } + const basePath = path.join(thermalRoot, dir.name); + const tempC = normalizeTempC(readTextFileSafe(path.join(basePath, 'temp'))); + if (tempC === null) { + continue; + } + const zoneType = readTextFileSafe(path.join(basePath, 'type')) || dir.name; + candidates.push({ + label: `${zoneType} / temp`, + value: tempC + }); + } + + return { + source: 'thermal_zone', + ...mapTemperatureCandidates(preferCpuTemperatureCandidates(candidates)) + }; + } + + async collectGpuMetrics() { + if (this.nvidiaSmiAvailable === false) { + return { + source: 'nvidia-smi', + available: false, + devices: [], + message: 'nvidia-smi ist nicht verfuegbar.' + }; + } + + try { + const { stdout } = await execFileAsync( + 'nvidia-smi', + [ + '--query-gpu=index,name,utilization.gpu,utilization.memory,temperature.gpu,memory.used,memory.total,power.draw,power.limit,fan.speed', + '--format=csv,noheader,nounits' + ], + { + timeout: NVIDIA_SMI_TIMEOUT_MS, + maxBuffer: 1024 * 1024 + } + ); + + this.nvidiaSmiAvailable = true; + const devices = String(stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => parseNvidiaCsvLine(line)) + .filter(Boolean); + + if (devices.length === 0) { + return { + source: 'nvidia-smi', + available: false, + devices: [], + message: 'Keine GPU-Daten ueber nvidia-smi erkannt.' + }; + } + + return { + source: 'nvidia-smi', + available: true, + devices, + message: null + }; + } catch (error) { + const commandMissing = isCommandMissingError(error); + if (commandMissing) { + this.nvidiaSmiAvailable = false; + } + logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) }); + return { + source: 'nvidia-smi', + available: false, + devices: [], + message: commandMissing + ? 'nvidia-smi ist nicht verfuegbar.' + : (String(error?.stderr || error?.message || 'GPU-Abfrage fehlgeschlagen').trim().slice(0, 220)) + }; + } + } + + async collectStorageMetrics() { + const list = []; + for (const entry of this.monitoredPaths) { + const metric = await this.collectStorageForPath(entry); + 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) { + 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 + }); + 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); + const shouldShowExternal = exists || 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 (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..3ae364f --- /dev/null +++ b/backend/src/services/historyService.js @@ -0,0 +1,7087 @@ +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 omdbService = require('./omdbService'); +const tmdbService = require('./tmdbService'); +const cdRipService = require('./cdRipService'); +const { getJobLogDir } = require('./logPathService'); +const thumbnailService = require('./thumbnailService'); + +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; + +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 parseInfoFromValue(value, fallback = null) { + if (!value) { + return fallback; + } + if (typeof value === 'object') { + return value; + } + return parseJsonSafe(value, fallback); +} + +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') { + 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'; + } + + 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 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_dvd_series); + + const dvdEffective = settingsService.resolveEffectiveToolSettings(source, 'dvd') || {}; + pushPath(dvdEffective.series_raw_dir); + + return unique; +} + +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\/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_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') { + 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 hasSeriesParent = normalizeJobIdValue(job?.parent_job_id) !== null; + 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 isSeriesDvd = mediaType === 'dvd' && ( + 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 = isSeriesDvd ? (seriesRawDir || defaultRawDir) : defaultRawDir; + if (isSeriesDvd) { + 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 = isSeriesDvd + ? (seriesMovieDir || String(effectiveSettings?.movie_dir || '').trim()) + : String(effectiveSettings?.movie_dir || '').trim(); + const movieDir = configuredMovieDir || rawDir; + const rawLookupDirsBase = isSeriesDvd + ? 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); + const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDvd) + ? 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 omdbInfo = parseJsonSafe(job.omdb_json, null); + 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 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') + ); + + return { + ...job, + raw_path: resolvedPaths.effectiveRawPath, + output_path: resolvedPaths.effectiveOutputPath, + makemkvInfo, + handbrakeInfo, + mediainfoInfo, + omdbInfo, + encodePlan, + mediaType, + ripSuccessful, + backupSuccess, + encodeSuccess, + rawStatus, + outputStatus, + movieDirStatus + }; +} + +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 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 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 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 normalizeJobIdValue(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +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 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); + this._deleteProcessLogFile(fromJobId); + + logger.warn('job:retired', { + sourceJobId: fromJobId, + replacementJobId: toJobId, + reason, + sourceWasActive: sourceIsActive + }); + + return { + retired: true, + sourceJobId: fromJobId, + replacementJobId: toJobId, + reason + }; + } + + 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 = `[${new Date().toISOString()}] [${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 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 (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( + `[${new Date().toISOString()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}` + ); + for (const source of normalizedSources) { + lines.push(...source.lines); + } + } + if (importInfo) { + lines.push( + `[${new Date().toISOString()}] [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?.omdb_json + || 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?.omdb_json && sourceJob?.omdb_json) { + patch.omdb_json = sourceJob.omdb_json; + patch.selected_from_omdb = Number(sourceJob.selected_from_omdb || 0); + } + 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) => String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'); + 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 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 expectedTotal = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0); + const containerChildRows = directDiskRowsByContainerId.get(containerId) || []; + const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || []; + 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; + } + } + } + if (existingCountFromSeriesBatch > existingCount) { + existingCount = existingCountFromSeriesBatch; + } + if (!encodeSuccessAny && existingCount > 0) { + encodeSuccessAny = true; + if (encodeSuccessCount === 0 && totalChildren > 0) { + encodeSuccessCount = Math.min(1, totalChildren); + } + } + const expectedFinal = 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 } + }); + } + + 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 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', '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 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 (isSeriesContainer && enrichedChildren.length > 0) { + const total = enrichedChildren.length; + let rawCount = 0; + let backupCount = 0; + let encodeCount = 0; + for (const child of enrichedChildren) { + if (child?.rawStatus?.exists) { + rawCount += 1; + } + if (child?.backupSuccess) { + backupCount += 1; + } + if (child?.encodeSuccess) { + encodeCount += 1; + } + } + seriesChildSummary = { + raw: { existing: rawCount, expected: total }, + backup: { existing: backupCount, expected: total }, + encode: { existing: encodeCount, expected: total } + }; + } + + 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 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 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; + } + + 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; + } + + 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 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); + let omdbById = null; + if (metadata.imdbId) { + try { + omdbById = await omdbService.fetchByImdbId(metadata.imdbId); + } catch (error) { + logger.warn('job:import-orphan-raw:omdb-fetch-failed', { + rawPath: absRawPath, + imdbId: metadata.imdbId, + message: error.message + }); + } + } + const effectiveTitle = omdbById?.title || 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 orphanPosterUrl = omdbById?.poster || null; + 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 sourceJob = sourceJobContext?.job || null; + const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' + ? sourceJobContext.selectedMetadata + : {}; + const sourceAnalyzeContext = sourceJobContext?.analyzeContext && typeof sourceJobContext.analyzeContext === 'object' + ? sourceJobContext.analyzeContext + : {}; + const sourceHasSeriesSignals = hasSeriesMetadataSignals(sourceSelectedMetadata, sourceAnalyzeContext); + const seriesImportHints = effectiveDetectedMediaType === 'dvd' && (seriesRawPathHint || sourceHasSeriesSignals) + ? buildSeriesImportHints({ + folderName: path.basename(finalRawPath), + rawPath: finalRawPath, + metadata, + sourceSelectedMetadata, + sourceAnalyzeContext + }) + : null; + let effectiveSeriesImportHints = seriesImportHints; + let inferredSeriesContainer = null; + if (effectiveDetectedMediaType === 'dvd') { + const hintedTmdbId = normalizePositiveIntegerOrNull( + effectiveSeriesImportHints?.selectedMetadata?.tmdbId + ?? effectiveSeriesImportHints?.selectedMetadata?.providerId + ?? null + ); + const hintedSeasonNumber = normalizePositiveIntegerOrNull( + effectiveSeriesImportHints?.selectedMetadata?.seasonNumber + ?? effectiveSeriesImportHints?.analyzeContextPatch?.seriesLookupHint?.seasonNumber + ?? null + ); + + if (hintedTmdbId && hintedSeasonNumber) { + inferredSeriesContainer = await this.findSeriesContainerJob(hintedTmdbId, hintedSeasonNumber); + } + + if (!inferredSeriesContainer && seriesRawPathHint) { + inferredSeriesContainer = await this.findLikelySeriesContainerJob({ + title: sourceSelectedMetadata?.title || sourceSelectedMetadata?.seriesTitle || effectiveTitle || metadata.title || null, + year: sourceSelectedMetadata?.year || sourceJob?.year || metadata.year || null + }); + } + + if (inferredSeriesContainer) { + const containerInfo = parseJsonSafe(inferredSeriesContainer?.makemkv_info_json, {}) || {}; + const containerSelectedMetadata = extractSelectedMetadataFromMakemkvInfo(containerInfo); + const containerAnalyzeContext = containerInfo?.analyzeContext && typeof containerInfo.analyzeContext === 'object' + ? containerInfo.analyzeContext + : {}; + const containerTmdbId = normalizePositiveIntegerOrNull( + containerSelectedMetadata?.tmdbId + || containerSelectedMetadata?.providerId + || null + ); + const containerSeasonNumber = normalizePositiveIntegerOrNull(containerSelectedMetadata?.seasonNumber || null); + const discNumberHint = normalizePositiveIntegerOrNull( + effectiveSeriesImportHints?.selectedMetadata?.discNumber + ?? extractDiscNumberFromText(`${path.basename(finalRawPath)} ${path.basename(absRawPath)}`) + ?? null + ); + + if (containerTmdbId && containerSeasonNumber) { + const mergedTitle = String( + effectiveSeriesImportHints?.selectedMetadata?.title + || containerSelectedMetadata?.title + || effectiveTitle + || inferredSeriesContainer?.title + || '' + ).trim() || null; + effectiveSeriesImportHints = { + selectedMetadata: { + ...containerSelectedMetadata, + ...(effectiveSeriesImportHints?.selectedMetadata && typeof effectiveSeriesImportHints.selectedMetadata === 'object' + ? effectiveSeriesImportHints.selectedMetadata + : {}), + metadataProvider: 'tmdb', + metadataKind: String(containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series', + tmdbId: containerTmdbId, + seasonNumber: containerSeasonNumber, + title: mergedTitle, + ...(discNumberHint ? { discNumber: discNumberHint } : {}) + }, + analyzeContextPatch: { + ...(effectiveSeriesImportHints?.analyzeContextPatch && typeof effectiveSeriesImportHints.analyzeContextPatch === 'object' + ? effectiveSeriesImportHints.analyzeContextPatch + : {}), + metadataProvider: 'tmdb', + metadataKind: String(containerAnalyzeContext?.metadataKind || containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series', + seriesLookupHint: { + ...(containerAnalyzeContext?.seriesLookupHint && typeof containerAnalyzeContext.seriesLookupHint === 'object' + ? containerAnalyzeContext.seriesLookupHint + : {}), + query: mergedTitle, + seasonNumber: containerSeasonNumber, + ...(discNumberHint ? { discNumber: discNumberHint } : {}) + }, + seriesAnalysis: { + ...(containerAnalyzeContext?.seriesAnalysis && typeof containerAnalyzeContext.seriesAnalysis === 'object' + ? containerAnalyzeContext.seriesAnalysis + : {}), + summary: { + ...(containerAnalyzeContext?.seriesAnalysis?.summary && typeof containerAnalyzeContext.seriesAnalysis.summary === 'object' + ? containerAnalyzeContext.seriesAnalysis.summary + : {}), + seriesLike: true, + confidence: containerAnalyzeContext?.seriesAnalysis?.summary?.confidence || 'high' + } + } + } + }; + } + } + } + const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null); + const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null; + const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt, + rawPath: finalRawPath, + requestedRawPath: absRawPath, + sourceFolderJobId: metadata.folderJobId || null, + mediaProfile: effectiveDetectedMediaType, + existingInfo: sourceJobContext?.makemkvInfo || null, + recovery: cdRecovery, + selectedMetadata: effectiveSeriesImportHints?.selectedMetadata || null, + analyzeContextPatch: effectiveSeriesImportHints?.analyzeContextPatch || null + }); + const inferredSeriesContainerId = normalizeJobIdValue(inferredSeriesContainer?.id); + const recoveredPosterUrl = orphanPosterUrl + || (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate) + || null; + const recoveredExternalId = String( + omdbById?.imdbId + || metadata.imdbId + || sourceSelectedMetadata?.mbId + || sourceSelectedMetadata?.musicBrainzId + || sourceSelectedMetadata?.musicbrainzId + || sourceSelectedMetadata?.musicbrainz_id + || sourceSelectedMetadata?.music_brainz_id + || sourceSelectedMetadata?.musicbrainz + || sourceSelectedMetadata?.mbid + || sourceJob?.imdb_id + || '' + ).trim() || null; + await this.updateJob(created.id, { + ...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), + ...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}), + ...(effectiveDetectedMediaType === 'dvd' + ? (inferredSeriesContainerId + ? { job_kind: 'dvd_series_child', media_type: 'dvd', parent_job_id: inferredSeriesContainerId } + : { job_kind: 'dvd', media_type: 'dvd' }) + : {}), + ...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}), + status: 'FINISHED', + last_state: 'FINISHED', + title: omdbById?.title + || sourceSelectedMetadata?.title + || sourceSelectedMetadata?.album + || sourceJob?.title + || metadata.title + || cdRecovery?.selectedMetadata?.title + || null, + year: Number.isFinite(Number(omdbById?.year)) + ? Number(omdbById.year) + : ( + sourceSelectedMetadata?.year + || sourceJob?.year + || metadata.year + || cdRecovery?.selectedMetadata?.year + || null + ), + imdb_id: recoveredExternalId, + poster_url: recoveredPosterUrl, + omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : (sourceJob?.omdb_json || null), + selected_from_omdb: omdbById ? 1 : Number(sourceJob?.selected_from_omdb || 0), + rip_successful: 1, + raw_path: finalRawPath, + output_path: cdRecovery?.outputPath || null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: recoveredCdEncodePlan ? JSON.stringify(recoveredCdEncodePlan) : 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 + }); + } + + // Bild direkt persistieren (kein Rip-Prozess, daher kein Cache-Zwischenschritt) + if (orphanPosterUrl || sourcePosterCandidate) { + this.restoreImportedPoster(created.id, sourceJobContext, orphanPosterUrl || sourcePosterCandidate).catch(() => {}); + } + + 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})` + ); + if (metadata.imdbId) { + await this.appendLog( + created.id, + 'SYSTEM', + omdbById + ? `OMDb-Zuordnung via IMDb-ID übernommen: ${omdbById.imdbId} (${omdbById.title || '-'})` + : `OMDb-Zuordnung via IMDb-ID fehlgeschlagen: ${metadata.imdbId}` + ); + } + if (sourceJob) { + await this.appendLog( + created.id, + 'SYSTEM', + `Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.` + ); + } + if (inferredSeriesContainerId) { + await this.appendLog( + created.id, + 'SYSTEM', + `Automatisch dem Serien-Container #${inferredSeriesContainerId} zugeordnet.` + ); + } + + 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 assignOmdbMetadata(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 metadataProvider = requestedProviderRaw === 'themoviedb' + ? 'tmdb' + : (requestedProviderRaw || (requestedTmdbId !== null ? 'tmdb' : 'omdb')); + 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 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; + 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 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 = manualEpisodes && manualEpisodes.length > 0 + ? 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 (resolvedJobMediaType === 'dvd' && discNumber === null) { + const error = new Error('Serien-DVD 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 = seasonNumber !== null ? 'season' : 'series'; + } + let providerId = manualProviderId + || String(existingSelectedMetadata?.providerId || '').trim() + || null; + if (!providerId && effectiveTmdbId !== null) { + providerId = seasonNumber !== null + ? `tmdb:${effectiveTmdbId}:season:${seasonNumber}` + : `tmdb:${effectiveTmdbId}`; + } + + let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object' + ? { ...existingSelectedMetadata.tmdbDetails } + : null; + if (effectiveTmdbId !== null) { + try { + const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { + 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('assignOmdbMetadata:tmdb-details-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + message: tmdbDetailsErr?.message || String(tmdbDetailsErr) + }); + } + } + + if (effectiveTmdbId !== null && seasonNumber !== null) { + try { + const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber); + const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber); + const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); + if (seasonSummary) { + const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; + if (fetchedEpisodes.length > 0 && (!Array.isArray(episodes) || episodes.length === 0)) { + 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('assignOmdbMetadata: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, + metadataProvider: 'tmdb', + providerId, + tmdbId: effectiveTmdbId, + metadataKind, + seasonNumber, + seasonName, + episodeCount, + episodes: Array.isArray(episodes) ? episodes : [], + ...(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, + metadataProvider: 'tmdb', + metadataKind, + selectedMetadata: { + ...existingAnalyzeSelected, + ...nextSelectedMetadata + }, + seriesLookupHint: { + ...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, + makemkv_info_json: JSON.stringify(nextMakemkvInfo) + }); + + if (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}${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 imdbIdInput = String(payload.imdbId || '').trim().toLowerCase(); + let omdb = null; + if (imdbIdInput) { + try { + omdb = await omdbService.fetchByImdbId(imdbIdInput); + } catch (omdbErr) { + logger.warn('assignOmdbMetadata:fetch-failed', { jobId, imdbId: imdbIdInput, message: omdbErr.message }); + } + } + + const manualTitle = String(payload.title || '').trim(); + const manualYearRaw = Number(payload.year); + const manualYear = Number.isFinite(manualYearRaw) ? Math.trunc(manualYearRaw) : null; + const manualPoster = String(payload.poster || '').trim() || null; + const hasManual = manualTitle.length > 0 || manualYear !== null || imdbIdInput.length > 0; + if (!omdb && !hasManual) { + const error = new Error('Keine OMDb-/Metadaten zum Aktualisieren angegeben.'); + error.statusCode = 400; + throw error; + } + + const title = omdb?.title || manualTitle || job.title || job.detected_title || null; + const year = Number.isFinite(Number(omdb?.year)) + ? Number(omdb.year) + : (manualYear !== null ? manualYear : (job.year ?? null)); + const imdbId = omdb?.imdbId || imdbIdInput || job.imdb_id || null; + const posterUrl = omdb?.poster || manualPoster || job.poster_url || null; + const selectedFromOmdb = omdb ? 1 : Number(payload.fromOmdb ? 1 : 0); + const nextSelectedMetadata = { + ...(existingSelectedMetadata && typeof existingSelectedMetadata === 'object' ? existingSelectedMetadata : {}), + title, + year, + imdbId, + poster: posterUrl, + metadataProvider: 'omdb' + }; + const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const nextAnalyzeContext = { + ...analyzeContext, + metadataProvider: 'omdb', + selectedMetadata: { + ...existingAnalyzeSelected, + ...nextSelectedMetadata + } + }; + 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: omdb?.raw ? JSON.stringify(omdb.raw) : (job.omdb_json || null), + selected_from_omdb: selectedFromOmdb, + makemkv_info_json: JSON.stringify(nextMakemkvInfo) + }); + + // Bild herunterladen, in persistenten Ordner verschieben und poster_url aktualisieren + if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { + this.queuePosterCache(jobId, posterUrl, { + source: 'OMDb Poster', + logFailures: true + }); + } + + await this.appendLog( + jobId, + 'USER_ACTION', + omdb + ? `OMDb-Zuordnung aktualisiert: ${omdb.imdbId} (${omdb.title || '-'})` + : `Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"` + ); + + const [updated, settings] = await Promise.all([ + this.getJobById(jobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); + } + + 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; + const 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 + }; + }); + } + + 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 (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 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; + } + + 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) { + 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 + } + } + } + + 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([ + ...getConfiguredMediaPathList(settings || {}, 'raw_dir'), + 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); + const includeMovieParentCandidate = resolvedPaths?.mediaType !== 'converter'; + for (const outputPath of explicitMoviePaths) { + addCandidate( + movieCandidates, + 'movie', + outputPath, + artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path', + movieAllowedPaths + ); + const parentDir = toNormalizedPath(path.dirname(outputPath)); + if (includeMovieParentCandidate && parentDir && !movieRoots.includes(parentDir)) { + addCandidate( + movieCandidates, + 'movie', + parentDir, + artifactMoviePathSet.has(outputPath) ? 'lineage_output_parent' : 'output_parent', + 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) => Array.from(candidateMap.values()) + .filter((row) => row.target === target) + .map((row) => { + const inspection = inspectDeletionPath(row.path); + const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right)); + // Do not expose stale output file paths from DB/lineage in delete preview. + // They cannot be deleted anyway and show up as "ghost paths" in UI/QA checks. + if (target === 'movie' && !inspection.exists && hasTrackedOutputSource(sources)) { + return null; + } + 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(Boolean) + .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 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 settings = await settingsService.getSettingsMap(); + const relatedJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); + const [lineageArtifactsByJobId, outputFoldersByJobId] = await Promise.all([ + this.listJobLineageArtifactsByJobIds(relatedJobIds), + this.listJobOutputFoldersByJobIds(relatedJobIds) + ]); + const preview = this._buildDeletePreviewFromJobs( + jobs, + settings, + lineageArtifactsByJobId, + outputFoldersByJobId + ); + const relatedJobs = jobs.map((job) => ({ + 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, + createdAt: String(job.created_at || '').trim() || null + })); + const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length; + const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length; + + return { + jobId: normalizedJobId, + includeRelated, + relatedJobs, + pathCandidates: preview.pathCandidates, + protectedRoots: preview.protectedRoots, + counts: { + relatedJobs: relatedJobs.length, + 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 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 }, + 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 === 'movie' && selectedMoviePathFilter.size > 0) { + candidates = candidates.filter((item) => { + const candidatePath = String(item?.path || '').trim(); + if (!candidatePath) { + return false; + } + return selectedMoviePathFilter.has(normalizeComparablePath(candidatePath)); + }); + } + if (candidates.length === 0) { + summary[targetKey].reason = targetKey === 'movie' && selectedMoviePathFilter.size > 0 + ? 'Keine ausgewählten Audio/Video-Ordner gefunden.' + : '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) { + 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 : [] + }); + } + + 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) + }); + } + } + + async deleteJobFiles(jobId, target = 'both') { + 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 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: ${effectiveRawPath}`); + 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: ${effectiveOutputPath}`); + error.statusCode = 400; + throw error; + } else if (!fs.existsSync(effectiveOutputPath)) { + const movieRoot = normalizeComparablePath(effectiveMovieDir); + 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); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved; + 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); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved; + movieDeletedPathForTracking = parentDir; + movieCandidatePathForTracking = trackedPath; + } else { + fs.unlinkSync(trackedPath); + summary.movie.deleted = true; + summary.movie.filesDeleted = 1; + summary.movie.dirsRemoved = 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 stat = fs.lstatSync(outputPath); + if (stat.isDirectory()) { + const keepRoot = outputPath === movieRoot; + const result = deleteFilesRecursively(outputPath, keepRoot ? true : false); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved; + movieDeletedPathForTracking = outputPath; + movieCandidatePathForTracking = outputPath; + } else { + const parentDir = normalizeComparablePath(path.dirname(outputPath)); + // Converter jobs output a single file — never delete the parent dir + 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); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved; + movieDeletedPathForTracking = parentDir; + movieCandidatePathForTracking = outputPath; + } else { + fs.unlinkSync(outputPath); + summary.movie.deleted = true; + summary.movie.filesDeleted = 1; + summary.movie.dirsRemoved = 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 outputPath = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim(); + if (!outputPath) { + return; + } + const normalized = normalizeComparablePath(outputPath); + if (!normalized || seen.has(normalized)) { + return; + } + let exists = false; + try { + exists = fs.existsSync(normalized); + } catch (_error) { + exists = false; + } + 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: outputPath, + 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 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); + } 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); + } else { + fs.unlinkSync(normalizedFolderPath); + } + } + 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 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 includeRelated = Boolean(options?.includeRelated); + if (!includeRelated) { + const existing = await this.getJobById(jobId); + if (!existing) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + let fileSummary = null; + if (fileTarget !== 'none') { + const preview = await this.getJobDeletePreview(jobId, { includeRelated: false }); + fileSummary = this._deletePathsFromPreview(preview, fileTarget, { + selectedMoviePaths: options?.selectedMoviePaths + }); + } + + 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(jobId); + const runningStates = new Set(['ANALYZING', '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 = ? + `, + [jobId] + ); + } + + await db.run('DELETE FROM jobs WHERE id = ?', [jobId]); + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + await this.closeProcessLog(jobId); + this._deleteProcessLogFile(jobId); + thumbnailService.deleteThumbnail(jobId); + + logger.warn('job:deleted', { + jobId, + fileTarget, + includeRelated: false, + pipelineStateReset: isActivePipelineJob, + filesDeleted: fileSummary + ? { + raw: fileSummary.raw?.filesDeleted ?? 0, + movie: fileSummary.movie?.filesDeleted ?? 0 + } + : { raw: 0, movie: 0 } + }); + + return { + deleted: true, + jobId, + fileTarget, + includeRelated: false, + deletedJobIds: [Number(jobId)], + fileSummary + }; + } + + const normalizedJobId = normalizeJobIdValue(jobId); + const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true }); + const deleteJobIds = Array.isArray(preview?.relatedJobs) + ? preview.relatedJobs + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + : []; + 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', '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; + } + + let fileSummary = null; + if (fileTarget !== 'none') { + fileSummary = this._deletePathsFromPreview(preview, fileTarget, { + selectedMoviePaths: options?.selectedMoviePaths + }); + } + + 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); + 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); + } + + logger.warn('job:deleted', { + jobId: normalizedJobId, + fileTarget, + includeRelated: true, + deletedJobIds: deleteJobIds, + deletedJobCount: deleteJobIds.length, + pipelineStateReset: activeJobIncluded, + filesDeleted: fileSummary + ? { + raw: fileSummary.raw?.filesDeleted ?? 0, + movie: fileSummary.movie?.filesDeleted ?? 0 + } + : { raw: 0, movie: 0 } + }); + + return { + deleted: true, + jobId: normalizedJobId, + fileTarget, + includeRelated: true, + deletedJobIds: deleteJobIds, + deletedJobs: preview.relatedJobs, + fileSummary + }; + } +} + +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..4652114 --- /dev/null +++ b/backend/src/services/logger.js @@ -0,0 +1,151 @@ +const fs = require('fs'); +const path = require('path'); +const { logLevel } = require('../config'); +const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService'); + +const LEVELS = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; + +const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info; + +function ensureLogDir(logDirPath) { + try { + fs.mkdirSync(logDirPath, { recursive: true }); + return true; + } catch (_error) { + return false; + } +} + +function resolveWritableBackendLogDir() { + const preferred = getBackendLogDir(); + if (ensureLogDir(preferred)) { + return preferred; + } + + const fallback = path.join(getFallbackLogRootDir(), 'backend'); + if (fallback !== preferred && ensureLogDir(fallback)) { + return fallback; + } + + return null; +} + +function getDailyFileName() { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `backend-${y}-${m}-${day}.log`; +} + +function safeJson(value) { + try { + return JSON.stringify(value); + } catch (error) { + return JSON.stringify({ serializationError: error.message }); + } +} + +function truncateString(value, maxLen = 3000) { + const str = String(value); + if (str.length <= maxLen) { + return str; + } + return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`; +} + +function sanitizeMeta(meta) { + if (!meta || typeof meta !== 'object') { + return meta; + } + + const out = Array.isArray(meta) ? [] : {}; + + for (const [key, val] of Object.entries(meta)) { + if (val instanceof Error) { + out[key] = { + name: val.name, + message: val.message, + stack: val.stack + }; + continue; + } + + if (typeof val === 'string') { + out[key] = truncateString(val, 5000); + continue; + } + + out[key] = val; + } + + return out; +} + +function writeLine(line) { + const backendLogDir = resolveWritableBackendLogDir(); + if (!backendLogDir) { + return; + } + const daily = path.join(backendLogDir, getDailyFileName()); + const latest = path.join(backendLogDir, 'backend-latest.log'); + + fs.appendFile(daily, `${line}\n`, (_error) => null); + fs.appendFile(latest, `${line}\n`, (_error) => null); +} + +function emit(level, scope, message, meta = null) { + const normLevel = String(level || 'info').toLowerCase(); + const lvl = LEVELS[normLevel] || LEVELS.info; + if (lvl < ACTIVE_LEVEL) { + return; + } + + const timestamp = new Date().toISOString(); + const payload = { + timestamp, + level: normLevel, + scope, + message, + meta: sanitizeMeta(meta) + }; + + const line = safeJson(payload); + writeLine(line); + + const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`; + if (normLevel === 'error') { + console.error(print, payload.meta ? payload.meta : ''); + } else if (normLevel === 'warn') { + console.warn(print, payload.meta ? payload.meta : ''); + } else { + console.log(print, payload.meta ? payload.meta : ''); + } +} + +function child(scope) { + return { + debug(message, meta) { + emit('debug', scope, message, meta); + }, + info(message, meta) { + emit('info', scope, message, meta); + }, + warn(message, meta) { + emit('warn', scope, message, meta); + }, + error(message, meta) { + emit('error', scope, message, meta); + } + }; +} + +module.exports = { + child, + emit +}; diff --git a/backend/src/services/musicBrainzService.js b/backend/src/services/musicBrainzService.js new file mode 100644 index 0000000..62062d1 --- /dev/null +++ b/backend/src/services/musicBrainzService.js @@ -0,0 +1,169 @@ +const settingsService = require('./settingsService'); +const logger = require('./logger').child('MUSICBRAINZ'); + +const MB_BASE = 'https://musicbrainz.org/ws/2'; +const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)'; +const MB_TIMEOUT_MS = 10000; + +async function mbFetch(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS); + try { + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': MB_USER_AGENT + }, + signal: controller.signal + }); + clearTimeout(timer); + if (!response.ok) { + throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`); + } + return response.json(); + } catch (error) { + clearTimeout(timer); + throw error; + } +} + +function normalizeRelease(release) { + if (!release) { + return null; + } + const artistCredit = Array.isArray(release['artist-credit']) + ? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ') + : null; + const date = String(release.date || '').trim(); + const yearMatch = date.match(/\b(\d{4})\b/); + const year = yearMatch ? Number(yearMatch[1]) : null; + + const media = Array.isArray(release.media) ? release.media : []; + const normalizedTracks = media.flatMap((medium, mediumIdx) => { + const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : []; + return mediumTracks.map((track, trackIdx) => { + const rawPosition = String(track.position || track.number || '').trim(); + const parsedPosition = Number.parseInt(rawPosition, 10); + const fallbackPosition = mediumIdx * 100 + trackIdx + 1; + const position = Number.isFinite(parsedPosition) && parsedPosition > 0 + ? parsedPosition + : fallbackPosition; + return { + position, + number: String(track.number || track.position || ''), + title: String(track.title || ''), + durationMs: Number(track.length || 0) || null, + rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [], + rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : [] + }; + }); + }).map((track) => { + const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit) + ? track.rawTrackArtistCredit + : []; + const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit) + ? track.rawRecordingArtistCredit + : []; + const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', '); + const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', '); + return { + position: track.position, + number: track.number, + title: track.title, + durationMs: track.durationMs, + artist: artistFromTrack || artistFromRecording || artistCredit || null + }; + }); + + // Always generate the CAA URL when an id is present; the browser/onError + // handles 404s for releases that have no front cover. + const coverArtUrl = release.id + ? `https://coverartarchive.org/release/${release.id}/front-250` + : null; + + return { + mbId: String(release.id || ''), + title: String(release.title || ''), + artist: artistCredit || null, + year, + date, + country: String(release.country || '').trim() || null, + label: Array.isArray(release['label-info']) + ? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null + : null, + coverArtUrl, + tracks: normalizedTracks + }; +} + +class MusicBrainzService { + async isEnabled() { + const settings = await settingsService.getSettingsMap(); + return settings.musicbrainz_enabled !== 'false'; + } + + async searchByTitle(query) { + const q = String(query || '').trim(); + if (!q) { + return []; + } + + const enabled = await this.isEnabled(); + if (!enabled) { + return []; + } + + logger.info('search:start', { query: q }); + + const url = new URL(`${MB_BASE}/release`); + url.searchParams.set('query', q); + url.searchParams.set('fmt', 'json'); + url.searchParams.set('limit', '10'); + url.searchParams.set('inc', 'artist-credits+labels+recordings+media'); + + try { + const data = await mbFetch(url.toString()); + const releases = Array.isArray(data.releases) ? data.releases : []; + const results = releases.map(normalizeRelease).filter(Boolean); + logger.info('search:done', { query: q, count: results.length }); + return results; + } catch (error) { + logger.warn('search:failed', { query: q, error: String(error?.message || error) }); + return []; + } + } + + async searchByDiscLabel(discLabel) { + return this.searchByTitle(discLabel); + } + + async getReleaseById(mbId) { + const id = String(mbId || '').trim(); + if (!id) { + return null; + } + + const enabled = await this.isEnabled(); + if (!enabled) { + return null; + } + + logger.info('getById:start', { mbId: id }); + + const url = new URL(`${MB_BASE}/release/${id}`); + url.searchParams.set('fmt', 'json'); + url.searchParams.set('inc', 'artist-credits+labels+recordings+media'); + + try { + const data = await mbFetch(url.toString()); + const result = normalizeRelease(data); + logger.info('getById:done', { mbId: id, title: result?.title }); + return result; + } catch (error) { + logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) }); + return null; + } + } +} + +module.exports = new MusicBrainzService(); 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/omdbService.js b/backend/src/services/omdbService.js new file mode 100644 index 0000000..781c5c0 --- /dev/null +++ b/backend/src/services/omdbService.js @@ -0,0 +1,225 @@ +const settingsService = require('./settingsService'); +const logger = require('./logger').child('OMDB'); + +const OMDB_BASE_URL = 'https://www.omdbapi.com/'; +const OMDB_TIMEOUT_MS = 10000; +const OMDB_MAX_ATTEMPTS = 2; +const OMDB_RETRY_BASE_DELAY_MS = 300; + +function normalizeOmdbTimeoutMs(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return OMDB_TIMEOUT_MS; + } + return Math.max(1000, Math.trunc(parsed)); +} + +function isRetryableOmdbError(error, aborted = false) { + if (aborted) { + return true; + } + const code = String(error?.code || '').trim().toUpperCase(); + if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') { + return true; + } + const causeCode = String(error?.cause?.code || '').trim().toUpperCase(); + if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') { + return true; + } + const message = String(error?.message || '').toLowerCase(); + return message.includes('timed out') || message.includes('timeout'); +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +class OmdbService { + mapSearchResults(data) { + if (!data || typeof data !== 'object') { + return []; + } + if (data.Response === 'False' || !Array.isArray(data.Search)) { + return []; + } + return data.Search.map((item) => ({ + title: item.Title, + year: item.Year, + imdbId: item.imdbID, + type: item.Type, + poster: item.Poster + })); + } + + async requestJson(url, meta = {}, options = {}) { + const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs); + const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS))); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Ripster/1.0' + }, + signal: controller.signal + }); + if (!response.ok) { + logger.warn('request:http-failed', { + ...meta, + status: response.status, + attempt, + maxAttempts + }); + return null; + } + return await response.json(); + } catch (error) { + const aborted = error?.name === 'AbortError'; + const retryable = isRetryableOmdbError(error, aborted); + const willRetry = retryable && attempt < maxAttempts; + logger[willRetry ? 'info' : 'warn']('request:failed', { + ...meta, + timeoutMs, + aborted, + retryable, + attempt, + maxAttempts, + willRetry, + message: error?.message || String(error) + }); + if (willRetry) { + await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt); + continue; + } + return null; + } finally { + clearTimeout(timer); + } + } + return null; + } + + async search(query) { + const normalizedQuery = String(query || '').trim(); + if (!normalizedQuery) { + return []; + } + logger.info('search:start', { query: normalizedQuery }); + + // Allow direct IMDb-ID lookups in the search field (useful for History remapping). + const imdbLike = normalizedQuery.toLowerCase(); + if (/^tt\d{6,12}$/.test(imdbLike)) { + const byId = await this.fetchByImdbId(imdbLike); + return byId + ? [{ + title: byId.title, + year: byId.year != null ? String(byId.year) : null, + imdbId: byId.imdbId, + type: byId.type, + poster: byId.poster + }] + : []; + } + + const settings = await settingsService.getSettingsMap(); + const apiKey = settings.omdb_api_key; + if (!apiKey) { + return []; + } + const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); + + const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie'; + const requestSearch = async (type = null) => { + const url = new URL(OMDB_BASE_URL); + url.searchParams.set('apikey', apiKey); + url.searchParams.set('s', normalizedQuery); + if (type) { + url.searchParams.set('type', type); + } + const data = await this.requestJson( + url, + { query: normalizedQuery, action: 'search', type: type || null }, + { timeoutMs } + ); + return { + data, + results: this.mapSearchResults(data) + }; + }; + + let { data, results } = await requestSearch(configuredType); + if (results.length === 0 && configuredType) { + logger.info('search:fallback-no-type', { + query: normalizedQuery, + configuredType, + response: data?.Response || null, + error: data?.Error || null + }); + ({ data, results } = await requestSearch(null)); + } + + if (results.length === 0) { + logger.warn('search:no-results', { + query: normalizedQuery, + response: data?.Response || null, + error: data?.Error || null, + configuredType + }); + return []; + } + + logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType }); + return results; + } + + async fetchByImdbId(imdbId) { + const normalizedId = String(imdbId || '').trim().toLowerCase(); + if (!/^tt\d{6,12}$/.test(normalizedId)) { + return null; + } + + logger.info('fetchByImdbId:start', { imdbId: normalizedId }); + const settings = await settingsService.getSettingsMap(); + const apiKey = settings.omdb_api_key; + if (!apiKey) { + return null; + } + const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); + + const url = new URL(OMDB_BASE_URL); + url.searchParams.set('apikey', apiKey); + url.searchParams.set('i', normalizedId); + url.searchParams.set('plot', 'full'); + + const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs }); + if (!data || typeof data !== 'object') { + return null; + } + if (data.Response === 'False') { + logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error }); + return null; + } + + const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/); + const year = yearMatch ? Number(yearMatch[0]) : null; + const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null; + + const result = { + title: data.Title || null, + year: Number.isFinite(year) ? year : null, + imdbId: String(data.imdbID || normalizedId), + type: data.Type || null, + poster, + raw: data + }; + logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title }); + return result; + } +} + +module.exports = new OmdbService(); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js new file mode 100644 index 0000000..e995533 --- /dev/null +++ b/backend/src/services/pipelineService.js @@ -0,0 +1,24366 @@ +const fs = require('fs'); +const path = require('path'); +const { EventEmitter } = require('events'); +const { execFile } = require('child_process'); +const { getDb } = require('../db/database'); +const settingsService = require('./settingsService'); +const historyService = require('./historyService'); +const omdbService = require('./omdbService'); +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 } = require('../utils/progressParsers'); +const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files'); +const { buildMediainfoReview } = require('../utils/encodePlan'); +const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); +const { errorToMeta } = require('../utils/errorMeta'); +const userPresetService = require('./userPresetService'); +const thumbnailService = require('./thumbnailService'); +const activationBytesService = require('./activationBytesService'); + +const RUNNING_STATES = new Set(['ANALYZING', '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', + '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 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); + +function nowIso() { + return new Date().toISOString(); +} + +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 resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) { + const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const activeSelectedMetadata = activeContext?.selectedMetadata && typeof activeContext.selectedMetadata === 'object' + ? activeContext.selectedMetadata + : {}; + const merged = { + ...analyzeSelectedMetadata, + ...activeSelectedMetadata + }; + + 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, + metadataProvider: String( + merged.metadataProvider + || analyzeContext?.metadataProvider + || activeContext?.metadataProvider + || 'omdb' + ).trim().toLowerCase() || 'omdb' + }; +} + +function isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata = {}, analyzeContext = null) { + if (normalizeMediaProfile(mediaProfile) !== 'dvd') { + return false; + } + + const metadata = selectedMetadata && typeof selectedMetadata === 'object' + ? selectedMetadata + : {}; + const provider = String( + metadata.metadataProvider + || analyzeContext?.metadataProvider + || '' + ).trim().toLowerCase(); + + const metadataKind = String(metadata.metadataKind || '').trim().toLowerCase(); + 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 tmdbId = Number(metadata.tmdbId || 0) || null; + const episodeCount = Number(metadata.episodeCount || 0) || 0; + const hasEpisodeList = Array.isArray(metadata.episodes) && metadata.episodes.length > 0; + const hasSeriesLookupHint = Boolean( + String(analyzeContext?.seriesLookupHint?.query || '').trim() + || Number(analyzeContext?.seriesLookupHint?.seasonNumber || 0) > 0 + ); + const seriesLikeByAnalysis = 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 + || tmdbId !== null + || 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 seriesRawCandidate = String( + settings?.series_raw_dir + || settings?.raw_dir_dvd_series + || '' + ).trim(); + const seriesRawOwnerCandidate = String( + settings?.series_raw_dir_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') { + 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{seasonNr}E{episodeRange}'; + +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) { + const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {}; + const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {}; + const episodeStart = normalizeTemplateNumberValue( + assignmentSource?.episodeNumberStart + ?? assignmentSource?.episodeNoStart + ?? assignmentSource?.episodeNumber + ?? assignmentSource?.number + ?? selectedTitleSource?.episodeNumber + ?? 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 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 ''; + } + 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*$/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 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 = mediaProfile === 'dvd' + && ( + 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 seasonNumber = normalizePositiveInteger( + assignment?.seasonNumber + ?? selectedTitle?.seasonNumber + ?? selectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ) || 1; + const episodeRangeInfo = resolveSeriesEpisodeRangeFromAssignment( + assignment, + selectedTitle, + primaryTitleId || 1 + ); + const episodeTemplateValue = episodeRangeInfo.start; + const episodeRangeToken = buildSeriesEpisodeRangeToken( + episodeRangeInfo.start, + episodeRangeInfo.end + ); + const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo); + const discNumber = normalizePositiveInteger( + selectedMetadata?.discNumber + ?? assignment?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + const seriesTitle = normalizeCdTrackText( + selectedMetadata?.title + || selectedMetadata?.seriesTitle + || job?.title + || job?.detected_title + || (fallbackJobId ? `job-${fallbackJobId}` : 'series') + ) || (fallbackJobId ? `job-${fallbackJobId}` : 'series'); + const rawEpisodeTitle = normalizeCdTrackText( + assignment?.episodeTitle + || selectedTitle?.episodeTitle + || selectedTitle?.fileName + || `Episode ${episodeRangeToken}` + ) || `Episode ${episodeRangeToken}`; + const episodeTitle = normalizeSeriesEpisodeTitleForOutput( + rawEpisodeTitle, + episodeRangeInfo + ); + 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 singleTemplate = String( + settings?.output_template_dvd_series_episode + || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE + ).trim() || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE; + const multiTemplate = String( + settings?.output_template_dvd_series_multi_episode + || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE + ).trim() || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE; + const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate; + const rendered = renderTemplate(template, { + seriesTitle, + seasonNr: seasonNumber, + seasonNo: String(seasonNumber), + episodeNr: episodeTemplateValue, + episodeNo: String(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 buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { + const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); + if (seriesParts?.rootDir) { + if (seriesParts.folderPath) { + return path.join(seriesParts.rootDir, seriesParts.folderPath, `${seriesParts.baseName}.${seriesParts.ext}`); + } + return path.join(seriesParts.rootDir, `${seriesParts.baseName}.${seriesParts.ext}`); + } + const movieDir = settings.movie_dir; + const values = resolveOutputTemplateValues(job, fallbackJobId); + const { folderPath, baseName } = resolveOutputPathParts(settings, values); + const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; + if (folderPath) { + return path.join(movieDir, folderPath, `${baseName}.${ext}`); + } + return path.join(movieDir, `${baseName}.${ext}`); +} + +function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { + 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 = seriesParts?.baseName || defaultBaseName; + 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 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 parent-folder of 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 fileName = path.basename(outputPath); + for (let i = 2; i < 200; i++) { + const attempt = path.join(`${parentDir}_${i}`, fileName); + 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 base = percent !== null && percent !== undefined + ? `${stage} ${percent.toFixed(2)}%` + : stage; + + 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 position = Number.isFinite(index) && Number.isFinite(total) && total > 0 + ? ` ${index}/${total}` + : ''; + const status = statusWord ? ` ${statusWord}` : ''; + const detail = String(label || '').trim(); + return `ENCODING ${percent.toFixed(2)}% - ${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 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 normalizeTrackLanguage(raw) { + const value = String(raw || '').trim(); + if (!value) { + return 'und'; + } + return value.toLowerCase().slice(0, 3); +} + +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 resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistIdRaw) { + const playlistId = normalizePlaylistId(playlistIdRaw); + if (!playlistId || !playlistAnalysis) { + return { + playlistId: playlistId || null, + playlistFile: playlistId ? `${playlistId}.mpls` : null, + 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`, + 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 rows = buildHandBrakeScanTitleRows(scanJson); + const matches = rows.filter((item) => item.playlist === playlistId); + + 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); + 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; + } + const best = matches.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 cachedPlaylistId = normalizePlaylistId(titleInfo?.playlistId || null); + if (cachedPlaylistId && cachedPlaylistId !== playlistId) { + 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; + return { handBrakeTitleId, durationSeconds, audioTrackCount, subtitleTrackCount, sizeBytes }; + }); +} + +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' + }; + } + + try { + const analysisResult = dvdSeriesScanService.analyzeHandBrakeScan(scanText, options?.seriesOptions || {}); + const analysis = analysisResult?.analysis || null; + const analysisTitles = Array.isArray(analysis?.titles) ? analysis.titles : []; + if (analysisTitles.length === 0) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: 'no_titles' + }; + } + + return { + analyzeContext: { + ...baseAnalyzeContext, + seriesAnalysis: analysis + }, + derived: true, + reason: 'scan_classification' + }; + } catch (_error) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: 'scan_parse_failed' + }; + } +} + +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 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 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, + 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 isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + 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 (isSeriesDvd && 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}`); + } + + 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 ''; + } + return rawName + .replace(/^Incomplete_/i, '') + .replace(/^Rip_Complete_/i, '') + .trim(); +} + +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 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_/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 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 + : {}; + + 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 score = Number(source?.score); + const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence); + 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), + 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, + 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 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 + }); + } + + 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 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 + ); + const rawEpisodeLabel = String( + assignment?.episodeTitle + || selectedTitle?.episodeTitle + || selectedTitle?.fileName + || '' + ).trim(); + const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange); + + 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; + + 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: selectedAssignment + ? { + [String(titleId)]: { + ...selectedAssignment, + titleId: Number(titleId), + filePath: selectedTitle?.filePath || selectedAssignment?.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, + 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 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 explicitSubtitleVariants = normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection); + const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder); + let effectiveSubtitleVariantSelection = explicitSubtitleVariants; + let forceExplicitEmptySubtitleSelection = false; + const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); + const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); + if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && hasRawSubtitleTrackIds) { + 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 && !hasRawSubtitleVariantSelection && requestedSubtitleTrackIds.length > 0) { + effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds); + } + + const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ + subtitleTracks: encodeTitle.subtitleTracks, + subtitleVariantSelection: Object.fromEntries(effectiveSubtitleVariantSelection.map.entries()), + subtitleLanguageOrder: explicitSubtitleLanguageOrder.length > 0 + ? explicitSubtitleLanguageOrder + : effectiveSubtitleVariantSelection.order, + fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds, + fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered, + hasExplicitVariantSelection: hasRawSubtitleVariantSelection || forceExplicitEmptySubtitleSelection + }); + + 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, + 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 hasManualSubtitleVariantSelection = Boolean( + manualMatchesTitle + && manualSelection + && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleVariantSelection') + ); + const manualSubtitleLanguageOrder = manualMatchesTitle && Array.isArray(manualSelection?.subtitleLanguageOrder) + ? manualSelection.subtitleLanguageOrder + : []; + + const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ + subtitleTracks: allSubtitleTracks, + subtitleVariantSelection: manualSubtitleVariantSelection, + subtitleLanguageOrder: manualSubtitleLanguageOrder, + fallbackSelectedSubtitleTrackIds: fallbackSubtitleTrackIds, + fallbackSelectedSubtitleTrackIdsOrdered: fallbackSubtitleTrackIdsOrdered, + hasExplicitVariantSelection: hasManualSubtitleVariantSelection + }); + + const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIdsOrdered.length > 0 + ? resolvedSubtitleSelection.subtitleTrackIdsOrdered + : (hasManualSubtitleVariantSelection + ? [] + : (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, + 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 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; + } + return { + 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 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; + } + + 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 mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, { excludeBurned = false } = {}) { + 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 mapped = []; + const seen = new Set(); + for (const sourceTrackId of requested) { + const match = allowedTracks.find((track) => { + const sourceId = normalizeTrackIdList([track?.sourceTrackId])[0] || null; + const reviewId = normalizeTrackIdList([track?.id])[0] || null; + return sourceId === sourceTrackId || reviewId === sourceTrackId; + }) || null; + const targetId = normalizeTrackIdList([match?.id])[0] || null; + if (targetId === null) { + continue; + } + const key = String(targetId); + if (seen.has(key)) { + continue; + } + seen.add(key); + mapped.push(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; + 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 (previousSelectedTitle && nextSelectedTitle) { + const previousAudioSourceIds = normalizeTrackIdList( + (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)) + .map((track) => track?.sourceTrackId ?? track?.id) + ); + const previousSubtitleSourceIds = normalizeTrackIdList( + (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)) + .map((track) => track?.sourceTrackId ?? track?.id) + ); + + const mappedAudioTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( + nextSelectedTitle?.audioTracks, + previousAudioSourceIds + ); + const mappedSubtitleTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( + nextSelectedTitle?.subtitleTracks, + previousSubtitleSourceIds, + { 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 || []); + const userPreset = normalizeUserPresetForPlan(previousPlan?.userPreset || null); + + nextPlan = { + ...nextPlan, + preEncodeScriptIds, + postEncodeScriptIds, + preEncodeScripts: buildScriptDescriptorList(preEncodeScriptIds, previousPlan?.preEncodeScripts || []), + postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []), + preEncodeChainIds, + postEncodeChainIds, + userPreset, + reviewConfirmed: false, + reviewConfirmedAt: null, + prefilledFromPreviousRun: true, + prefilledFromPreviousRunAt: nowIso() + }; + + const applied = selectedTitleApplied + || trackSelectionApplied + || preEncodeScriptIds.length > 0 + || postEncodeScriptIds.length > 0 + || preEncodeChainIds.length > 0 + || postEncodeChainIds.length > 0 + || Boolean(userPreset); + + return { + plan: nextPlan, + applied, + selectedEncodeTitleId: normalizeReviewTitleId(nextPlan?.encodeInputTitleId), + preEncodeScriptCount: preEncodeScriptIds.length, + postEncodeScriptCount: postEncodeScriptIds.length, + preEncodeChainCount: preEncodeChainIds.length, + postEncodeChainCount: postEncodeChainIds.length, + userPresetApplied: Boolean(userPreset) + }; +} + +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); + return { + scanLines: Array.isArray(pluginResult?.scanLines) ? pluginResult.scanLines : [], + runInfo: pluginResult?.runInfo && typeof pluginResult.runInfo === 'object' + ? pluginResult.runInfo + : null, + sourceArg: String(pluginResult?.sourceArg || '').trim() || null, + pluginExecution: this.sanitizePluginExecutionState(pluginCtx.getPluginExecution()) + }; + } + + 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_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, + 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, + inputPath, + hasUsableRawInput: Boolean(inputPath) + }; + } + + 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_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_%') + `); + if (!Array.isArray(rows) || rows.length === 0) { + return; + } + + let renamedCount = 0; + let pathUpdateCount = 0; + let ripFlagUpdateCount = 0; + let conflictCount = 0; + let missingCount = 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 match = String(entry.name || '').match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); + if (!match) { + continue; + } + const mappedJobId = Number(match[1]); + if (!Number.isFinite(mappedJobId) || mappedJobId <= 0) { + 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 rows) { + 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 ripSuccessful = this.isRipSuccessful(row); + if (ripSuccessful && Number(row?.rip_successful || 0) !== 1) { + await historyService.updateJob(jobId, { rip_successful: 1 }); + ripFlagUpdateCount += 1; + } + + const currentRawPath = this.resolveCurrentRawPath(rawBaseDir, row.raw_path, rawExtraDirs) + || discoveredByJobId.get(jobId)?.path + || null; + if (!currentRawPath) { + missingCount += 1; + continue; + } + + // 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 + }, jobId, { + mediaProfile: row.media_type || null, + analyzeContext, + selectedMetadata, + seriesSeasonNumber: folderSeriesSeasonDisc.seasonNumber, + seriesDiscNumber: folderSeriesSeasonDisc.discNumber + }); + const desiredRawFolderState = this.resolveDesiredRawFolderState(row); + 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; + } 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; + } + } + + if (renamedCount > 0 || pathUpdateCount > 0 || ripFlagUpdateCount > 0 || conflictCount > 0 || missingCount > 0) { + logger.info('startup:raw-dir-migrate:done', { + renamedCount, + pathUpdateCount, + ripFlagUpdateCount, + conflictCount, + missingCount, + 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.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) + }); + } + + // 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 + FROM jobs + WHERE COALESCE(job_kind, '') != 'dvd_series_container' + AND status IN ('ANALYZING', '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, + skipped: 0 + }; + } + + let preparedReadyToEncode = 0; + let markedError = 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') { + 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 + } + } + } + + 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, + skipped + }); + return { + scanned: rows.length, + preparedReadyToEncode, + markedError, + 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_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; + 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; + } + + async _restoreDriveLocksFromJobs() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT id, disc_device, status, last_state, rip_successful, job_kind + FROM jobs + WHERE disc_device IS NOT NULL + AND TRIM(disc_device) <> '' + AND COALESCE(job_kind, '') != 'dvd_series_container' + 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_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 = []; + for (const row of (Array.isArray(rows) ? rows : [])) { + 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; + } + 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); + } + + 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)))); + const removedQueueEntries = previousQueueLength - this.queueEntries.length; + + 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 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 activeLocks = diskDetectionService.getActiveLocks(); + const locksToCheck = normalizedPaths + .map((devicePath) => { + const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null; + return lock ? { devicePath, lock } : null; + }) + .filter(Boolean); + if (locksToCheck.length === 0) { + return []; + } + + const ownerJobIds = new Set(); + for (const entry of locksToCheck) { + const owners = Array.isArray(entry.lock?.owners) ? entry.lock.owners : []; + for (const owner of owners) { + const jobId = Number(owner?.jobId); + if (Number.isFinite(jobId) && jobId > 0) { + ownerJobIds.add(Math.trunc(jobId)); + } + } + } + if (ownerJobIds.size === 0) { + return []; + } + + let existingJobIds = new Set(); + try { + const db = await getDb(); + const placeholders = Array.from(ownerJobIds).map(() => '?').join(', '); + const rows = await db.all(`SELECT id FROM jobs WHERE id IN (${placeholders})`, Array.from(ownerJobIds)); + existingJobIds = new Set( + (Array.isArray(rows) ? rows : []) + .map((row) => Number(row?.id)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + } catch (error) { + logger.warn('drive-lock:stale-check:failed', { error: errorToMeta(error) }); + 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; + } + return existingJobIds.has(Math.trunc(jobId)); + }); + if (hasActiveOwner) { + continue; + } + const clearedCount = Number(diskDetectionService.forceUnlockDevice(entry.devicePath, { + reason: 'stale_lock', + deletedJobIds: [] + }) || 0); + if (clearedCount > 0) { + cleared.push(entry.devicePath); + } + } + + if (cleared.length > 0) { + logger.warn('drive-lock:stale-unlocked', { devicePaths: cleared }); + } + 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.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) { + 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'; + } + + _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', '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] + ); + if (!parentJob || !this.isSeriesContainerHistoryJob(parentJob)) { + 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.isSeriesContainerHistoryJob(row)); + 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, '') = 'dvd_series_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 + }; + } + + 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 = ? + ORDER BY id ASC + `, + [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', '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'); + 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 + }); + // 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; + const nextState = hasErrors + ? 'ERROR' + : (hasCancellations ? 'CANCELLED' : (hasPendingChildren ? 'ENCODING' : 'READY_TO_ENCODE')); + const nextLastState = hasPendingChildren && nextState === 'ENCODING' + ? '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 selectedMetadata = resolveSelectedMetadataForJob(sourceJob, analyzeContext, this.snapshot.context || {}); + const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(parentPlan?.selectedTitleIds) + ? parentPlan.selectedTitleIds + : [parentPlan?.encodeInputTitleId] + ); + 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: sourceJob.omdb_json || null, + selected_from_omdb: Number(sourceJob.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)); + } + + 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; + } + + 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.isSeriesContainerHistoryJob(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.isSeriesContainerHistoryJob(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 scriptMetaByJobId = await this.buildQueueJobScriptMeta( + Array.from( + new Map( + [...visibleRunningJobs, ...queuedRows, ...idleJobs].map((row) => [Number(row?.id), row]) + ).values() + ) + ); + 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 + }; + + if (entryType === 'script') { + return { ...base, scriptId: entry.scriptId, title: entry.scriptName || `Skript #${entry.scriptId}`, status: 'QUEUED' }; + } + if (entryType === 'chain') { + return { ...base, chainId: entry.chainId, title: entry.chainName || `Kette #${entry.chainId}`, 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(); + void this.pumpQueue(); + 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; + } + + let poolType = this.normalizeQueuePoolType(options?.poolType); + if (!poolType) { + const poolJob = await historyService.getJobById(normalizedJobId).catch(() => null); + poolType = this.resolveQueuePoolTypeForJob(poolJob); + } + + 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 shouldQueue = forceQueue + ? 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 : {}) + }; + } + + this.queueEntries.push({ + id: this.queueEntrySeq++, + jobId: normalizedJobId, + action, + poolType, + uniqueEntryKey, + ...(options?.entryData && typeof options.entryData === 'object' ? options.entryData : {}), + enqueuedAt: nowIso() + }); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `In Queue aufgenommen: ${QUEUE_ACTION_LABELS[action] || action}` + ); + await this.emitQueueChanged(); + void this.pumpQueue(); + + return { + queued: true, + started: false, + queuePosition: this.queueEntries.length, + action, + poolType, + queuedPoolTypeCount + }; + } + + 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 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; + // Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE). + const anyActiveJobs = totalRunning; + + // Find next startable entry + let entryIndex = -1; + for (let i = 0; i < this.queueEntries.length; i++) { + const candidate = this.queueEntries[i]; + const isNonJob = candidate.type && candidate.type !== 'job'; + + if (isNonJob) { + // Non-job entries (script, chain, wait) only start when no jobs are actively running. + // anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.) + // activeProcesses provides a second safety net for race conditions during state transitions. + if (anyActiveJobs === 0 && this.activeProcesses.size === 0) { + entryIndex = i; + } + break; // FIFO: stop scanning regardless (non-job blocks everything behind it) + } + + // Job entry: check hierarchical limits + if (totalRunning >= maxTotal) { + // Total limit reached – nothing can start + break; + } + + const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate); + if (candidatePoolType === 'audio') { + if (cdRunning < maxCd) { + entryIndex = i; + break; + } + // CD limit reached + if (!cdBypass) break; // Strict FIFO: stop scanning + continue; // Bypass mode: skip this blocked CD entry + } else { + // Film/video job entry + if (filmRunning < maxFilm) { + entryIndex = i; + break; + } + // Film limit reached + if (!cdBypass) break; // Strict FIFO: stop scanning + continue; // 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}` + ); + } + } + } + } 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', '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_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 ensureMakeMKVRegistration(jobId, stage) { + const registrationConfig = await settingsService.buildMakeMKVRegisterConfig(); + if (!registrationConfig) { + return { applied: false, reason: 'not_configured' }; + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Setze MakeMKV-Registrierungsschlüssel aus den Settings (makemkvcon reg).' + ); + + await this.runCommand({ + jobId, + stage, + source: 'MAKEMKV_REG', + cmd: registrationConfig.cmd, + args: registrationConfig.args, + argsForLog: registrationConfig.argsForLog + }); + + return { applied: true }; + } + + 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 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: 'METADATA_SELECTION', + detectedTitle, + jobKind: resolveJobKindForMediaProfile(mediaProfile) + }); + + // Surface the newly created analyze job immediately in the Ripper UI, + // even if metadata provider lookups (TMDb/OMDb) 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 omdbCandidates = null; + let metadataCandidates = null; + let metadataProvider = 'omdb'; + let seriesAnalysis = null; + let seriesLookupHint = 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; + } + if (Array.isArray(pluginResult?.omdbCandidates)) { + omdbCandidates = pluginResult.omdbCandidates; + } + 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 (mediaProfile === 'dvd' && analyzePlugin?.id === 'dvd') { + try { + const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, { + jobId: job.id, + deviceInfo: deviceWithProfile, + reviewMode: 'pre_rip', + source: 'HANDBRAKE_SCAN', + silent: true + }); + pluginExecution = this.mergePluginExecutionState(pluginExecution, reviewResult?.pluginExecution || null); + + const scanText = Array.isArray(reviewResult?.scanLines) + ? reviewResult.scanLines.join('\n') + : ''; + if (scanText) { + const { DVDSeriesPlugin } = require('../plugins/DVDSeriesPlugin'); + const dvdSeriesPlugin = new DVDSeriesPlugin(); + const dvdSeriesContext = await this.buildPluginContext(dvdSeriesPlugin.id, { + discInfo: deviceWithProfile, + jobId: job.id, + scanText, + detectedTitle: effectiveDetectedTitle + }); + const seriesPluginResult = await dvdSeriesPlugin.analyze(device.path, job, dvdSeriesContext); + pluginExecution = this.mergePluginExecutionState( + pluginExecution, + this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) + ); + + seriesAnalysis = seriesPluginResult?.seriesAnalysis || null; + seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; + const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' + ? seriesAnalysis.summary + : null; + const seriesLike = Boolean(rawSeriesSummary?.seriesLike); + seriesAnalysis = rawSeriesSummary + ? { + source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', + generatedAt: seriesAnalysis?.generatedAt || nowIso(), + summary: { + seriesLike, + confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', + reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], + titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0 + } + } + : null; + + if (seriesLike) { + metadataProvider = 'tmdb'; + metadataCandidates = []; + const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null; + if (fallbackQuery) { + seriesLookupHint = { + ...(seriesLookupHint && typeof seriesLookupHint === 'object' ? seriesLookupHint : {}), + query: fallbackQuery, + seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery + }; + } + + if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) { + metadataCandidates = await tmdbService.searchSeriesWithSeasons( + seriesLookupHint.query, + { language: dvdSettings?.dvd_series_language || null } + ).catch((error) => { + logger.warn('dvd-series:tmdb-search-failed', { + jobId: job.id, + query: seriesLookupHint?.query || null, + seasonNumber: seriesLookupHint?.seasonNumber || null, + error: errorToMeta(error) + }); + return []; + }); + } + } + + logger.info('dvd-series:analyze:done', { + jobId: job.id, + seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), + confidence: seriesAnalysis?.summary?.confidence || null, + metadataProvider, + metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, + seriesLookupHint + }); + } + } catch (error) { + logger.warn('dvd-series:analyze:fallback-legacy', { + jobId: job.id, + error: errorToMeta(error) + }); + } + } + if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) { + omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []); + } + if (metadataProvider !== 'tmdb') { + metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : []; + } + logger.info('metadata:prepare:result', { + jobId: job.id, + detectedTitle: effectiveDetectedTitle, + metadataProvider, + metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, + omdbCandidateCount: Array.isArray(omdbCandidates) ? omdbCandidates.length : 0 + }); + + const prepareInfo = this.withPluginExecutionMeta( + this.withAnalyzeContextMediaProfile({ + phase: 'PREPARE', + preparedAt: nowIso(), + analyzeContext: { + playlistAnalysis: null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis, + seriesLookupHint + } + }, 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', + metadataProvider === 'tmdb' + ? `Serien-DVD erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` + : `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${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 : [], + omdbCandidates, + mediaProfile, + seriesAnalysis, + seriesLookupHint, + 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}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)` + }); + + return { + jobId: job.id, + detectedTitle: effectiveDetectedTitle, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis, + seriesLookupHint, + omdbCandidates + }; + } catch (error) { + logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) }); + await this.failJob(job.id, 'METADATA_SELECTION', error); + throw error; + } + } + + async searchOmdb(query) { + logger.info('omdb:search', { query }); + const results = await omdbService.search(query); + logger.info('omdb:search:done', { query, count: results.length }); + return results; + } + + async searchTmdbSeries(query, seasonNumber = null) { + const normalizedQuery = String(query || '').trim(); + if (!normalizedQuery) { + 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(); + logger.info('tmdb:series-search', { + query: normalizedQuery, + seasonNumber: seasonFilter || null + }); + + let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery); + if (!Array.isArray(normalizedResults)) { + normalizedResults = []; + } + + if (seasonFilter) { + const seasonFilterLower = seasonFilter.toLowerCase(); + normalizedResults = normalizedResults.filter((row) => { + const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase(); + const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase(); + return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower); + }); + } + + logger.info('tmdb:series-search:done', { query: normalizedQuery, count: normalizedResults.length }); + + if (normalizedResults.length === 0) { + const seasonInfo = seasonFilter + ? ` (Staffel-Filter ${seasonFilter})` + : ''; + const error = new Error(`Keine TMDb-Treffer für "${normalizedQuery}"${seasonInfo}.`); + error.statusCode = 404; + throw error; + } + + return normalizedResults; + } + + 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) + }); + lines.push(...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 (normalizeMediaProfile(mediaProfile) === 'dvd') { + const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, lines); + effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; + effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, effectiveAnalyzeContext); + } + + if (effectiveIsSeriesDvdReview) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-DVD 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; + } + + 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); + } + const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze) + ? (options?.selectedPlaylist || null) + : (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null); + const 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 || {}); + + 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 }); + 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 analyzed = analyzePlaylistObfuscation( + analyzeLines, + Number(settings.makemkv_min_length_minutes || 60), + {} + ); + playlistAnalysis = analyzed || null; + analyzedFromFreshRun = true; + } + + const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired); + let playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + const selectedTitleFromPlaylist = selectedPlaylistId + ? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId) + : null; + const selectedTitleFromContext = (!selectedPlaylistId && !isCandidateTitleId(playlistAnalysis, selectedMakemkvTitleId)) + ? null + : selectedMakemkvTitleId; + const selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContext ?? 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 + ); + if (shouldPrepareHandBrakeDecisionData) { + const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates); + if (!hasCompleteCache) { + await this.updateProgress( + 'MEDIAINFO_CHECK', + 25, + null, + '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) + }); + resolveScanLines.push(...pluginScan.scanLines); + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + + const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); + if (resolveScanJson) { + const preparedCache = buildHandBrakePlaylistScanCache(resolveScanJson, playlistCandidates, rawPath); + if (preparedCache) { + handBrakePlaylistScan = preparedCache; + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.` + ); + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + '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', + `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}` + ); + throw error; + } + } else { + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).' + ); + } + } + + 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 (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) { + const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`; + const score = Number(candidate?.score); + const scoreLabel = Number.isFinite(score) ? score.toFixed(0) : '-'; + const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-'; + const recommendedLabel = candidate?.recommended ? ' (empfohlen)' : ''; + const evaluationLabel = candidate?.evaluationLabel ? ` | ${candidate.evaluationLabel}` : ''; + await historyService.appendLog( + jobId, + 'SYSTEM', + `${playlistFile} -> Dauer ${durationLabel} | Score ${scoreLabel}${recommendedLabel}${evaluationLabel}` + ); + } + 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, + 'USER_ACTION', + `Playlist-Auswahl übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` + ); + } + + const selectedTitleForReview = pickTitleIdForTrackReview(playlistAnalysis, selectedTitleForContext); + if (selectedTitleForReview === null) { + const error = new Error('Titel-/Spurprüfung aus RAW nicht möglich: keine auflösbare Titel-ID vorhanden.'); + error.statusCode = 400; + throw error; + } + + 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 hasCachedHandBrakeEntry = Boolean( + isHandBrakePlaylistCacheEntryCompatible( + cachedHandBrakePlaylistEntry, + resolvedPlaylistId, + { + expectedDurationSeconds: expectedDurationForCache, + expectedSizeBytes: expectedSizeForCache + } + ) + ); + 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) + }); + resolveScanLines.push(...pluginScan.scanLines); + handBrakeResolveRunInfo = pluginScan.runInfo || null; + 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; + } + + resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, { + expectedMakemkvTitleId: selectedTitleForReview, + expectedDurationSeconds: expectedDurationForCache, + expectedSizeBytes: expectedSizeForCache + }); + if (!resolvedHandBrakeTitleId) { + const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson); + 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 + })) { + 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 syntheticFilePath = path.join( + rawPath, + reviewTitleSource === 'handbrake' && Number.isFinite(Number(resolvedHandBrakeTitleId)) && Number(resolvedHandBrakeTitleId) > 0 + ? `handbrake_t${String(Math.trunc(Number(resolvedHandBrakeTitleId))).padStart(2, '0')}.mkv` + : `makemkv_t${String(selectedTitleForReview).padStart(2, '0')}.mkv` + ); + const syntheticMediaInfoByPath = { + [syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(reviewTitleInfo) + }; + let review = buildMediainfoReview({ + mediaFiles: [{ + path: syntheticFilePath, + size: Number(reviewTitleInfo?.sizeBytes || 0) + }], + mediaInfoByPath: syntheticMediaInfoByPath, + settings, + presetProfile, + playlistAnalysis, + preferredEncodeTitleId: selectedTitleForReview, + selectedPlaylistId: resolvedPlaylistId || reviewTitleInfo?.playlistId || null, + selectedMakemkvTitleId: selectedTitleForReview + }); + review = remapReviewTrackIdsToSourceIds(review); + + const resolvedPlaylistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId); + const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0); + const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60)); + const subtitleTrackMetaBySourceId = new Map( + (Array.isArray(reviewTitleInfo?.subtitleTracks) ? reviewTitleInfo.subtitleTracks : []) + .map((track) => { + const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; + return sourceTrackId ? [sourceTrackId, track] : null; + }) + .filter(Boolean) + ); + const normalizedTitles = (Array.isArray(review.titles) ? review.titles : []) + .slice(0, 1) + .map((title) => { + const durationSeconds = Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0); + const eligibleForEncode = durationSeconds >= minLengthSecondsForReview; + 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(sourceMeta?.selectedByRule ?? track?.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(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn); + const subtitlePreviewForced = Boolean( + sourceMeta?.subtitlePreviewForced + ?? track?.subtitlePreviewForced + ?? (selectedByRule && subtitleType === 'forced') + ); + const subtitlePreviewForcedOnly = Boolean( + sourceMeta?.subtitlePreviewForcedOnly + ?? track?.subtitlePreviewForcedOnly + ); + const isForcedOnly = Boolean( + sourceMeta?.isForcedOnly + ?? track?.isForcedOnly + ?? sourceMeta?.forcedOnly + ?? track?.forcedOnly + ?? subtitlePreviewForcedOnly + ?? subtitleType === 'forced' + ); + const fullHasForced = !isForcedOnly && Boolean( + sourceMeta?.fullHasForced + ?? track?.fullHasForced + ?? sourceMeta?.subtitleFullHasForced + ?? track?.subtitleFullHasForced + ); + const subtitlePreviewDefaultTrack = Boolean( + sourceMeta?.subtitlePreviewDefaultTrack + ?? track?.subtitlePreviewDefaultTrack + ?? sourceMeta?.defaultFlag + ?? track?.defaultFlag + ); + const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags) + ? sourceMeta.subtitlePreviewFlags + : (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []); + const subtitlePreviewSummary = String( + sourceMeta?.subtitlePreviewSummary + || track?.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, + filePath: rawPath, + fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`, + durationSeconds, + durationMinutes: Number(((durationSeconds / 60)).toFixed(2)), + selectedByMinLength: eligibleForEncode, + eligibleForEncode, + sizeBytes: Number(reviewTitleInfo?.sizeBytes || title?.sizeBytes || 0), + playlistId: resolvedPlaylistInfo.playlistId || title?.playlistId || null, + playlistFile: resolvedPlaylistInfo.playlistFile || title?.playlistFile || null, + playlistRecommended: Boolean(resolvedPlaylistInfo.recommended || title?.playlistRecommended), + playlistEvaluationLabel: resolvedPlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null, + playlistSegmentCommand: resolvedPlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null, + playlistSegmentFiles: Array.isArray(resolvedPlaylistInfo.segmentFiles) && resolvedPlaylistInfo.segmentFiles.length > 0 + ? resolvedPlaylistInfo.segmentFiles + : (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []), + subtitleTracks + }; + }); + + const encodeInputTitleId = Number(normalizedTitles[0]?.id || review.encodeInputTitleId || null) || null; + review = { + ...review, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + reviewConfirmed: false, + partial: false, + processedFiles: 1, + totalFiles: 1, + handBrakeTitleId: resolvedHandBrakeTitleId || null, + selectedPlaylistId: resolvedPlaylistId || null, + selectedMakemkvTitleId: selectedTitleForReview, + titleSelectionRequired: false, + titles: normalizedTitles, + selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [], + encodeInputTitleId, + encodeInputPath: rawPath, + 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).` + ] + }; + + const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( + review, + options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null + ); + review = reviewPrefillResult.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 (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}` + ); + } + } + + 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 selectMetadata({ + jobId, + title, + year, + imdbId, + poster, + fromOmdb = null, + selectedPlaylist = null, + selectedHandBrakeTitleId = null, + selectedHandBrakeTitleIds = null, + metadataProvider = null, + providerId = null, + tmdbId = null, + metadataKind = null, + seasonNumber = null, + seasonName = null, + episodeCount = null, + episodes = null, + discNumber = null + }) { + this.ensureNotBusy('selectMetadata', jobId); + logger.info('metadata:selected', { + jobId, + title, + year, + imdbId, + poster, + fromOmdb, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, + metadataProvider, + providerId, + tmdbId, + seasonNumber, + discNumber + }); + + 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 + || (fromOmdb !== null && fromOmdb !== 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 selectedFromOmdb = fromOmdb === null || fromOmdb === undefined + ? Number(job.selected_from_omdb || 0) + : (fromOmdb ? 1 : 0); + const posterValue = poster === undefined + ? (job.poster_url || null) + : (poster || null); + const effectiveDiscNumber = normalizePositiveInteger(discNumber); + + const converterMetadataProvider = String(metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; + let omdbJsonValue = converterMetadataProvider === 'omdb' + ? (job.omdb_json || null) + : null; + if (fromOmdb && effectiveImdbId && converterMetadataProvider === 'omdb') { + try { + const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); + if (omdbFull?.raw) { + omdbJsonValue = JSON.stringify(omdbFull.raw); + } + } catch (omdbErr) { + logger.warn('metadata:converter:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) }); + } + } + + 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: selectedFromOmdb, + omdb_json: omdbJsonValue, + 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 (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 + || (fromOmdb !== null && fromOmdb !== 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 selectedFromOmdb = fromOmdb === null || fromOmdb === undefined + ? Number(job.selected_from_omdb || 0) + : (fromOmdb ? 1 : 0); + const posterValue = poster === undefined + ? (job.poster_url || null) + : (poster || null); + const effectiveMetadataProvider = String( + metadataProvider + || mkInfo?.analyzeContext?.metadataProvider + || 'omdb' + ).trim().toLowerCase() || 'omdb'; + const effectiveProviderId = String( + providerId + || mkInfo?.analyzeContext?.selectedMetadata?.providerId + || '' + ).trim() || null; + const effectiveTmdbId = Number( + tmdbId + || mkInfo?.analyzeContext?.selectedMetadata?.tmdbId + || 0 + ) || null; + const effectiveMetadataKind = String( + metadataKind + || mkInfo?.analyzeContext?.selectedMetadata?.metadataKind + || '' + ).trim().toLowerCase() || null; + const effectiveDiscNumber = normalizePositiveInteger(discNumber); + const 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; + let effectiveEpisodes = Array.isArray(episodes) + ? episodes + : (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes) + ? mkInfo.analyzeContext.selectedMetadata.episodes + : []); + let tmdbDetails = null; + + const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveMetadataProvider === 'tmdb'; + if (isDvdTmdbMetadataSelection && !effectiveDiscNumber) { + const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).'); + error.statusCode = 400; + throw error; + } + + // Fetch full OMDb details when selecting from OMDb with a valid IMDb ID. + let omdbJsonValue = effectiveMetadataProvider === 'omdb' + ? (job.omdb_json || null) + : null; + if (fromOmdb && effectiveImdbId && effectiveMetadataProvider === 'omdb') { + try { + const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); + if (omdbFull?.raw) { + omdbJsonValue = JSON.stringify(omdbFull.raw); + } + } catch (omdbErr) { + logger.warn('metadata:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) }); + } + } + + let seasonDetails = null; + let seasonCredits = null; + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) { + try { + const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { + 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 && effectiveSeasonNumber) { + try { + seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber); + seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber); + const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); + if (seasonSummary) { + const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; + if (fetchedEpisodes.length > 0) { + if (!Array.isArray(effectiveEpisodes) || effectiveEpisodes.length === 0) { + 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, + providerId: effectiveProviderId, + tmdbId: effectiveTmdbId, + metadataKind: effectiveMetadataKind, + ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}), + seasonNumber: effectiveSeasonNumber, + seasonName: effectiveSeasonName, + episodeCount: effectiveEpisodeCount, + episodes: effectiveEpisodes, + ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) + }; + + let parentContainerJobId = 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: selectedFromOmdb, + omdb_json: omdbJsonValue, + 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 ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' + ? 'backup' + : 'mkv'; + const isBackupMode = ripMode === 'backup'; + const metadataBase = buildRawMetadataBase({ + title: selectedMetadata.title || job.detected_title || null, + year: selectedMetadata.year || null, + detected_title: job.detected_title || null, + media_type: mediaProfile + }, jobId, { + mediaProfile, + analyzeContext: mkInfo?.analyzeContext || null, + selectedMetadata + }); + const rawStorage = resolveSeriesAwareRawStorage( + settings, + mediaProfile, + selectedMetadata, + mkInfo?.analyzeContext || null + ); + const rawBaseDir = String( + rawStorage?.rawBaseDir + || settings?.raw_dir + || settingsService.DEFAULT_RAW_DIR + || '' + ).trim(); + const existingRawPath = findExistingRawDirectory(rawBaseDir, metadataBase); + let updatedRawPath = existingRawPath || null; + 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 requiresManualPlaylistSelection = Boolean( + playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null + ); + const nextStatus = requiresManualPlaylistSelection ? '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, + selectedMetadata + } + }, mediaProfile); + + await historyService.updateJob(jobId, { + title: effectiveTitle, + year: effectiveYear, + imdb_id: effectiveImdbId, + poster_url: posterValue, + selected_from_omdb: selectedFromOmdb, + omdb_json: omdbJsonValue, + 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 } : {}) + }); + + // Bild in Cache laden (async, blockiert nicht) + if (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 { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Kein bestehendes RAW-Verzeichnis zu den Metadaten gefunden (${metadataBase}).` + ); + } + if (rawStorage.usingSeriesRawPath) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-DVD RAW-Pfad aktiv: ${rawBaseDir}` + ); + } + + if (!keepCurrentPipelineSession) { + await this.setState(nextStatus, { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : (existingRawPath + ? '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, + manualDecisionState: 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 (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 (existingRawPath) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW.' + ); + 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); + } + + 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 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 }); + } + + const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE'; + if (isReadyToEncode) { + // 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 }) + ); + } + + 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) { + // 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 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 historyService.resetProcessLog(jobId); + + 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 }; + } + + 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.` + ); + } + + 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 }) + ); + } + + 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_SELECTION') { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten-Auswahl übersprungen. Starte Review ohne OMDb-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 selectedEncodeTitleId = options?.selectedEncodeTitleId ?? null; + const selectedEncodeTitleIds = normalizeReviewTitleIdList(options?.selectedEncodeTitleIds); + const effectiveSelectedEncodeTitleIds = selectedEncodeTitleIds.length > 0 + ? selectedEncodeTitleIds + : normalizeReviewTitleIdList(selectedEncodeTitleId ? [selectedEncodeTitleId] : []); + const planWithSelectionResult = applyEncodeTitleSelectionToPlan( + encodePlan, + selectedEncodeTitleId, + effectiveSelectedEncodeTitleIds + ); + let planForConfirm = planWithSelectionResult.plan; + const selectedTrackSelectionPayload = options?.selectedTrackSelection && typeof options.selectedTrackSelection === 'object' + ? options.selectedTrackSelection + : null; + const trackSelectionTargets = normalizeReviewTitleIdList( + Array.isArray(planForConfirm?.selectedTitleIds) && planForConfirm.selectedTitleIds.length > 0 + ? planForConfirm.selectedTitleIds + : [planForConfirm?.encodeInputTitleId] + ); + 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( + 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 = options?.selectedPostEncodeScriptIds !== undefined; + const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection + ? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || []) + : normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []); + const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { + strict: true + }); + + const hasExplicitPreScriptSelection = options?.selectedPreEncodeScriptIds !== undefined; + const selectedPreEncodeScriptIds = hasExplicitPreScriptSelection + ? normalizeScriptIdList(options?.selectedPreEncodeScriptIds || []) + : normalizeScriptIdList(planForConfirm?.preEncodeScriptIds || encodePlan?.preEncodeScriptIds || []); + const selectedPreEncodeScripts = await scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }); + + const hasExplicitPostChainSelection = options?.selectedPostEncodeChainIds !== undefined; + const selectedPostEncodeChainIds = hasExplicitPostChainSelection + ? normalizeChainIdList(options?.selectedPostEncodeChainIds || []) + : normalizeChainIdList(planForConfirm?.postEncodeChainIds || encodePlan?.postEncodeChainIds || []); + + const hasExplicitPreChainSelection = 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; + } + + // Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan. + const hasExplicitUserPresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId'); + const hasExplicitHandBrakePresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset'); + const rawHandBrakePreset = hasExplicitHandBrakePresetSelection + ? String(options?.selectedHandBrakePreset || '').trim() + : ''; + let resolvedUserPreset = null; + if (hasExplicitUserPresetSelection) { + const rawUserPresetId = options?.selectedUserPresetId; + const userPresetId = rawUserPresetId !== null && rawUserPresetId !== undefined && String(rawUserPresetId).trim() !== '' + ? Number(rawUserPresetId) + : null; + if (Number.isFinite(userPresetId) && userPresetId > 0) { + resolvedUserPreset = await userPresetService.getPresetById(userPresetId); + if (!resolvedUserPreset) { + const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + } + } + if ((!resolvedUserPreset || !hasExplicitUserPresetSelection) && hasExplicitHandBrakePresetSelection) { + resolvedUserPreset = rawHandBrakePreset + ? { + name: `HandBrake: ${rawHandBrakePreset}`, + handbrakePreset: rawHandBrakePreset, + extraArgs: '' + } + : null; + } + if (!hasExplicitUserPresetSelection && !hasExplicitHandBrakePresetSelection) { + resolvedUserPreset = normalizeUserPresetForPlan(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 readyMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: confirmedPlan + }); + if (readyMediaProfile === 'converter' && !confirmedPlan.userPreset) { + const error = new Error('Bestätigung nicht möglich: Bitte ein User- oder HandBrake-Preset auswählen.'); + error.statusCode = 400; + throw error; + } + const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); + 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'}.` + + (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', '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 historyService.resetProcessLog(sourceJobId); + 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 historyService.resetProcessLog(sourceJobId); + 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 historyService.resetProcessLog(sourceJobId); + + // 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', '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 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: `Track ${normalizedPosition}`, + artist: null, + selected: track?.selected !== false + }; + }); + const selectedMetadata = {}; + 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_METADATA_SELECTION', + last_state: 'CD_METADATA_SELECTION', + 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_METADATA_SELECTION', + jobId, + device: null, + progress: 0, + eta: null, + statusText: 'Vorprüfung (kein Laufwerk)', + 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). ${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; + } + + 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); + 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-DVD 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 + + 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) + }); + dvdScanLines.push(...pluginScan.scanLines); + dvdScanRunInfo = pluginScan.runInfo || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + + dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n')); + if (dvdHandBrakeScanJson) { + const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson); + const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdScanLines); + 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-DVD 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 reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( + enrichedReview, + options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null + ); + enrichedReview = reviewPrefillResult.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 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-DVD: ${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) + }); + dvdTitleScanLines.push(...pluginScan.scanLines); + dvdTitleScanRunInfo = pluginScan.runInfo || null; + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n')); + if (dvdTitleScanJson) { + const allTitles = parseHandBrakeTitleList(dvdTitleScanJson); + const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdTitleScanLines); + 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-DVD 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 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-DVD: ${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'}.` + ); + } + + 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 + }; + } + + 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); + const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, outputPathJobView, jobId); + 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 preEncodeContext = { + mode, + jobId, + jobTitle: job.title || job.detected_title || null, + inputPath, + rawPath: activeRawPath, + mediaProfile + }; + 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 encodeScriptProgressTracker = createEncodeScriptProgressTracker({ + jobId, + preSteps: preScriptIds.length + normalizedPreChainIds.length, + postSteps: postScriptIds.length + normalizedPostChainIds.length, + updateProgress: this.updateProgress.bind(this) + }); + let preEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; + if (preScriptIds.length > 0 || preChainIds.length > 0) { + await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten werden ausgeführt...'); + try { + preEncodeScriptsSummary = await this.runPreEncodeScripts( + jobId, + encodePlan, + preEncodeContext, + encodeScriptProgressTracker + ); + } catch (preError) { + if (preError.preEncodeFailed) { + await this.failJob(jobId, 'ENCODING', preError); + throw preError; + } + throw preError; + } + await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten abgeschlossen.'); + } + + 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; + } + 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(encodePlan?.handBrakeTitleId); + 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; + 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 + }); + 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 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 }); + }); + let postEncodeScriptsSummary = { + configured: 0, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + results: [] + }; + try { + postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, { + mode, + jobTitle: job.title || job.detected_title || null, + inputPath, + outputPath: finalizedOutputPath, + rawPath: activeRawPath + }, encodeScriptProgressTracker); + } catch (error) { + logger.warn('encode:post-script:summary-failed', { + jobId, + error: errorToMeta(error) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}` + ); + } + if (postEncodeScriptsSummary.configured > 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}` + ); + } + let finalizedRawPath = activeRawPath || null; + if (activeRawPath) { + const currentRawPath = String(activeRawPath || '').trim(); + const completedRawPath = buildCompletedRawPath(currentRawPath); + if (completedRawPath && completedRawPath !== currentRawPath) { + if (fs.existsSync(completedRawPath)) { + 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 + }); + 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 + }); + + if (job?.parent_job_id) { + await this.syncSeriesContainerStatusFromChildren(job.parent_job_id).catch((parentSyncError) => { + logger.warn('series-container:status-sync-on-child-finish-failed', { + parentJobId: job.parent_job_id, + 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 (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 + }, jobId, { + mediaProfile, + analyzeContext, + selectedMetadata + }); + 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-DVD erkannt: MakeMKV-Rip läuft ohne Mindestlängen-Filter (--minlength deaktiviert).' + ); + } + if (rawStorage.usingSeriesRawPath) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-DVD RAW-Pfad aktiv: ${effectiveRawBaseDir}` + ); + } + const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); + if (devicePath) { + 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, + 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 always starts a rip → bypass the encode queue entirely. + 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 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'; + + // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden. + const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) + || ['ERROR', 'CANCELLED'].includes(sourceLastState) + || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)); + if (!retryable) { + const error = new Error( + `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).` + ); + 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) + } else if (!isAudiobookRetry) { + 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 retryJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), + detectedTitle: sourceJob.detected_title || sourceJob.title || null, + jobKind: this.resolveJobKindForJob(sourceJob, { + encodePlan: sourceEncodePlan + }) + }); + const retryJobId = Number(retryJob?.id || 0); + if (!Number.isFinite(retryJobId) || retryJobId <= 0) { + throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + + let retryRawPath = isAudiobookRetry ? (sourceJob.raw_path || null) : null; + let retryEncodeInputPath = isAudiobookRetry + ? ( + sourceJob.encode_input_path + || sourceEncodePlan?.encodeInputPath + || sourceMakemkvInfo?.rawFilePath + || null + ) + : null; + if (retryRawPath) { + const previousRetryRawPath = retryRawPath; + retryRawPath = await this.alignRawFolderJobId(retryRawPath, retryJobId); + if ( + previousRetryRawPath + && retryRawPath + && normalizeComparablePath(previousRetryRawPath) !== normalizeComparablePath(retryRawPath) + ) { + retryEncodeInputPath = remapPathToRetargetedRawRoot( + retryEncodeInputPath, + previousRetryRawPath, + retryRawPath + ); + } + } + + const retryUpdatePayload = { + parent_job_id: Number(jobId), + 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: sourceJob.omdb_json || null, + selected_from_omdb: Number(sourceJob.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: (isCdRetry || isAudiobookRetry) + ? (sourceJob.encode_plan_json || null) + : null, + encode_input_path: retryEncodeInputPath, + encode_review_confirmed: isAudiobookRetry ? 1 : 0, + output_path: null, + raw_path: retryRawPath, + status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), + last_state: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING') + }; + await historyService.updateJob(retryJobId, retryUpdatePayload); + + // 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), 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' : 'Disc')}).` + ); + if (preservedRetryMediaType && !explicitSourceMediaType) { + await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {}); + } + this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); + await historyService.retireJobInFavorOf(jobId, retryJobId, { + reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_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: true, + started: false, + queued: false, + stage: 'READY_TO_START' + }; + } 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: true + }; + } + + 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 job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId); + + const currentStatus = String(job.status || '').trim().toUpperCase(); + if (['ANALYZING', '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 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 && restartDeleteIncompleteOutput && !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 + && restartDeleteIncompleteOutput + && !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); + + 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 + }) + }); + const replacementJobId = Number(replacementJob?.id || 0); + if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { + throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + + const previousRestartRawPath = activeRestartRawPath; + activeRestartRawPath = await this.alignRawFolderJobId(activeRestartRawPath, replacementJobId); + if ( + previousRestartRawPath + && activeRestartRawPath + && normalizeComparablePath(previousRestartRawPath) !== normalizeComparablePath(activeRestartRawPath) + ) { + inputPath = remapPathToRetargetedRawRoot(inputPath, previousRestartRawPath, activeRestartRawPath); + restartPlan.encodeInputPath = inputPath; + } + + await historyService.updateJob(replacementJobId, { + parent_job_id: Number(jobId), + 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: job.omdb_json || null, + selected_from_omdb: Number(job.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 + }); + + // Keep local poster thumbnails valid for the replacement job id. + if (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=${restartDeleteIncompleteOutput ? '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 (preservedRestartMediaType && !explicitJobMediaType) { + await historyService.updateJob(jobId, { media_type: preservedRestartMediaType }).catch(() => {}); + } + 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: true, + 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', '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 = Boolean(options?.reuseCurrentJob); + 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 historyService.resetProcessLog(targetJobId); + 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: sourceJob.omdb_json || null, + selected_from_omdb: Number(sourceJob.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 entryJobId = Number(entry?.jobId); + 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) + ); + return !(isLegacyParentEntry || isSeriesChildJobEntry); + }); + 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_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); + await historyService.appendLog( + normalizedJobId, + 'USER_ACTION', + `Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}` + ); + 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_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'CD_METADATA_SELECTION', + 'CD_READY_TO_RIP' + ]); + + if (!processHandle) { + if (SOFT_CANCELABLE_STATES.has(runningStatus)) { + // Kein laufender Prozess – Job direkt abbrechen (Review-/Ready-Phasen) + const cancelMessage = 'Vom Benutzer abgebrochen.'; + 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 + }); + + 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); + } + + 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 (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, + 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) => { + 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) => { + 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'; + 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 shouldAutoRecoverEncode = normalizedStage === 'ENCODING' + && hasConfirmedPlan + && (!isCancelled || resolvedMediaProfile === 'converter'); + 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}` + ); + 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 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 historyService.resetProcessLog(job.id); + 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 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, + 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}` + ); + + 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 }) + ); + } + + 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 historyService.resetProcessLog(jobId); + 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 + }); + + // 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 } + ); + } + + 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 historyService.resetProcessLog(jobId); + 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 + }); + + // 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) { + logger.info('musicbrainz:search', { query }); + const results = await musicBrainzService.searchByTitle(query); + logger.info('musicbrainz:search:done', { query, 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) || {}; + + // 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 + }; + }); + + const updatedCdInfo = { + ...cdInfo, + tracks: mergedTracks, + selectedMetadata: { title, artist, year, mbId, coverUrl } + }; + + await historyService.updateJob(jobId, { + title: title || null, + year: year ? Number(year) : null, + poster_url: coverUrl || null, + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', + makemkv_info_json: JSON.stringify(updatedCdInfo) + }); + + // Bild in Cache laden (async, blockiert nicht) + if (coverUrl) { + historyService.queuePosterCache(jobId, coverUrl, { + source: 'Coverart', + logFailures: true + }); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).` + ); + + const resolvedDevicePath = String(job?.disc_device || '').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) { + this._setCdDriveState(resolvedDevicePath, { + state: 'CD_READY_TO_RIP', + jobId, + progress: 0, + eta: null, + statusText: 'CD bereit zum Rippen', + context: { + ...(existingDrive?.context || {}), + jobId, + mediaProfile: 'cd', + tracks: mergedTracks, + selectedMetadata: { title, artist, year, mbId, coverUrl }, + devicePath: resolvedDevicePath, + cdparanoiaCmd: resolvedCdparanoiaCmd, + cdparanoiaCommandPreview + } + }); + } else { + // No real device — update virtual drive if present (orphan CD job) + const virtualDrivePath = `__virtual__${jobId}`; + const existingVirtualDrive = this.cdDrives.get(virtualDrivePath); + if (existingVirtualDrive) { + 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 }, + devicePath: null, + virtualDrivePath, + skipRip: true, + cdparanoiaCmd: resolvedCdparanoiaCmd + } + }); + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + } + } + + const canAutoStartRawRip = Boolean( + resolvedDevicePath + && !String(resolvedDevicePath).startsWith('__virtual__') + && Number(job?.rip_successful || 0) !== 1 + ); + if (canAutoStartRawRip) { + const autoRawRipConfig = { + selectedTracks: mergedTracks + .filter((track) => track?.selected !== false) + .map((track) => Number(track?.position)) + .filter((value) => Number.isFinite(value) && value > 0), + tracks: mergedTracks, + metadata: { title, artist, year, mbId, coverUrl }, + skipEncode: true + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten bestätigt. Starte automatisch RAW-Rip (Encode-Auswahl folgt danach).' + ); + this.startCdRip(Number(jobId), autoRawRipConfig).then((result) => { + logger.info('cd:auto-raw-rip:started', { + jobId: Number(jobId), + replacedSourceJob: Boolean(result?.replacedSourceJob), + replacementJobId: Number(result?.jobId || 0) || null + }); + }).catch((error) => { + logger.error('cd:auto-raw-rip:failed', { + jobId: Number(jobId), + error: errorToMeta(error) + }); + historyService.appendLog( + Number(jobId), + 'SYSTEM', + `Automatischer RAW-Rip konnte nicht gestartet werden: ${error?.message || 'unknown'}` + ).catch(() => {}); + }); + } + + 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 rawBaseDir = 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 + }, jobId, { + mediaProfile, + analyzeContext: mkInfo?.analyzeContext || null, + selectedMetadata + }); + 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 { + const newOutputPath = isAudiobook + ? buildAudiobookOutputConfig(settings, job, mkInfo, encodePlan, jobId).preferredFinalOutputPath + : buildFinalOutputPathFromJob(settings, job, jobId); + 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 = 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: sourceJob.omdb_json || null, + selected_from_omdb: Number(sourceJob.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, + year: effectiveSelectedMeta?.year || null + }, activeJobId); + 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 + } + }); + + if (!skipRip && !String(effectiveDevicePath || '').startsWith('__virtual__')) { + this._acquireDriveLockForJob(effectiveDevicePath, activeJobId, { + stage: 'CD_RIPPING', + source: 'CDPARANOIA_RIP', + mediaProfile: 'cd', + reason: 'rip_running' + }); + } else { + this._releaseDriveLockForJob(activeJobId, { reason: '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 || []); + let preEncodeScriptsSummary = { + configured: 0, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + results: [] + }; + let postEncodeScriptsSummary = { + configured: 0, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 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 (preScriptIds.length > 0 || preChainIds.length > 0) { + await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten werden ausgeführt...'); + preEncodeScriptsSummary = await this.runPreEncodeScripts(jobId, normalizedEncodePlan, { + mode: 'cd_rip', + jobId, + jobTitle: selectedMeta?.title || `Job #${jobId}`, + inputPath: devicePath || null, + outputPath: outputDir || null, + rawPath: rawWavDir || null, + mediaProfile: 'cd', + pipelineStage: 'CD_RIPPING' + }); + await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten abgeschlossen.'); + } + let encodeStateApplied = false; + let lastProgressPercent = 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 (skipEncode && normalizedPhase === 'rip') { + clampedPercent = Math.max(0, Math.min(100, clampedPercent)); + } else if (normalizedPhase === 'rip') { + clampedPercent = Math.min(clampedPercent, 50); + } else { + clampedPercent = Math.max(50, clampedPercent); + } + if (clampedPercent < lastProgressPercent) { + clampedPercent = lastProgressPercent; + } + clampedPercent = Number(clampedPercent.toFixed(2)); + lastProgressPercent = clampedPercent; + + 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 fallbackPercent = Math.max(lastProgressPercent, Math.min(100, Number(progressPercent) || 0)); + const fallbackStage = currentPhase === 'encode' ? 'CD_ENCODING' : 'CD_RIPPING'; + 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(); + + if (postScriptIds.length > 0 || postChainIds.length > 0) { + await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...'); + try { + postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, { + mode: 'cd_rip', + jobId, + jobTitle: selectedMeta?.title || `Job #${jobId}`, + inputPath: devicePath || null, + outputPath: outputDir || null, + rawPath: rawWavDir || null, + mediaProfile: 'cd', + pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING' + }); + } catch (error) { + logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Rip Skripte/Ketten konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}` + ); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Rip Skripte/Ketten abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ` + + `${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.` + ); + } + + // 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)); + } + } + } 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; OMDb kann später zugeordnet werden.' + ); + } + + 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_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 selectedFromOmdb = metadataImdbId ? 1 : 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 = selectedFromOmdb; + } + 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 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 + }; + }); + } + + /** + * 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..d999b96 --- /dev/null +++ b/backend/src/services/scriptChainService.js @@ -0,0 +1,927 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('SCRIPT_CHAINS'); +const runtimeActivityService = require('./runtimeActivityService'); +const { spawnTrackedProcess } = require('./processRunner'); +const { errorToMeta } = require('../utils/errorMeta'); + +const CHAIN_NAME_MAX_LENGTH = 120; +const STEP_TYPE_SCRIPT = 'script'; +const STEP_TYPE_WAIT = 'wait'; +const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]); + +function normalizeChainId(rawValue) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); +} + +function createValidationError(message, details = null) { + const error = new Error(message); + error.statusCode = 400; + if (details) { + error.details = details; + } + return error; +} + +function mapChainRow(row, steps = []) { + if (!row) { + return null; + } + return { + id: Number(row.id), + name: String(row.name || ''), + orderIndex: Number(row.order_index || 0), + steps: steps.map(mapStepRow), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function mapStepRow(row) { + if (!row) { + return null; + } + return { + id: Number(row.id), + position: Number(row.position), + stepType: String(row.step_type || ''), + scriptId: row.script_id != null ? Number(row.script_id) : null, + scriptName: row.script_name != null ? String(row.script_name) : null, + waitSeconds: row.wait_seconds != null ? Number(row.wait_seconds) : null + }; +} + +function terminateChildProcess(child, { immediate = false } = {}) { + if (!child) { + return; + } + const signal = immediate ? 'SIGKILL' : 'SIGTERM'; + const pid = Number(child.pid); + if (Number.isFinite(pid) && pid > 0) { + try { + // For detached children this targets the full process group. + process.kill(-pid, signal); + return; + } catch (_error) { + // Fall through to direct child signal. + } + } + try { + child.kill(signal); + } catch (_error) { + return; + } +} + +function appendTailText(currentValue, nextChunk, maxChars = 12000) { + const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (!chunk) { + return { + value: currentValue || '', + truncated: false + }; + } + const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`; + const combined = `${String(currentValue || '')}${normalizedChunk}`; + if (combined.length <= maxChars) { + return { + value: combined, + truncated: false + }; + } + return { + value: combined.slice(-maxChars), + truncated: true + }; +} + +function validateSteps(rawSteps) { + const steps = Array.isArray(rawSteps) ? rawSteps : []; + const errors = []; + const normalized = []; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i] && typeof steps[i] === 'object' ? steps[i] : {}; + const stepType = String(step.stepType || step.step_type || '').trim(); + + if (!VALID_STEP_TYPES.has(stepType)) { + errors.push({ field: `steps[${i}].stepType`, message: `Ungültiger Schritt-Typ: '${stepType}'. Erlaubt: script, wait.` }); + continue; + } + + if (stepType === STEP_TYPE_SCRIPT) { + const scriptId = Number(step.scriptId ?? step.script_id); + if (!Number.isFinite(scriptId) || scriptId <= 0) { + errors.push({ field: `steps[${i}].scriptId`, message: 'scriptId fehlt oder ist ungültig.' }); + continue; + } + normalized.push({ stepType, scriptId: Math.trunc(scriptId), waitSeconds: null }); + } else if (stepType === STEP_TYPE_WAIT) { + const waitSeconds = Number(step.waitSeconds ?? step.wait_seconds); + if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) { + errors.push({ field: `steps[${i}].waitSeconds`, message: 'waitSeconds muss zwischen 1 und 3600 liegen.' }); + continue; + } + normalized.push({ stepType, scriptId: null, waitSeconds: Math.round(waitSeconds) }); + } + } + + if (errors.length > 0) { + throw createValidationError('Ungültige Schritte in der Skriptkette.', errors); + } + + return normalized; +} + +async function getStepsForChain(db, chainId) { + return db.all( + ` + SELECT + s.id, + s.chain_id, + s.position, + s.step_type, + s.script_id, + s.wait_seconds, + sc.name AS script_name + FROM script_chain_steps s + LEFT JOIN scripts sc ON sc.id = s.script_id + WHERE s.chain_id = ? + ORDER BY s.position ASC, s.id ASC + `, + [chainId] + ); +} + +class ScriptChainService { + async listChains() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT id, name, order_index, created_at, updated_at + FROM script_chains + ORDER BY order_index ASC, id ASC + ` + ); + + if (rows.length === 0) { + return []; + } + + const chainIds = rows.map((row) => Number(row.id)); + const placeholders = chainIds.map(() => '?').join(', '); + const stepRows = await db.all( + ` + SELECT + s.id, + s.chain_id, + s.position, + s.step_type, + s.script_id, + s.wait_seconds, + sc.name AS script_name + FROM script_chain_steps s + LEFT JOIN scripts sc ON sc.id = s.script_id + WHERE s.chain_id IN (${placeholders}) + ORDER BY s.chain_id ASC, s.position ASC, s.id ASC + `, + chainIds + ); + + const stepsByChain = new Map(); + for (const step of stepRows) { + const cid = Number(step.chain_id); + if (!stepsByChain.has(cid)) { + stepsByChain.set(cid, []); + } + stepsByChain.get(cid).push(step); + } + + return rows.map((row) => mapChainRow(row, stepsByChain.get(Number(row.id)) || [])); + } + + async getChainById(chainId) { + const normalizedId = normalizeChainId(chainId); + if (!normalizedId) { + throw createValidationError('Ungültige chainId.'); + } + const db = await getDb(); + const row = await db.get( + `SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`, + [normalizedId] + ); + if (!row) { + const error = new Error(`Skriptkette #${normalizedId} wurde nicht gefunden.`); + error.statusCode = 404; + throw error; + } + const steps = await getStepsForChain(db, normalizedId); + return mapChainRow(row, steps); + } + + async getChainsByIds(rawIds = []) { + const ids = Array.isArray(rawIds) + ? rawIds.map(normalizeChainId).filter(Boolean) + : []; + if (ids.length === 0) { + return []; + } + const db = await getDb(); + const placeholders = ids.map(() => '?').join(', '); + const rows = await db.all( + `SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`, + ids + ); + const stepRows = await db.all( + ` + SELECT + s.id, s.chain_id, s.position, s.step_type, s.script_id, s.wait_seconds, + sc.name AS script_name + FROM script_chain_steps s + LEFT JOIN scripts sc ON sc.id = s.script_id + WHERE s.chain_id IN (${placeholders}) + ORDER BY s.chain_id ASC, s.position ASC, s.id ASC + `, + ids + ); + const stepsByChain = new Map(); + for (const step of stepRows) { + const cid = Number(step.chain_id); + if (!stepsByChain.has(cid)) { + stepsByChain.set(cid, []); + } + stepsByChain.get(cid).push(step); + } + const byId = new Map(rows.map((row) => [ + Number(row.id), + mapChainRow(row, stepsByChain.get(Number(row.id)) || []) + ])); + return ids.map((id) => byId.get(id)).filter(Boolean); + } + + async createChain(payload = {}) { + const body = payload && typeof payload === 'object' ? payload : {}; + const name = String(body.name || '').trim(); + if (!name) { + throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]); + } + if (name.length > CHAIN_NAME_MAX_LENGTH) { + throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]); + } + const steps = validateSteps(body.steps); + + const db = await getDb(); + try { + const nextOrderIndex = await this._getNextOrderIndex(db); + const result = await db.run( + ` + INSERT INTO script_chains (name, order_index, created_at, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + [name, nextOrderIndex] + ); + const chainId = result.lastID; + await this._saveSteps(db, chainId, steps); + return this.getChainById(chainId); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE constraint failed')) { + throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]); + } + throw error; + } + } + + async updateChain(chainId, payload = {}) { + const normalizedId = normalizeChainId(chainId); + if (!normalizedId) { + throw createValidationError('Ungültige chainId.'); + } + const body = payload && typeof payload === 'object' ? payload : {}; + const name = String(body.name || '').trim(); + if (!name) { + throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]); + } + if (name.length > CHAIN_NAME_MAX_LENGTH) { + throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]); + } + const steps = validateSteps(body.steps); + + await this.getChainById(normalizedId); + + const db = await getDb(); + try { + await db.run( + `UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [name, normalizedId] + ); + await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]); + await this._saveSteps(db, normalizedId, steps); + return this.getChainById(normalizedId); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE constraint failed')) { + throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]); + } + throw error; + } + } + + async deleteChain(chainId) { + const normalizedId = normalizeChainId(chainId); + if (!normalizedId) { + throw createValidationError('Ungültige chainId.'); + } + const existing = await this.getChainById(normalizedId); + const db = await getDb(); + await db.run(`DELETE FROM script_chains WHERE id = ?`, [normalizedId]); + return existing; + } + + async reorderChains(orderedIds = []) { + const providedIds = Array.isArray(orderedIds) + ? orderedIds.map(normalizeChainId).filter(Boolean) + : []; + const db = await getDb(); + const rows = await db.all( + ` + SELECT id + FROM script_chains + ORDER BY order_index ASC, id ASC + ` + ); + if (rows.length === 0) { + return []; + } + + const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0); + const existingSet = new Set(existingIds); + const used = new Set(); + const nextOrder = []; + + for (const id of providedIds) { + if (!existingSet.has(id) || used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + for (const id of existingIds) { + if (used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + await db.exec('BEGIN'); + try { + for (let i = 0; i < nextOrder.length; i += 1) { + await db.run( + ` + UPDATE script_chains + SET order_index = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [i + 1, nextOrder[i]] + ); + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + return this.listChains(); + } + + async _getNextOrderIndex(db) { + const row = await db.get( + ` + SELECT COALESCE(MAX(order_index), 0) AS max_order_index + FROM script_chains + ` + ); + const maxOrder = Number(row?.max_order_index || 0); + if (!Number.isFinite(maxOrder) || maxOrder < 0) { + return 1; + } + return Math.trunc(maxOrder) + 1; + } + + async _saveSteps(db, chainId, steps) { + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + await db.run( + ` + INSERT INTO script_chain_steps (chain_id, position, step_type, script_id, wait_seconds, created_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, + [chainId, i + 1, step.stepType, step.scriptId ?? null, step.waitSeconds ?? null] + ); + } + } + + async executeChain(chainId, context = {}, { appendLog = null } = {}) { + const chain = await this.getChainById(chainId); + logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length }); + const totalSteps = chain.steps.length; + const activityId = runtimeActivityService.startActivity('chain', { + name: chain.name, + source: context?.source || 'chain', + chainId: chain.id, + jobId: context?.jobId || null, + cronJobId: context?.cronJobId || null, + parentActivityId: context?.runtimeParentActivityId || null, + currentStep: totalSteps > 0 ? `Schritt 1/${totalSteps}` : 'Keine Schritte' + }); + const controlState = { + cancelRequested: false, + cancelReason: null, + currentStepType: null, + activeWaitResolve: null, + activeChild: null, + activeChildTermination: null + }; + const emitRuntimeStep = (payload = {}) => { + if (typeof context?.onRuntimeStep !== 'function') { + return; + } + try { + context.onRuntimeStep({ + chainId: chain.id, + chainName: chain.name, + ...payload + }); + } catch (_error) { + // ignore runtime callback errors + } + }; + const requestCancel = async (payload = {}) => { + if (controlState.cancelRequested) { + return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' }; + } + controlState.cancelRequested = true; + controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen'; + runtimeActivityService.updateActivity(activityId, { + message: 'Abbruch angefordert', + currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { + controlState.activeWaitResolve('cancel'); + } else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { + controlState.activeChildTermination = 'cancel'; + terminateChildProcess(controlState.activeChild, { immediate: true }); + } + return { accepted: true, message: 'Abbruch angefordert.' }; + }; + const requestNextStep = async () => { + if (controlState.cancelRequested) { + return { accepted: false, message: 'Kette wird bereits abgebrochen.' }; + } + if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { + controlState.activeWaitResolve('skip'); + runtimeActivityService.updateActivity(activityId, { + message: 'Nächster Schritt angefordert (Wait übersprungen)' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Wait-Schritt manuell übersprungen.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + return { accepted: true, message: 'Wait-Schritt übersprungen.' }; + } + if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { + controlState.activeChildTermination = 'skip'; + terminateChildProcess(controlState.activeChild, { immediate: true }); + runtimeActivityService.updateActivity(activityId, { + message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript-Schritt manuell übersprungen.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + return { accepted: true, message: 'Skript-Schritt wird übersprungen.' }; + } + return { accepted: false, message: 'Kein aktiver Schritt zum Überspringen.' }; + }; + runtimeActivityService.setControls(activityId, { + cancel: requestCancel, + nextStep: requestNextStep + }); + + const results = []; + let completionPayload = null; + let abortedByUser = false; + try { + for (let index = 0; index < chain.steps.length; index += 1) { + if (controlState.cancelRequested) { + abortedByUser = true; + break; + } + const step = chain.steps[index]; + const stepIndex = index + 1; + if (step.stepType === STEP_TYPE_WAIT) { + const seconds = Math.max(1, Number(step.waitSeconds || 1)); + const waitLabel = `Warte ${seconds} Sekunde(n)`; + controlState.currentStepType = STEP_TYPE_WAIT; + runtimeActivityService.updateActivity(activityId, { + currentStepType: 'wait', + currentStep: waitLabel, + currentScriptName: null, + stepIndex, + stepTotal: totalSteps + }); + emitRuntimeStep({ + stepType: 'wait', + stepIndex, + stepTotal: totalSteps, + currentStep: waitLabel + }); + logger.info('chain:step:wait', { chainId, seconds }); + if (typeof appendLog === 'function') { + await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`); + } + const waitOutcome = await new Promise((resolve) => { + const timer = setTimeout(() => { + controlState.activeWaitResolve = null; + resolve('done'); + }, seconds * 1000); + controlState.activeWaitResolve = (mode = 'done') => { + clearTimeout(timer); + controlState.activeWaitResolve = null; + resolve(mode); + }; + }); + controlState.currentStepType = null; + if (waitOutcome === 'skip') { + results.push({ stepType: 'wait', waitSeconds: seconds, success: true, skipped: true, reason: 'skipped_by_user' }); + continue; + } + if (waitOutcome === 'cancel' || controlState.cancelRequested) { + abortedByUser = true; + results.push({ stepType: 'wait', waitSeconds: seconds, success: false, aborted: true, reason: 'cancelled_by_user' }); + break; + } + results.push({ stepType: 'wait', waitSeconds: seconds, success: true }); + } else if (step.stepType === STEP_TYPE_SCRIPT) { + if (!step.scriptId) { + logger.warn('chain:step:script-missing', { chainId, stepId: step.id }); + results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' }); + continue; + } + + const scriptService = require('./scriptService'); + let script; + try { + script = await scriptService.getScriptById(step.scriptId); + } catch (error) { + logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); + results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' }); + continue; + } + + controlState.currentStepType = STEP_TYPE_SCRIPT; + runtimeActivityService.updateActivity(activityId, { + currentStepType: 'script', + currentStep: `Skript: ${script.name}`, + currentScriptName: script.name, + stepIndex, + stepTotal: totalSteps, + scriptId: script.id + }); + emitRuntimeStep({ + stepType: 'script', + stepIndex, + stepTotal: totalSteps, + scriptId: script.id, + scriptName: script.name, + currentScriptName: script.name, + currentStep: `Skript: ${script.name}` + }); + + if (typeof appendLog === 'function') { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`); + } + + const scriptActivityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: context?.source || 'chain', + scriptId: script.id, + chainId: chain.id, + jobId: context?.jobId || null, + cronJobId: context?.cronJobId || null, + parentActivityId: activityId, + currentStep: `Kette: ${chain.name}` + }); + + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { + ...context, + scriptId: script.id, + scriptName: script.name, + source: context?.source || 'chain' + }); + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + const processHandle = spawnTrackedProcess({ + cmd: prepared.cmd, + args: prepared.args, + context: { source: context?.source || 'chain', chainId: chain.id, scriptId: script.id }, + onStart: (child) => { + controlState.activeChild = child; + controlState.activeChildTermination = null; + }, + onStdoutLine: (line) => { + const next = appendTailText(stdout, line); + stdout = next.value; + stdoutTruncated = stdoutTruncated || next.truncated; + runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line }); + }, + onStderrLine: (line) => { + const next = appendTailText(stderr, line); + stderr = next.value; + stderrTruncated = stderrTruncated || next.truncated; + runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line }); + } + }); + let runError = null; + let exitCode = 0; + let signal = null; + try { + const result = await processHandle.promise; + exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0; + signal = result?.signal || null; + } catch (error) { + runError = error; + exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null; + signal = error?.signal || null; + } + const termination = controlState.activeChildTermination; + controlState.activeChild = null; + controlState.activeChildTermination = null; + if (runError && exitCode === null && !termination) { + throw runError; + } + const run = { + code: exitCode, + signal, + stdout, + stderr, + stdoutTruncated, + stderrTruncated, + termination + }; + controlState.currentStepType = null; + + if (run.termination === 'skip') { + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'success', + success: true, + outcome: 'skipped', + skipped: true, + currentStep: null, + message: 'Schritt übersprungen', + output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, + stdout: run.stdout || null, + stderr: run.stderr || null, + stdoutTruncated: Boolean(run.stdoutTruncated), + stderrTruncated: Boolean(run.stderrTruncated) + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" übersprungen.`); + } catch (_error) { + // ignore appendLog failures on skip path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: true, + skipped: true, + reason: 'skipped_by_user' + }); + continue; + } + + if (run.termination === 'cancel' || controlState.cancelRequested) { + abortedByUser = true; + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'cancelled', + cancelled: true, + currentStep: null, + message: controlState.cancelReason || 'Von Benutzer abgebrochen', + output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, + stdout: run.stdout || null, + stderr: run.stderr || null, + stdoutTruncated: Boolean(run.stdoutTruncated), + stderrTruncated: Boolean(run.stderrTruncated), + errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`); + } catch (_error) { + // ignore appendLog failures on cancel path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: false, + aborted: true, + reason: 'cancelled_by_user' + }); + break; + } + + const success = run.code === 0; + runtimeActivityService.completeActivity(scriptActivityId, { + status: success ? 'success' : 'error', + success, + outcome: success ? 'success' : 'error', + exitCode: run.code, + currentStep: null, + message: success ? null : `Fehler (Exit ${run.code})`, + output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, + stderr: success ? null : (run.stderr || null), + stdout: success ? null : (run.stdout || null), + stdoutTruncated: Boolean(run.stdoutTruncated), + stderrTruncated: Boolean(run.stderrTruncated), + errorMessage: success ? null : `Fehler (Exit ${run.code})` + }); + logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success }); + if (typeof appendLog === 'function') { + await appendLog( + success ? 'SYSTEM' : 'ERROR', + `Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}` + ); + } + results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' }); + + if (!success) { + logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code }); + break; + } + } catch (error) { + controlState.currentStepType = null; + if (controlState.cancelRequested) { + abortedByUser = true; + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'cancelled', + cancelled: true, + message: controlState.cancelReason || 'Von Benutzer abgebrochen', + errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`); + } catch (_error) { + // ignore appendLog failures on cancel path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: false, + aborted: true, + reason: 'cancelled_by_user' + }); + break; + } + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'error', + message: error?.message || 'unknown', + errorMessage: error?.message || 'unknown' + }); + logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); + if (typeof appendLog === 'function') { + await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`); + } + results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message }); + break; + } finally { + controlState.activeChild = null; + controlState.activeChildTermination = null; + if (prepared?.cleanup) { + await prepared.cleanup(); + } + } + } + } + + const succeeded = results.filter((r) => r.success).length; + const skipped = results.filter((r) => r.skipped).length; + const failed = results.filter((r) => !r.success && !r.skipped && !r.aborted).length; + logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed, skipped, abortedByUser }); + if (abortedByUser) { + completionPayload = { + status: 'error', + success: false, + outcome: 'cancelled', + cancelled: true, + currentStep: null, + currentScriptName: null, + message: controlState.cancelReason || 'Von Benutzer abgebrochen', + errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' + }; + emitRuntimeStep({ + finished: true, + success: false, + aborted: true, + failed, + succeeded + }); + return { + chainId, + chainName: chain.name, + steps: results.length, + succeeded, + failed, + skipped, + aborted: true, + abortedByUser: true, + results + }; + } + completionPayload = { + status: failed > 0 ? 'error' : 'success', + success: failed === 0, + outcome: failed > 0 ? 'error' : (skipped > 0 ? 'skipped' : 'success'), + skipped: skipped > 0, + currentStep: null, + currentScriptName: null, + message: failed > 0 + ? `${failed} Schritt(e) fehlgeschlagen` + : (skipped > 0 + ? `${succeeded} Schritt(e) erfolgreich, ${skipped} übersprungen` + : `${succeeded} Schritt(e) erfolgreich`) + }; + emitRuntimeStep({ + finished: true, + success: failed === 0, + failed, + succeeded + }); + + return { + chainId, + chainName: chain.name, + steps: results.length, + succeeded, + failed, + skipped, + aborted: failed > 0, + results + }; + } catch (error) { + completionPayload = { + status: 'error', + success: false, + outcome: 'error', + message: error?.message || 'unknown', + errorMessage: error?.message || 'unknown', + currentStep: null + }; + throw error; + } finally { + runtimeActivityService.completeActivity(activityId, completionPayload || { + status: 'error', + success: false, + outcome: 'error', + message: 'Kette unerwartet beendet', + errorMessage: 'Kette unerwartet beendet', + currentStep: null + }); + } + } +} + +module.exports = new ScriptChainService(); diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js new file mode 100644 index 0000000..2e28220 --- /dev/null +++ b/backend/src/services/scriptService.js @@ -0,0 +1,688 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const { getDb } = require('../db/database'); +const logger = require('./logger').child('SCRIPTS'); +const settingsService = require('./settingsService'); +const runtimeActivityService = require('./runtimeActivityService'); +const { streamLines } = require('./processRunner'); +const { errorToMeta } = require('../utils/errorMeta'); + +const SCRIPT_NAME_MAX_LENGTH = 120; +const SCRIPT_BODY_MAX_LENGTH = 200000; +const SCRIPT_TEST_TIMEOUT_SETTING_KEY = 'script_test_timeout_ms'; +const DEFAULT_SCRIPT_TEST_TIMEOUT_MS = 0; +const SCRIPT_TEST_TIMEOUT_MS = (() => { + const parsed = Number(process.env.RIPSTER_SCRIPT_TEST_TIMEOUT_MS); + if (Number.isFinite(parsed)) { + return Math.max(0, Math.trunc(parsed)); + } + return DEFAULT_SCRIPT_TEST_TIMEOUT_MS; +})(); +const SCRIPT_OUTPUT_MAX_CHARS = 150000; + +function normalizeScriptTestTimeoutMs(rawValue, fallbackMs = SCRIPT_TEST_TIMEOUT_MS) { + const parsed = Number(rawValue); + if (Number.isFinite(parsed)) { + return Math.max(0, Math.trunc(parsed)); + } + if (fallbackMs === null || fallbackMs === undefined) { + return null; + } + const parsedFallback = Number(fallbackMs); + if (Number.isFinite(parsedFallback)) { + return Math.max(0, Math.trunc(parsedFallback)); + } + return 0; +} + +function normalizeScriptId(rawValue) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); +} + +function normalizeScriptIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const normalized = normalizeScriptId(item); + if (!normalized) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function normalizeScriptName(rawValue) { + return String(rawValue || '').trim(); +} + +function normalizeScriptBody(rawValue) { + return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function createValidationError(message, details = null) { + const error = new Error(message); + error.statusCode = 400; + if (details) { + error.details = details; + } + return error; +} + +function validateScriptPayload(payload, { partial = false } = {}) { + const body = payload && typeof payload === 'object' ? payload : {}; + const hasName = Object.prototype.hasOwnProperty.call(body, 'name'); + const hasScriptBody = Object.prototype.hasOwnProperty.call(body, 'scriptBody'); + const normalized = {}; + const errors = []; + + if (!partial || hasName) { + const name = normalizeScriptName(body.name); + if (!name) { + errors.push({ field: 'name', message: 'Name darf nicht leer sein.' }); + } else if (name.length > SCRIPT_NAME_MAX_LENGTH) { + errors.push({ field: 'name', message: `Name darf maximal ${SCRIPT_NAME_MAX_LENGTH} Zeichen enthalten.` }); + } else { + normalized.name = name; + } + } + + if (!partial || hasScriptBody) { + const scriptBody = normalizeScriptBody(body.scriptBody); + if (!scriptBody.trim()) { + errors.push({ field: 'scriptBody', message: 'Skript darf nicht leer sein.' }); + } else if (scriptBody.length > SCRIPT_BODY_MAX_LENGTH) { + errors.push({ field: 'scriptBody', message: `Skript darf maximal ${SCRIPT_BODY_MAX_LENGTH} Zeichen enthalten.` }); + } else { + normalized.scriptBody = scriptBody; + } + } + + if (errors.length > 0) { + throw createValidationError('Skript ist ungültig.', errors); + } + + return normalized; +} + +function mapScriptRow(row) { + if (!row) { + return null; + } + return { + id: Number(row.id), + name: String(row.name || ''), + scriptBody: String(row.script_body || ''), + orderIndex: Number(row.order_index || 0), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function quoteForBashSingle(value) { + return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`; +} + +function buildScriptEnvironment(context = {}) { + const now = new Date().toISOString(); + const entries = { + RIPSTER_SCRIPT_RUN_AT: now, + RIPSTER_JOB_ID: context?.jobId ?? '', + RIPSTER_JOB_TITLE: context?.jobTitle ?? '', + RIPSTER_MODE: context?.mode ?? '', + RIPSTER_INPUT_PATH: context?.inputPath ?? '', + RIPSTER_OUTPUT_PATH: context?.outputPath ?? '', + RIPSTER_RAW_PATH: context?.rawPath ?? '', + RIPSTER_SCRIPT_ID: context?.scriptId ?? '', + RIPSTER_SCRIPT_NAME: context?.scriptName ?? '', + RIPSTER_SCRIPT_SOURCE: context?.source ?? '' + }; + + const output = {}; + for (const [key, value] of Object.entries(entries)) { + output[key] = String(value ?? ''); + } + return output; +} + +function buildScriptWrapper(scriptBody, context = {}) { + const envVars = buildScriptEnvironment(context); + const exportLines = Object.entries(envVars) + .map(([key, value]) => `export ${key}=${quoteForBashSingle(value)}`) + .join('\n'); + // Wait for potential background jobs started by the script before returning. + return `${exportLines}\n\n${String(scriptBody || '')}\n\nwait\n`; +} + +function appendWithCap(current, chunk, maxChars) { + const value = String(chunk || ''); + if (!value) { + return { value: current, truncated: false }; + } + const currentText = String(current || ''); + if (currentText.length >= maxChars) { + return { value: currentText, truncated: true }; + } + const available = maxChars - currentText.length; + if (value.length <= available) { + return { value: `${currentText}${value}`, truncated: false }; + } + return { + value: `${currentText}${value.slice(0, available)}`, + truncated: true + }; +} + +function killChildProcessTree(child, signal = 'SIGTERM') { + if (!child) { + return false; + } + const pid = Number(child.pid); + if (Number.isFinite(pid) && pid > 0) { + try { + // If spawned as detached=true this targets the full process group. + process.kill(-pid, signal); + return true; + } catch (_error) { + // Fallback below. + } + } + try { + child.kill(signal); + return true; + } catch (_error) { + return false; + } +} + +function runProcessCapture({ + cmd, + args, + timeoutMs = SCRIPT_TEST_TIMEOUT_MS, + cwd = process.cwd(), + onChild = null, + onStdoutLine = null, + onStderrLine = null +}) { + return new Promise((resolve, reject) => { + const effectiveTimeoutMs = normalizeScriptTestTimeoutMs(timeoutMs, SCRIPT_TEST_TIMEOUT_MS); + const startedAt = Date.now(); + let ended = false; + const child = spawn(cmd, args, { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + }); + if (typeof onChild === 'function') { + try { + onChild(child); + } catch (_error) { + // ignore observer errors + } + } + + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + let timedOut = false; + + let timeout = null; + if (effectiveTimeoutMs > 0) { + timeout = setTimeout(() => { + timedOut = true; + killChildProcessTree(child, 'SIGTERM'); + setTimeout(() => { + if (!ended) { + killChildProcessTree(child, 'SIGKILL'); + } + }, 2000); + }, effectiveTimeoutMs); + } + + const onData = (streamName, chunk) => { + if (streamName === 'stdout') { + const next = appendWithCap(stdout, chunk, SCRIPT_OUTPUT_MAX_CHARS); + stdout = next.value; + stdoutTruncated = stdoutTruncated || next.truncated; + } else { + const next = appendWithCap(stderr, chunk, SCRIPT_OUTPUT_MAX_CHARS); + stderr = next.value; + stderrTruncated = stderrTruncated || next.truncated; + } + }; + + child.stdout?.on('data', (chunk) => onData('stdout', chunk)); + child.stderr?.on('data', (chunk) => onData('stderr', chunk)); + + if (child.stdout && typeof onStdoutLine === 'function') { + streamLines(child.stdout, onStdoutLine); + } + if (child.stderr && typeof onStderrLine === 'function') { + streamLines(child.stderr, onStderrLine); + } + + child.on('error', (error) => { + ended = true; + if (timeout) { + clearTimeout(timeout); + } + reject(error); + }); + + child.on('close', (code, signal) => { + ended = true; + if (timeout) { + clearTimeout(timeout); + } + const endedAt = Date.now(); + resolve({ + code: Number.isFinite(Number(code)) ? Number(code) : null, + signal: signal || null, + durationMs: Math.max(0, endedAt - startedAt), + timedOut, + stdout, + stderr, + stdoutTruncated, + stderrTruncated + }); + }); + }); +} + +async function resolveScriptTestTimeoutMs(options = {}) { + const timeoutFromOptions = normalizeScriptTestTimeoutMs(options?.timeoutMs, null); + if (timeoutFromOptions !== null) { + return timeoutFromOptions; + } + try { + const settingsMap = await settingsService.getSettingsMap(); + return normalizeScriptTestTimeoutMs( + settingsMap?.[SCRIPT_TEST_TIMEOUT_SETTING_KEY], + SCRIPT_TEST_TIMEOUT_MS + ); + } catch (error) { + logger.warn('script:test-timeout:settings-read-failed', { error: errorToMeta(error) }); + return SCRIPT_TEST_TIMEOUT_MS; + } +} + +class ScriptService { + async listScripts() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT id, name, script_body, order_index, created_at, updated_at + FROM scripts + ORDER BY order_index ASC, id ASC + ` + ); + return rows.map(mapScriptRow); + } + + async getScriptById(scriptId) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const db = await getDb(); + const row = await db.get( + ` + SELECT id, name, script_body, order_index, created_at, updated_at + FROM scripts + WHERE id = ? + `, + [normalizedId] + ); + if (!row) { + const error = new Error(`Skript #${normalizedId} wurde nicht gefunden.`); + error.statusCode = 404; + throw error; + } + return mapScriptRow(row); + } + + async createScript(payload = {}) { + const normalized = validateScriptPayload(payload, { partial: false }); + const db = await getDb(); + try { + const nextOrderIndex = await this._getNextOrderIndex(db); + const result = await db.run( + ` + INSERT INTO scripts (name, script_body, order_index, created_at, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + [normalized.name, normalized.scriptBody, nextOrderIndex] + ); + return this.getScriptById(result.lastID); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE constraint failed')) { + throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [ + { field: 'name', message: 'Name muss eindeutig sein.' } + ]); + } + throw error; + } + } + + async updateScript(scriptId, payload = {}) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const normalized = validateScriptPayload(payload, { partial: false }); + const db = await getDb(); + await this.getScriptById(normalizedId); + try { + await db.run( + ` + UPDATE scripts + SET + name = ?, + script_body = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [normalized.name, normalized.scriptBody, normalizedId] + ); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE constraint failed')) { + throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [ + { field: 'name', message: 'Name muss eindeutig sein.' } + ]); + } + throw error; + } + return this.getScriptById(normalizedId); + } + + async deleteScript(scriptId) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const db = await getDb(); + const existing = await this.getScriptById(normalizedId); + await db.run('DELETE FROM scripts WHERE id = ?', [normalizedId]); + return existing; + } + + async getScriptsByIds(rawIds = []) { + const ids = normalizeScriptIdList(rawIds); + if (ids.length === 0) { + return []; + } + const db = await getDb(); + const placeholders = ids.map(() => '?').join(', '); + const rows = await db.all( + ` + SELECT id, name, script_body, order_index, created_at, updated_at + FROM scripts + WHERE id IN (${placeholders}) + `, + ids + ); + const byId = new Map(rows.map((row) => [Number(row.id), mapScriptRow(row)])); + return ids.map((id) => byId.get(id)).filter(Boolean); + } + + async resolveScriptsByIds(rawIds = [], options = {}) { + const ids = normalizeScriptIdList(rawIds); + if (ids.length === 0) { + return []; + } + const strict = options?.strict !== false; + const scripts = await this.getScriptsByIds(ids); + if (!strict) { + return scripts; + } + const foundIds = new Set(scripts.map((item) => Number(item.id))); + const missing = ids.filter((id) => !foundIds.has(Number(id))); + if (missing.length > 0) { + throw createValidationError(`Skript(e) nicht gefunden: ${missing.join(', ')}`, [ + { field: 'selectedPostEncodeScriptIds', message: `Nicht gefunden: ${missing.join(', ')}` } + ]); + } + return scripts; + } + + async reorderScripts(orderedIds = []) { + const db = await getDb(); + const providedIds = normalizeScriptIdList(orderedIds); + const rows = await db.all( + ` + SELECT id + FROM scripts + ORDER BY order_index ASC, id ASC + ` + ); + if (rows.length === 0) { + return []; + } + + const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0); + const existingSet = new Set(existingIds); + const used = new Set(); + const nextOrder = []; + + for (const id of providedIds) { + if (!existingSet.has(id) || used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + for (const id of existingIds) { + if (used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + await db.exec('BEGIN'); + try { + for (let i = 0; i < nextOrder.length; i += 1) { + await db.run( + ` + UPDATE scripts + SET order_index = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [i + 1, nextOrder[i]] + ); + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + return this.listScripts(); + } + + async _getNextOrderIndex(db) { + const row = await db.get( + ` + SELECT COALESCE(MAX(order_index), 0) AS max_order_index + FROM scripts + ` + ); + const maxOrder = Number(row?.max_order_index || 0); + if (!Number.isFinite(maxOrder) || maxOrder < 0) { + return 1; + } + return Math.trunc(maxOrder) + 1; + } + + async createExecutableScriptFile(script, context = {}) { + const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`; + const scriptBody = normalizeScriptBody(script?.scriptBody); + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-')); + const scriptPath = path.join(tempDir, 'script.sh'); + const wrapped = buildScriptWrapper(scriptBody, { + ...context, + scriptId: script?.id ?? context?.scriptId ?? '', + scriptName: name, + source: context?.source || 'post_encode' + }); + + await fs.promises.writeFile(scriptPath, wrapped, { + encoding: 'utf-8', + mode: 0o700 + }); + + const cleanup = async () => { + try { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + logger.warn('script:temp-cleanup-failed', { + scriptId: script?.id ?? null, + scriptName: name, + tempDir, + error: errorToMeta(error) + }); + } + }; + + return { + tempDir, + scriptPath, + cmd: '/usr/bin/env', + args: ['bash', scriptPath], + argsForLog: ['bash', ``], + 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..abc0413 --- /dev/null +++ b/backend/src/services/settingsService.js @@ -0,0 +1,1707 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); +const { getDb } = require('../db/database'); +const logger = require('./logger').child('SETTINGS'); +const { + parseJson, + normalizeValueByType, + serializeValueByType, + validateSetting +} = require('../utils/validators'); +const { splitArgs } = require('../utils/commandLine'); +const { setLogRootDir } = require('./logPathService'); + +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 +} = 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', + 'omdb_api_key', + 'tmdb_api_read_access_token', + 'pushover_token', + 'pushover_user' +]); +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 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: { + dvd: 'raw_dir_dvd_series' + }, + series_raw_dir_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: { + 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: { + 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' + }, + 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 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 isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key) + || SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key); + 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 (!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(); + return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC'); + } + + async getSettingsMap(options = {}) { + const snapshot = await this.getSettingsSnapshot(options); + return { ...(snapshot?.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, movies: bluray.movie_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.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) { + 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); + + 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] + ); + logger.info('setting:updated', { + key, + value: SENSITIVE_SETTING_KEYS.has(String(key || '').trim().toLowerCase()) ? '[redacted]' : result.normalized + }); + if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) { + applyRuntimeLogDirSetting(result.normalized); + } + 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) { + 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 = 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] + ); + } + 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); + } + + 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 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 = (!hasExplicitMinLength && minLengthSeconds > 0) + ? [`--minlength=${minLengthSeconds}`] + : []; + const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', this.resolveSourceArg(map, deviceInfo)]; + logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo }); + 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 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 = (!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, + 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 sourceArg = 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', + 'mkv', + sourceArg, + targetTitle, + rawJobDir + ]; + } else { + const minLengthArgs = (!disableMinLengthFilter && minLengthSeconds > 0) + ? [`--minlength=${minLengthSeconds}`] + : []; + baseArgs = [ + '-r', '--progress=-same', + ...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 buildMakeMKVRegisterConfig() { + const map = await this.getSettingsMap(); + const registrationKey = String(map.makemkv_registration_key || '').trim(); + if (!registrationKey) { + return null; + } + + const cmd = map.makemkv_command || 'makemkvcon'; + const args = ['reg', registrationKey]; + logger.debug('cli:makemkv:register', { cmd, args: ['reg', ''] }); + return { + cmd, + args, + argsForLog: ['reg', ''] + }; + } + + 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, '-t', '0']; + 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)}`; + const exportFile = path.join(os.tmpdir(), `${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/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..bfb9b90 --- /dev/null +++ b/backend/src/services/tmdbService.js @@ -0,0 +1,428 @@ +'use strict'; + +const settingsService = require('./settingsService'); +const logger = require('./logger').child('TMDB'); + +const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; +const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p'; +const TMDB_TIMEOUT_MS = 10000; + +class TmdbService { + normalizeNameList(values = [], options = {}) { + const maxItems = Math.max(1, Number(options.maxItems || 10)); + const source = Array.isArray(values) ? values : []; + const output = []; + const seen = new Set(); + for (const item of source) { + const name = String(item?.name || item || '').trim(); + if (!name) { + continue; + } + const key = name.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(name); + if (output.length >= maxItems) { + break; + } + } + return output; + } + + formatRuntimeLabel(value) { + if (Array.isArray(value)) { + const values = value + .map((entry) => Number(entry)) + .filter((entry) => Number.isFinite(entry) && entry > 0) + .map((entry) => Math.trunc(entry)); + if (values.length === 0) { + return null; + } + const min = Math.min(...values); + const max = Math.max(...values); + return min === max ? `${min} min` : `${min}-${max} min`; + } + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return `${Math.trunc(numeric)} min`; + } + const text = String(value || '').trim(); + return text || null; + } + + async getConfig() { + const settings = await settingsService.getSettingsMap(); + return { + readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null, + language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE' + }; + } + + async isConfigured() { + const config = await this.getConfig(); + return Boolean(config.readAccessToken); + } + + async resolveLanguage(explicitLanguage = null) { + const config = await this.getConfig(); + return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE'; + } + + async request(pathName, options = {}) { + const config = await this.getConfig(); + if (!config.readAccessToken) { + return null; + } + + const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const normalizedPath = String(pathName || '').replace(/^\/+/, ''); + const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`); + if (options.query && typeof options.query === 'object') { + for (const [key, value] of Object.entries(options.query)) { + if (value === undefined || value === null || value === '') { + continue; + } + url.searchParams.set(key, String(value)); + } + } + + const response = await fetch(url, { + method: options.method || 'GET', + headers: { + 'accept': 'application/json', + 'Authorization': `Bearer ${config.readAccessToken}`, + 'User-Agent': 'Ripster/1.0' + }, + signal: controller.signal + }); + + if (!response.ok) { + const error = new Error(`TMDb request failed (${response.status})`); + error.statusCode = response.status; + error.url = url.toString(); + throw error; + } + + return response.json(); + } finally { + clearTimeout(timer); + } + } + + buildImageUrl(imagePath, size = 'w342') { + const normalizedPath = String(imagePath || '').trim(); + if (!normalizedPath) { + return null; + } + return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`; + } + + async searchSeries(query, options = {}) { + const normalizedQuery = String(query || '').trim(); + if (!normalizedQuery || !(await this.isConfigured())) { + return []; + } + const language = await this.resolveLanguage(options.language); + + const data = await this.request('/search/tv', { + query: { + query: normalizedQuery, + first_air_date_year: options.year || undefined, + language, + page: options.page || 1, + include_adult: false + } + }).catch((error) => { + logger.warn('search:failed', { + query: normalizedQuery, + error: error?.message || String(error) + }); + return null; + }); + + const rows = Array.isArray(data?.results) ? data.results : []; + return rows + .map((row) => ({ + id: Number(row?.id || 0) || null, + title: String(row?.name || row?.original_name || '').trim() || null, + originalTitle: String(row?.original_name || '').trim() || null, + year: Number(String(row?.first_air_date || '').slice(0, 4)) || null, + overview: String(row?.overview || '').trim() || null, + posterPath: String(row?.poster_path || '').trim() || null, + poster: this.buildImageUrl(row?.poster_path, 'w342'), + backdropPath: String(row?.backdrop_path || '').trim() || null, + backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'), + originalLanguage: String(row?.original_language || '').trim() || null, + popularity: Number(row?.popularity || 0) || 0 + })) + .filter((row) => row.id && row.title); + } + + async getSeriesDetails(seriesId, options = {}) { + const id = Number(seriesId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + const appendToResponse = Array.isArray(options.appendToResponse) + ? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean) + : []; + return this.request(`/tv/${Math.trunc(id)}`, { + query: { + language, + append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined + } + }).catch((error) => { + logger.warn('series:details:failed', { + seriesId: Math.trunc(id), + error: error?.message || String(error) + }); + return null; + }); + } + + async getEpisodeGroups(seriesId) { + const id = Number(seriesId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return []; + } + const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => { + logger.warn('series:episode-groups:failed', { + seriesId: Math.trunc(id), + error: error?.message || String(error) + }); + return null; + }); + return Array.isArray(response?.results) ? response.results : []; + } + + async getEpisodeGroupDetails(groupId, options = {}) { + const normalizedId = String(groupId || '').trim(); + if (!normalizedId || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/tv/episode_group/${normalizedId}`, { + query: { + language + } + }).catch((error) => { + logger.warn('episode-group:details:failed', { + groupId: normalizedId, + error: error?.message || String(error) + }); + return null; + }); + } + + async getSeasonDetails(seriesId, seasonNumber, options = {}) { + const id = Number(seriesId); + const season = Number(seasonNumber); + if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, { + query: { + language + } + }).catch(() => null); + } + + async getSeasonCredits(seriesId, seasonNumber, options = {}) { + const id = Number(seriesId); + const season = Number(seasonNumber); + if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, { + query: { + language + } + }).catch((error) => { + logger.warn('season:credits:failed', { + seriesId: Math.trunc(id), + seasonNumber: Math.trunc(season), + error: error?.message || String(error) + }); + return null; + }); + } + + buildSeasonSummary(seasonDetails = null) { + const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {}; + const episodes = Array.isArray(details.episodes) ? details.episodes : []; + return { + seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null, + name: String(details.name || '').trim() || null, + overview: String(details.overview || '').trim() || null, + posterPath: String(details.poster_path || '').trim() || null, + poster: this.buildImageUrl(details.poster_path, 'w342'), + episodeCount: episodes.length, + episodes: episodes.map((episode) => ({ + id: Number(episode?.id || 0) || null, + number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null, + seasonNumber: Number(episode?.season_number || details.season_number || 0) || null, + name: String(episode?.name || '').trim() || null, + overview: String(episode?.overview || '').trim() || null, + runtime: Number(episode?.runtime || 0) || null, + airDate: String(episode?.air_date || '').trim() || null, + stillPath: String(episode?.still_path || '').trim() || null, + still: this.buildImageUrl(episode?.still_path, 'w300') + })).filter((episode) => episode.id && episode.number) + }; + } + + buildSeriesDetailsSummary(seriesDetails = null) { + const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {}; + const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : []; + const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : []; + const creators = Array.isArray(details?.created_by) ? details.created_by : []; + const genres = Array.isArray(details?.genres) ? details.genres : []; + const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time); + const directorNames = this.normalizeNameList( + crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'), + { maxItems: 5 } + ); + const creatorNames = this.normalizeNameList(creators, { maxItems: 5 }); + const actorNames = this.normalizeNameList(cast, { maxItems: 10 }); + const genreNames = this.normalizeNameList(genres, { maxItems: 10 }); + const voteAverageRaw = Number(details?.vote_average || 0); + const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0 + ? Number(voteAverageRaw.toFixed(1)) + : null; + const voteCount = Number(details?.vote_count || 0); + const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null; + + return { + director: directorNames.length > 0 + ? directorNames.join(', ') + : (creatorNames.length > 0 ? creatorNames.join(', ') : null), + actors: actorNames.length > 0 ? actorNames.join(', ') : null, + runtime: runtimeLabel, + runtimeLabel, + genre: genreNames.length > 0 ? genreNames.join(', ') : null, + imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null, + voteAverage, + voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null, + rottenTomatoes: null, + imdbId, + tmdbId: Number(details?.id || 0) || null, + firstAirDate: String(details?.first_air_date || '').trim() || null + }; + } + + buildSeriesMetadataCandidate(series = {}, options = {}) { + const season = series?.season && typeof series.season === 'object' + ? series.season + : null; + const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null; + const providerId = seasonNumber + ? `tmdb:${series.id}:season:${seasonNumber}` + : `tmdb:${series.id}`; + + return { + provider: 'tmdb', + providerId, + metadataKind: seasonNumber ? 'season' : 'series', + tmdbId: Number(series?.id || 0) || null, + title: String(series?.title || '').trim() || null, + originalTitle: String(series?.originalTitle || '').trim() || null, + year: Number(series?.year || 0) || null, + overview: String(series?.overview || '').trim() || null, + poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'), + backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'), + seasonNumber, + seasonName: String(season?.name || '').trim() || null, + seasonOverview: String(season?.overview || '').trim() || null, + seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'), + episodeCount: Number(season?.episodeCount || 0) || 0, + episodes: Array.isArray(season?.episodes) ? season.episodes : [] + }; + } + + buildSeasonSummariesFromSeriesDetails(seriesDetails = null) { + const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {}; + const seasons = Array.isArray(details.seasons) ? details.seasons : []; + return seasons + .map((season) => ({ + seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null, + name: String(season?.name || '').trim() || null, + overview: String(season?.overview || '').trim() || null, + posterPath: String(season?.poster_path || '').trim() || null, + poster: this.buildImageUrl(season?.poster_path, 'w342'), + episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0, + episodes: [] + })) + .filter((season) => season.seasonNumber !== null && season.episodeCount > 0); + } + + async searchSeriesWithSeasons(query, options = {}) { + const candidates = await this.searchSeries(query, options); + if (candidates.length === 0) { + return []; + } + + const limit = Math.max(1, Math.min(10, Number(options.limit || 5))); + const selectedCandidates = candidates.slice(0, limit); + + const expanded = await Promise.all(selectedCandidates.map(async (candidate) => { + const details = await this.getSeriesDetails(candidate.id, options); + const seasons = this.buildSeasonSummariesFromSeriesDetails(details); + if (seasons.length === 0) { + return [this.buildSeriesMetadataCandidate(candidate)]; + } + return seasons.map((season) => this.buildSeriesMetadataCandidate({ + ...candidate, + season + }, { + seasonNumber: season.seasonNumber + })); + })); + + return expanded.flat().filter((item) => item?.tmdbId && item?.title); + } + + async searchSeriesWithSeason(query, seasonNumber, options = {}) { + const normalizedSeason = Number(seasonNumber); + if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) { + return []; + } + + const candidates = await this.searchSeries(query, options); + if (candidates.length === 0) { + return []; + } + + const limit = Math.max(1, Math.min(10, Number(options.limit || 5))); + const selectedCandidates = candidates.slice(0, limit); + const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => { + const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options); + if (!seasonDetails) { + return { + ...candidate, + season: null + }; + } + return { + ...candidate, + season: this.buildSeasonSummary(seasonDetails) + }; + })); + + return withSeasons + .filter((candidate) => candidate.season && candidate.season.episodeCount > 0) + .map((candidate) => this.buildSeriesMetadataCandidate(candidate, { + seasonNumber: normalizedSeason + })); + } +} + +module.exports = new TmdbService(); 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..9ea8943 --- /dev/null +++ b/backend/src/utils/encodePlan.js @@ -0,0 +1,1530 @@ +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', + 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 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 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'); + + return { + preset: settings?.handbrake_preset || null, + extraArgs: settings?.handbrake_extra_args || '', + presetProfileSource: profile.source || 'fallback', + presetProfileMessage: profile.message || null, + audio: { + mode: baseAudioMode, + languages: audioLanguages.filter((item) => item !== 'none'), + explicitIds: [], + firstOnly: baseAudioMode === 'first', + selectionSource: profile.source === 'preset-export' ? 'preset' : 'default', + 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: baseSubtitleMode, + languages: subtitleLanguages.filter((item) => item !== 'none'), + explicitIds: [], + firstOnly: baseSubtitleMode === 'first', + selectionSource: profile.source === 'preset-export' ? 'preset' : 'default', + // 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 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 +}; 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..3210775 --- /dev/null +++ b/backend/src/utils/playlistAnalysis.js @@ -0,0 +1,742 @@ +const LARGE_JUMP_THRESHOLD = 20; +const DEFAULT_DURATION_SIMILARITY_SECONDS = 90; +const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2; +const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024; + +function parseDurationSeconds(raw) { + const text = String(raw || '').trim(); + if (!text) { + return 0; + } + + const hms = text.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/); + if (hms) { + const h = Number(hms[1]); + const m = Number(hms[2]); + const s = Number(hms[3]); + return (h * 3600) + (m * 60) + s; + } + + const hm = text.match(/^(\d{1,2}):(\d{2})(?:\.\d+)?$/); + if (hm) { + const m = Number(hm[1]); + const s = Number(hm[2]); + return (m * 60) + s; + } + + const asNumber = Number(text); + if (Number.isFinite(asNumber) && asNumber > 0) { + return Math.round(asNumber); + } + + return 0; +} + +function formatDuration(seconds) { + const total = Number(seconds || 0); + if (!Number.isFinite(total) || total <= 0) { + return '-'; + } + + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +function parseSizeBytes(raw) { + const text = String(raw || '').trim(); + if (!text) { + return 0; + } + + if (/^\d+$/.test(text)) { + const direct = Number(text); + return Number.isFinite(direct) ? Math.max(0, Math.round(direct)) : 0; + } + + const match = text.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i); + if (!match) { + return 0; + } + + const value = Number(match[1]); + if (!Number.isFinite(value)) { + return 0; + } + + const unit = String(match[2] || '').toUpperCase(); + const factorByUnit = { + B: 1, + KB: 1024, + MB: 1024 ** 2, + GB: 1024 ** 3, + TB: 1024 ** 4 + }; + const factor = factorByUnit[unit] || 1; + return Math.max(0, Math.round(value * factor)); +} + +function normalizePlaylistId(raw) { + const value = String(raw || '').trim().toLowerCase(); + if (!value) { + return null; + } + + const match = value.match(/(\d{1,5})(?:\.mpls)?$/i); + if (!match) { + return null; + } + + return String(match[1]).padStart(5, '0'); +} + +function toSegmentFile(segmentNumber) { + const value = Number(segmentNumber); + if (!Number.isFinite(value) || value < 0) { + return null; + } + return `${String(Math.trunc(value)).padStart(5, '0')}.m2ts`; +} + +function parseSegmentNumbers(raw) { + const text = String(raw || '').trim(); + if (!text) { + return []; + } + + const matches = text.match(/\d{1,6}/g) || []; + return matches + .map((item) => Number(item)) + .filter((value) => Number.isFinite(value) && value >= 0) + .map((value) => Math.trunc(value)); +} + +function extractPlaylistMapping(line) { + const raw = String(line || ''); + + // Robot message typically maps playlist to title id. + const msgMatch = raw.match(/MSG:3016.*,"(\d{5}\.mpls)","(\d+)"/i); + if (msgMatch) { + return { + playlistId: normalizePlaylistId(msgMatch[1]), + titleId: Number(msgMatch[2]) + }; + } + + const textMatch = raw.match(/(?:file|datei)\s+(\d{5}\.mpls).*?(?:title\s*#|titel\s*#?\s*)(\d+)/i); + if (textMatch) { + return { + playlistId: normalizePlaylistId(textMatch[1]), + titleId: Number(textMatch[2]) + }; + } + + return null; +} + +function parseAnalyzeTitles(lines) { + const titleMap = new Map(); + + const ensureTitle = (titleId) => { + if (!titleMap.has(titleId)) { + titleMap.set(titleId, { + titleId, + playlistId: null, + playlistIdFromMap: null, + playlistIdFromField16: null, + playlistFile: null, + durationSeconds: 0, + durationLabel: null, + sizeBytes: 0, + sizeLabel: null, + chapters: 0, + segmentNumbers: [], + segmentFiles: [], + streams: {}, + fields: {} + }); + } + return titleMap.get(titleId); + }; + + for (const line of lines || []) { + const mapping = extractPlaylistMapping(line); + if (mapping && Number.isFinite(mapping.titleId) && mapping.titleId >= 0) { + const title = ensureTitle(mapping.titleId); + title.playlistIdFromMap = normalizePlaylistId(mapping.playlistId); + } + + const sinfo = String(line || '').match(/^SINFO:(\d+),(\d+),(\d+),\d+,"([^"]*)"/i); + if (sinfo) { + const titleId = Number(sinfo[1]); + const streamIndex = Number(sinfo[2]); + const fieldId = Number(sinfo[3]); + const value = String(sinfo[4] || '').trim(); + if ( + Number.isFinite(titleId) && titleId >= 0 + && Number.isFinite(streamIndex) && streamIndex >= 0 + && Number.isFinite(fieldId) + ) { + const title = ensureTitle(titleId); + const streamKey = String(Math.trunc(streamIndex)); + if (!title.streams[streamKey]) { + title.streams[streamKey] = { + index: Math.trunc(streamIndex), + type: null, + language: null, + languageLabel: null, + format: null, + channels: null, + description: null + }; + } + const stream = title.streams[streamKey]; + if (fieldId === 1) { + const lowered = value.toLowerCase(); + if (lowered.includes('audio')) { + stream.type = 'audio'; + } else if (lowered.includes('subtitle') || lowered.includes('untertitel') || lowered.includes('text')) { + stream.type = 'subtitle'; + } + } else if (fieldId === 3) { + stream.language = value ? value.toLowerCase() : null; + } else if (fieldId === 4) { + stream.languageLabel = value || null; + } else if (fieldId === 6 || fieldId === 7) { + if (!stream.format || fieldId === 6) { + stream.format = value || null; + } + } else if (fieldId === 14 || fieldId === 40) { + if (!stream.channels || fieldId === 40) { + stream.channels = value || null; + } + } else if (fieldId === 30) { + stream.description = value || null; + } + } + continue; + } + + const tinfo = String(line || '').match(/^TINFO:(\d+),(\d+),\d+,"([^"]*)"/i); + if (!tinfo) { + continue; + } + + const titleId = Number(tinfo[1]); + const fieldId = Number(tinfo[2]); + const value = String(tinfo[3] || '').trim(); + if (!Number.isFinite(titleId) || titleId < 0) { + continue; + } + + const title = ensureTitle(titleId); + title.fields[fieldId] = value; + + if (fieldId === 16) { + const fromField = normalizePlaylistId(value); + if (fromField) { + title.playlistIdFromField16 = fromField; + } + continue; + } + + if (fieldId === 26) { + const segmentNumbers = parseSegmentNumbers(value); + if (segmentNumbers.length > 0) { + title.segmentNumbers = segmentNumbers; + } + continue; + } + + if (fieldId === 9) { + const seconds = parseDurationSeconds(value); + if (seconds > 0) { + title.durationSeconds = seconds; + title.durationLabel = formatDuration(seconds); + } + continue; + } + + if (fieldId === 10 || fieldId === 11) { + const bytes = parseSizeBytes(value); + if (bytes > 0) { + title.sizeBytes = bytes; + title.sizeLabel = value; + } + continue; + } + + if (fieldId === 8 || fieldId === 7) { + const chapters = Number(value); + if (Number.isFinite(chapters) && chapters >= 0) { + title.chapters = Math.trunc(chapters); + } + } + + if (!title.durationSeconds && /\d+:\d{2}:\d{2}/.test(value)) { + const seconds = parseDurationSeconds(value); + if (seconds > 0) { + title.durationSeconds = seconds; + title.durationLabel = formatDuration(seconds); + } + } + + if (!title.sizeBytes && /(kb|mb|gb|tb)\b/i.test(value)) { + const bytes = parseSizeBytes(value); + if (bytes > 0) { + title.sizeBytes = bytes; + title.sizeLabel = value; + } + } + } + + return Array.from(titleMap.values()) + .map((item) => { + const playlistId = normalizePlaylistId(item.playlistId); + const playlistIdFromMap = normalizePlaylistId(item.playlistIdFromMap); + const playlistIdFromField16 = normalizePlaylistId(item.playlistIdFromField16); + const field16Raw = String(item?.fields?.[16] || '').trim(); + const hasField16 = field16Raw.length > 0; + const field16LooksPlaylist = /\.mpls$/i.test(field16Raw) || /^\d{1,5}$/i.test(field16Raw); + const field16LooksClip = /\.(?:m2ts|m2t|mts)$/i.test(field16Raw); + let resolvedPlaylistId = null; + + // TINFO:16 is part of the final title block and is more reliable than MSG:3307 + // lines, which can include pre-dedup title ids. + if (field16LooksPlaylist && playlistIdFromField16) { + resolvedPlaylistId = playlistIdFromField16; + } else if (!hasField16) { + resolvedPlaylistId = playlistIdFromField16 || playlistIdFromMap || playlistId; + } else if (!field16LooksClip && playlistIdFromField16) { + resolvedPlaylistId = playlistIdFromField16; + } + const segmentNumbers = Array.isArray(item.segmentNumbers) ? item.segmentNumbers : []; + const segmentFiles = segmentNumbers + .map((number) => toSegmentFile(number)) + .filter(Boolean); + const streams = item?.streams && typeof item.streams === 'object' ? Object.values(item.streams) : []; + const sortedStreams = streams + .filter((stream) => Number.isFinite(Number(stream?.index))) + .sort((a, b) => Number(a.index) - Number(b.index)); + const audioTracks = sortedStreams + .filter((stream) => String(stream?.type || '').toLowerCase() === 'audio') + .map((stream) => ({ + id: Number(stream.index) + 1, + sourceTrackId: Number(stream.index) + 1, + language: stream.language || 'und', + languageLabel: stream.languageLabel || stream.language || 'und', + title: stream.description || null, + format: stream.format || null, + channels: stream.channels || null + })); + const subtitleTracks = sortedStreams + .filter((stream) => String(stream?.type || '').toLowerCase() === 'subtitle') + .map((stream) => ({ + id: Number(stream.index) + 1, + sourceTrackId: Number(stream.index) + 1, + language: stream.language || 'und', + languageLabel: stream.languageLabel || stream.language || 'und', + title: stream.description || null, + format: stream.format || null, + channels: null + })); + + const { streams: _omitStreams, ...restItem } = item; + return { + ...restItem, + playlistId: resolvedPlaylistId, + playlistIdFromMap, + playlistIdFromField16, + playlistFile: resolvedPlaylistId ? `${resolvedPlaylistId}.mpls` : null, + durationLabel: item.durationLabel || formatDuration(item.durationSeconds), + audioTracks, + subtitleTracks, + audioTrackCount: audioTracks.length, + subtitleTrackCount: subtitleTracks.length, + segmentNumbers, + segmentFiles + }; + }) + .sort((a, b) => a.titleId - b.titleId); +} + +function uniqueOrdered(values) { + const seen = new Set(); + const output = []; + for (const value of values || []) { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + output.push(String(value).trim()); + } + return output; +} + +function parseReportedTitleCount(lines) { + for (let index = (Array.isArray(lines) ? lines.length : 0) - 1; index >= 0; index -= 1) { + const line = String(lines[index] || '').trim(); + const match = line.match(/^TCOUNT:(\d+)/i); + if (!match) { + continue; + } + const value = Number(match[1]); + if (Number.isFinite(value) && value >= 0) { + return Math.trunc(value); + } + } + return null; +} + +function likelyRawMirrorOfPlaylist(rawTitle, playlistTitle) { + const rawDuration = Number(rawTitle?.durationSeconds || 0); + const playlistDuration = Number(playlistTitle?.durationSeconds || 0); + const rawSize = Number(rawTitle?.sizeBytes || 0); + const playlistSize = Number(playlistTitle?.sizeBytes || 0); + if (!Number.isFinite(rawDuration) || !Number.isFinite(playlistDuration) || rawDuration <= 0 || playlistDuration <= 0) { + return false; + } + if (Math.abs(rawDuration - playlistDuration) > RAW_MIRROR_DURATION_TOLERANCE_SECONDS) { + return false; + } + + if (rawSize > 0 && playlistSize > 0) { + return Math.abs(rawSize - playlistSize) <= RAW_MIRROR_SIZE_TOLERANCE_BYTES; + } + return true; +} + +function suppressRawMirrorCandidates(candidates) { + const rows = Array.isArray(candidates) ? candidates : []; + if (rows.length <= 1) { + return rows; + } + + const playlistRows = rows.filter((item) => normalizePlaylistId(item?.playlistId)); + if (playlistRows.length === 0) { + return rows; + } + + return rows.filter((item) => { + if (normalizePlaylistId(item?.playlistId)) { + return true; + } + return !playlistRows.some((playlistRow) => likelyRawMirrorOfPlaylist(item, playlistRow)); + }); +} + +function buildSimilarityGroups(candidates, durationSimilaritySeconds) { + const list = Array.isArray(candidates) ? [...candidates] : []; + const tolerance = Math.max(0, Math.round(Number(durationSimilaritySeconds || 0))); + const groups = []; + const used = new Set(); + + for (let i = 0; i < list.length; i += 1) { + if (used.has(i)) { + continue; + } + + const base = list[i]; + const currentGroup = [base]; + used.add(i); + + for (let j = i + 1; j < list.length; j += 1) { + if (used.has(j)) { + continue; + } + const candidate = list[j]; + if (Math.abs(Number(candidate.durationSeconds || 0) - Number(base.durationSeconds || 0)) <= tolerance) { + currentGroup.push(candidate); + used.add(j); + } + } + + if (currentGroup.length > 1) { + const sortedTitles = currentGroup + .slice() + .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId); + const referenceDuration = Number(sortedTitles[0]?.durationSeconds || 0); + groups.push({ + durationSeconds: referenceDuration, + durationLabel: formatDuration(referenceDuration), + titles: sortedTitles + }); + } + } + + return groups.sort((a, b) => + b.durationSeconds - a.durationSeconds || b.titles.length - a.titles.length + ); +} + +function computeSegmentMetrics(segmentNumbers) { + const numbers = Array.isArray(segmentNumbers) + ? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value)) + : []; + + if (numbers.length === 0) { + return { + segmentCount: 0, + segmentNumbers: [], + directSequenceSteps: 0, + backwardJumps: 0, + largeJumps: 0, + alternatingJumps: 0, + alternatingPairs: 0, + alternatingRatio: 0, + sequenceCoherence: 0, + monotonicRatio: 0, + score: 0 + }; + } + + let directSequenceSteps = 0; + let backwardJumps = 0; + let largeJumps = 0; + let alternatingJumps = 0; + let alternatingPairs = 0; + let prevDiff = null; + + for (let i = 1; i < numbers.length; i += 1) { + const current = numbers[i - 1]; + const next = numbers[i]; + const diff = next - current; + + if (next < current) { + backwardJumps += 1; + } + if (Math.abs(diff) > LARGE_JUMP_THRESHOLD) { + largeJumps += 1; + } + if (diff === 1) { + directSequenceSteps += 1; + } + + if (prevDiff !== null) { + const largePair = Math.abs(prevDiff) > LARGE_JUMP_THRESHOLD && Math.abs(diff) > LARGE_JUMP_THRESHOLD; + if (largePair) { + alternatingPairs += 1; + const signChanged = (prevDiff < 0 && diff > 0) || (prevDiff > 0 && diff < 0); + if (signChanged) { + alternatingJumps += 1; + } + } + } + prevDiff = diff; + } + + const transitions = Math.max(1, numbers.length - 1); + const sequenceCoherence = Number((directSequenceSteps / transitions).toFixed(4)); + const alternatingRatio = alternatingPairs > 0 + ? Number((alternatingJumps / alternatingPairs).toFixed(4)) + : 0; + + const score = (directSequenceSteps * 2) - (backwardJumps * 3) - (largeJumps * 2); + + return { + segmentCount: numbers.length, + segmentNumbers: numbers, + directSequenceSteps, + backwardJumps, + largeJumps, + alternatingJumps, + alternatingPairs, + alternatingRatio, + sequenceCoherence, + monotonicRatio: sequenceCoherence, + score + }; +} + +function buildEvaluationLabel(metrics) { + if (!metrics || metrics.segmentCount === 0) { + return 'Keine Segmentliste aus TINFO:26 verfügbar'; + } + if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) { + return 'Fake-Struktur (alternierendes Sprungmuster)'; + } + if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) { + return 'Auffällige Segmentreihenfolge'; + } + return 'wahrscheinlich korrekt (lineare Segmentfolge)'; +} + +function scoreCandidates(groupTitles) { + const titles = Array.isArray(groupTitles) ? groupTitles : []; + if (titles.length === 0) { + return []; + } + + return titles + .map((title) => { + const metrics = computeSegmentMetrics(title.segmentNumbers); + const reasons = [ + `sequence_steps=${metrics.directSequenceSteps}`, + `sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`, + `backward_jumps=${metrics.backwardJumps}`, + `large_jumps=${metrics.largeJumps}`, + `alternating_ratio=${metrics.alternatingRatio.toFixed(3)}` + ]; + + return { + ...title, + score: Number(metrics.score || 0), + reasons, + structuralMetrics: metrics, + evaluationLabel: buildEvaluationLabel(metrics) + }; + }) + .sort((a, b) => + b.score - a.score + || b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence + || b.durationSeconds - a.durationSeconds + || b.sizeBytes - a.sizeBytes + || a.titleId - b.titleId + ) + .map((item, index) => ({ + ...item, + recommended: index === 0 + })); +} + +function buildPlaylistSegmentMap(titles) { + const map = {}; + for (const title of titles || []) { + const playlistId = normalizePlaylistId(title?.playlistId); + if (!playlistId || map[playlistId]) { + continue; + } + + map[playlistId] = { + playlistId, + playlistFile: `${playlistId}.mpls`, + playlistPath: `BDMV/PLAYLIST/${playlistId}.mpls`, + segmentCommand: `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, + segmentFiles: Array.isArray(title?.segmentFiles) ? title.segmentFiles : [], + segmentNumbers: Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [], + fileExists: null, + source: 'makemkv_tinfo_26' + }; + } + return map; +} + +function buildPlaylistToTitleIdMap(titles) { + const map = {}; + for (const title of titles || []) { + const playlistId = normalizePlaylistId(title?.playlistId || title?.playlistFile || null); + const titleId = Number(title?.titleId); + if (!playlistId || !Number.isFinite(titleId) || titleId < 0) { + continue; + } + const normalizedTitleId = Math.trunc(titleId); + if (map[playlistId] === undefined) { + map[playlistId] = normalizedTitleId; + } + const playlistFile = `${playlistId}.mpls`; + if (map[playlistFile] === undefined) { + map[playlistFile] = normalizedTitleId; + } + } + return map; +} + +function extractWarningLines(lines) { + return (Array.isArray(lines) ? lines : []) + .filter((line) => /warn|warning|error|fehler|decode|decoder|timeout|corrupt/i.test(String(line || ''))) + .slice(0, 40) + .map((line) => String(line || '').slice(0, 260)); +} + +function extractPlaylistMismatchWarnings(titles) { + return (Array.isArray(titles) ? titles : []) + .filter((title) => title?.playlistIdFromMap && title?.playlistIdFromField16) + .filter((title) => String(title.playlistIdFromMap) !== String(title.playlistIdFromField16)) + .slice(0, 25) + .map((title) => + `Titel #${title.titleId}: MSG-Playlist=${title.playlistIdFromMap}.mpls, TINFO16=${title.playlistIdFromField16}.mpls (TINFO16 bevorzugt)` + ); +} + +function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) { + const parsedTitles = parseAnalyzeTitles(lines); + const reportedTitleCount = parseReportedTitleCount(lines); + const minSeconds = Math.max(0, Math.round(Number(minLengthMinutes || 0) * 60)); + const durationSimilaritySeconds = Math.max( + 0, + Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS)) + ); + + const candidatesRaw = parsedTitles + .filter((item) => Number(item.durationSeconds || 0) >= minSeconds) + .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId); + const candidates = suppressRawMirrorCandidates(candidatesRaw) + .slice() + .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId); + const playlistBackedCandidates = candidates + .filter((item) => normalizePlaylistId(item?.playlistId)); + const candidatePlaylistsAll = uniqueOrdered( + playlistBackedCandidates.map((item) => item.playlistId).filter(Boolean) + ); + + const similarityGroups = buildSimilarityGroups(playlistBackedCandidates, durationSimilaritySeconds); + const obfuscationDetected = similarityGroups.length > 0; + const multipleCandidatesDetected = candidatePlaylistsAll.length > 1; + const manualDecisionRequired = multipleCandidatesDetected; + const decisionPool = manualDecisionRequired ? playlistBackedCandidates : []; + const evaluatedCandidates = decisionPool.length > 0 ? scoreCandidates(decisionPool) : []; + const recommendation = evaluatedCandidates[0] || null; + const candidatePlaylists = manualDecisionRequired ? candidatePlaylistsAll : []; + const playlistSegments = buildPlaylistSegmentMap(decisionPool); + const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles); + + return { + generatedAt: new Date().toISOString(), + reportedTitleCount, + minLengthMinutes: Number(minLengthMinutes || 0), + minLengthSeconds: minSeconds, + durationSimilaritySeconds, + titles: parsedTitles, + candidates, + duplicateDurationGroups: similarityGroups, + obfuscationDetected, + manualDecisionRequired, + manualDecisionReason: manualDecisionRequired + ? (obfuscationDetected ? 'multiple_similar_candidates' : 'multiple_candidates_after_min_length') + : null, + candidatePlaylists, + candidatePlaylistFiles: candidatePlaylists.map((item) => `${item}.mpls`), + playlistToTitleId, + recommendation: recommendation + ? { + titleId: recommendation.titleId, + playlistId: recommendation.playlistId, + score: Number(recommendation.score || 0), + reason: Array.isArray(recommendation.reasons) && recommendation.reasons.length > 0 + ? recommendation.reasons.join('; ') + : 'höchster Struktur-Score' + } + : null, + evaluatedCandidates, + playlistSegments, + structuralAnalysis: { + method: 'makemkv_tinfo_26', + sourceCommand: 'makemkvcon -r info disc:0 --robot', + analyzedPlaylists: Object.keys(playlistSegments).length + }, + warningLines: [ + ...extractWarningLines(lines), + ...(reportedTitleCount !== null && reportedTitleCount !== parsedTitles.length + ? [`Titel-Anzahl abweichend: TCOUNT=${reportedTitleCount}, geparst=${parsedTitles.length}`] + : []), + ...extractPlaylistMismatchWarnings(parsedTitles) + ].slice(0, 60) + }; +} + +module.exports = { + normalizePlaylistId, + analyzePlaylistObfuscation +}; diff --git a/backend/src/utils/progressParsers.js b/backend/src/utils/progressParsers.js new file mode 100644 index 0000000..3766250 --- /dev/null +++ b/backend/src/utils/progressParsers.js @@ -0,0 +1,100 @@ +function clampPercent(value) { + if (Number.isNaN(value) || value === Infinity || value === -Infinity) { + return null; + } + + return Math.max(0, Math.min(100, Number(value.toFixed(2)))); +} + +function parseGenericPercent(line) { + const match = line.match(/(\d{1,3}(?:\.\d+)?)\s?%/); + if (!match) { + return null; + } + + return clampPercent(Number(match[1])); +} + +function parseEta(line) { + const etaMatch = line.match(/ETA\s+([0-9:.hms-]+)/i); + if (!etaMatch) { + return null; + } + + const value = etaMatch[1].trim(); + if (!value || value.includes('--')) { + return null; + } + + return value.replace(/[),.;]+$/, ''); +} + +function parseMakeMkvProgress(line) { + const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/); + if (prgv) { + // Format: PRGV:current,total,max (official makemkv docs) + // current = per-file progress, total = overall progress across all files + const total = Number(prgv[2]); + const max = Number(prgv[3]); + + if (max > 0) { + return { percent: clampPercent((total / max) * 100), eta: null }; + } + } + + const percent = parseGenericPercent(line); + if (percent !== null) { + return { percent, eta: null }; + } + + return null; +} + +function parseHandBrakeProgress(line) { + const normalized = String(line || '').replace(/\s+/g, ' ').trim(); + const match = normalized.match(/Encoding:\s*(?:task\s+\d+\s+of\s+\d+,\s*)?(\d+(?:\.\d+)?)\s?%/i); + if (match) { + return { + percent: clampPercent(Number(match[1])), + eta: parseEta(normalized) + }; + } + + return null; +} + +function parseCdParanoiaProgress(line) { + // cdparanoia writes progress to stderr with \r overwrites. + // Formats seen in the wild: + // "Ripping track 1 of 12 progress: ( 34.21%)" + // "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" + const normalized = String(line || '').replace(/\s+/g, ' ').trim(); + + const progressMatch = normalized.match(/progress:\s*\(\s*(\d+(?:\.\d+)?)\s*%\s*\)/i); + if (progressMatch) { + const trackMatch = normalized.match(/track\s+(\d+)\s+of\s+(\d+)/i); + const currentTrack = trackMatch ? Number(trackMatch[1]) : null; + const totalTracks = trackMatch ? Number(trackMatch[2]) : null; + return { + percent: clampPercent(Number(progressMatch[1])), + currentTrack, + totalTracks, + eta: null + }; + } + + // "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" style – no clear percent here + // Fall back to generic percent match + const percent = parseGenericPercent(normalized); + if (percent !== null) { + return { percent, currentTrack: null, totalTracks: null, eta: null }; + } + + return null; +} + +module.exports = { + parseMakeMkvProgress, + parseHandBrakeProgress, + parseCdParanoiaProgress +}; 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/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/schema.sql b/db/schema.sql new file mode 100644 index 0000000..eb96e44 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,712 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE settings_schema ( + key TEXT PRIMARY KEY, + category TEXT NOT NULL, + label TEXT NOT NULL, + type TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 0, + description TEXT, + default_value TEXT, + options_json TEXT, + validation_json TEXT, + order_index INTEGER NOT NULL DEFAULT 0, + depends_on TEXT DEFAULT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE settings_values ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (key) REFERENCES settings_schema(key) ON DELETE CASCADE +); + +CREATE TABLE jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_job_id INTEGER, + title TEXT, + year INTEGER, + imdb_id TEXT, + poster_url TEXT, + omdb_json TEXT, + selected_from_omdb INTEGER DEFAULT 0, + start_time TEXT, + end_time TEXT, + status TEXT NOT NULL, + output_path TEXT, + disc_device TEXT, + error_message TEXT, + detected_title TEXT, + last_state TEXT, + raw_path TEXT, + rip_successful INTEGER NOT NULL DEFAULT 0, + makemkv_info_json TEXT, + handbrake_info_json TEXT, + mediainfo_info_json TEXT, + encode_plan_json TEXT, + encode_input_path TEXT, + encode_review_confirmed INTEGER DEFAULT 0, + aax_checksum TEXT, + media_type TEXT, + job_kind TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL +); + +CREATE INDEX idx_jobs_status ON jobs(status); +CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC); +CREATE INDEX idx_jobs_parent_job_id ON jobs(parent_job_id); +CREATE INDEX idx_jobs_job_kind ON jobs(job_kind); + +CREATE TABLE job_lineage_artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + source_job_id INTEGER, + media_type TEXT, + raw_path TEXT, + output_path TEXT, + reason TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_job_lineage_artifacts_job_id ON job_lineage_artifacts(job_id); +CREATE INDEX idx_job_lineage_artifacts_source_job_id ON job_lineage_artifacts(source_job_id); + +CREATE TABLE job_output_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + output_path TEXT NOT NULL, + label TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_job_output_folders_job_id ON job_output_folders(job_id); +CREATE UNIQUE INDEX idx_job_output_folders_unique ON job_output_folders(job_id, output_path); + +CREATE TABLE scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + script_body TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_scripts_name ON scripts(name); +CREATE INDEX idx_scripts_order_index ON scripts(order_index, id); + +CREATE TABLE script_chains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + order_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_script_chains_name ON script_chains(name); +CREATE INDEX idx_script_chains_order_index ON script_chains(order_index, id); + +CREATE TABLE script_chain_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chain_id INTEGER NOT NULL, + position INTEGER NOT NULL, + step_type TEXT NOT NULL, + script_id INTEGER, + wait_seconds INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (chain_id) REFERENCES script_chains(id) ON DELETE CASCADE, + FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL +); + +CREATE INDEX idx_script_chain_steps_chain ON script_chain_steps(chain_id, position); + +CREATE TABLE pipeline_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + state TEXT NOT NULL, + active_job_id INTEGER, + progress REAL DEFAULT 0, + eta TEXT, + status_text TEXT, + context_json TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (active_job_id) REFERENCES jobs(id) +); + +CREATE TABLE cron_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + cron_expression TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + pushover_enabled INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT, + last_run_status TEXT, + next_run_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_cron_jobs_enabled ON cron_jobs(enabled); + +CREATE TABLE cron_run_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cron_job_id INTEGER NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + output TEXT, + error_message TEXT, + FOREIGN KEY (cron_job_id) REFERENCES cron_jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_cron_run_logs_job ON cron_run_logs(cron_job_id, id DESC); + +CREATE TABLE user_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + media_type TEXT NOT NULL DEFAULT 'all', + handbrake_preset TEXT, + extra_args TEXT, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_user_presets_media_type ON user_presets(media_type); + +CREATE TABLE IF NOT EXISTS aax_activation_bytes ( + checksum TEXT PRIMARY KEY, + activation_bytes TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_prefs ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE converter_scan_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rel_path TEXT NOT NULL UNIQUE, + entry_type TEXT NOT NULL, + file_size INTEGER, + detected_media_type TEXT, + detected_format TEXT, + job_id INTEGER, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE SET NULL +); + +CREATE INDEX idx_converter_scan_entries_rel_path ON converter_scan_entries(rel_path); +CREATE INDEX idx_converter_scan_entries_job_id ON converter_scan_entries(job_id); + +CREATE TABLE dvd_series_disc_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + provider_series_name TEXT, + season_number INTEGER, + season_type TEXT NOT NULL DEFAULT 'dvd', + disc_label TEXT, + disc_serial TEXT, + disc_number INTEGER, + language TEXT, + disc_signature TEXT NOT NULL, + fingerprint_json TEXT, + episode_map_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature); +CREATE INDEX idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number); + +CREATE TABLE dvd_series_title_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_job_id INTEGER NOT NULL, + disc_profile_id INTEGER, + title_index INTEGER NOT NULL, + title_kind TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 1, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + season_number INTEGER, + episode_start INTEGER, + episode_end INTEGER, + episode_ids_json TEXT, + mapping_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE, + FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL +); + +CREATE INDEX idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index); +CREATE INDEX idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number); + +-- ============================================================================= +-- Default Settings Seed +-- ============================================================================= + +-- Pfade – Eigentümer für alternative Verzeichnisse (inline in DynamicSettingsForm gerendert) +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL); + +-- Laufwerk +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_mode', 'auto'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('drive_device', 'Laufwerk', 'Device Pfad', 'path', 0, 'Nur für expliziten Modus, z.B. /dev/sr0.', '/dev/sr0', '[]', '{}', 20); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_device', '/dev/sr0'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_source_index', 'Laufwerk', 'MakeMKV Source Index', 'number', 1, 'Disc Index im Auto-Modus.', '0', '[]', '{"min":0,"max":20}', 30); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_source_index', '0'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('disc_auto_detection_enabled', 'Laufwerk', 'Automatische Disk-Erkennung', 'boolean', 1, 'Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt.', 'true', '[]', '{}', 35); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_auto_detection_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('disc_poll_interval_ms', 'Laufwerk', 'Polling Intervall (ms)', 'number', 1, 'Intervall für Disk-Erkennung.', '4000', '[]', '{"min":1000,"max":60000}', 40); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('disc_poll_interval_ms', '4000'); + +-- Pfade +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]'); + +-- Monitoring +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('hardware_monitoring_enabled', 'Monitoring', 'Hardware Monitoring aktiviert', 'boolean', 1, 'Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates).', 'true', '[]', '{}', 130); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('hardware_monitoring_interval_ms', 'Monitoring', 'Hardware Monitoring Intervall (ms)', 'number', 1, 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.', '5000', '[]', '{"min":1000,"max":60000}', 140); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_interval_ms', '5000'); + +-- Tools +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_command', 'makemkvcon'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_registration_key', 'Tools', 'MakeMKV Key', 'string', 0, 'Optionaler Registrierungsschlüssel. Wird vor Analyze/Rip automatisch per "makemkvcon reg" gesetzt.', NULL, '[]', '{}', 202); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_registration_key', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('mediainfo_command', 'Tools', 'Mediainfo Kommando', 'string', 1, 'Pfad oder Befehl für mediainfo.', 'mediainfo', '[]', '{"minLength":1}', 205); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_command', 'mediainfo'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_min_length_minutes', 'Tools', 'Minimale Titellänge (Minuten)', 'number', 1, 'Filtert kurze Titel beim Rip.', '60', '[]', '{"min":1,"max":1000}', 210); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_min_length_minutes', '60'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_command', 'Tools', 'HandBrake Kommando', 'string', 1, 'Pfad oder Befehl für HandBrakeCLI.', 'HandBrakeCLI', '[]', '{"minLength":1}', 215); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_command', 'HandBrakeCLI'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_max_parallel_cd_encodes', 'Tools', 'Max. parallele Audio CD Jobs', 'number', 1, 'Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit.', '2', '[]', '{"min":1,"max":12}', 226); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_cd_encodes', '2'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_max_total_encodes', 'Tools', 'Max. Encodes gesamt (medienunabhängig)', 'number', 1, 'Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits.', '3', '[]', '{"min":1,"max":24}', 227); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_total_encodes', '3'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pipeline_cd_bypasses_queue', 'Tools', 'Audio CDs: Queue-Reihenfolge überspringen', 'boolean', 1, 'Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin.', 'false', '[]', '{}', 228); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_cd_bypasses_queue', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('script_test_timeout_ms', 'Tools', 'Script-Test Timeout (ms)', 'number', 1, 'Timeout fuer Script-Tests in den Settings. 0 = kein Timeout.', '0', '[]', '{"min":0,"max":86400000}', 229); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('script_test_timeout_ms', '0'); + +-- Migration: Label für bestehende Installationen aktualisieren +UPDATE settings_schema SET label = 'Max. parallele Film/Video Encodes', description = 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.' WHERE key = 'pipeline_max_parallel_jobs' AND label = 'Parallele Jobs'; + +-- Tools – Blu-ray +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('mediainfo_extra_args_bluray', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (Blu-ray).', NULL, '[]', '{}', 300); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_rip_mode_bluray', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).', 'backup', '[{"label":"Backup","value":"backup"},{"label":"MKV","value":"mkv"}]', '{}', 305); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_bluray', 'backup'); +UPDATE settings_schema SET default_value = 'backup', description = 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).' WHERE key = 'makemkv_rip_mode_bluray'; +UPDATE settings_values SET value = 'backup' WHERE key = 'makemkv_rip_mode_bluray'; + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_analyze_extra_args_bluray', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (Blu-ray).', NULL, '[]', '{}', 310); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_analyze_extra_args_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_rip_extra_args_bluray', 'Tools', 'MakeMKV Rip Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Rip (Blu-ray).', NULL, '[]', '{}', 315); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_extra_args_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_preset_bluray', 'Tools', 'HandBrake Preset', 'string', 0, 'Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet.', 'H.264 MKV 1080p30', '[]', '{}', 320); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_preset_bluray', 'H.264 MKV 1080p30'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_extra_args_bluray', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (Blu-ray).', NULL, '[]', '{}', 325); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_bluray', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (Blu-ray).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 330); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})'); + +-- Tools – DVD +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('mediainfo_extra_args_dvd', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (DVD).', NULL, '[]', '{}', 500); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_rip_mode_dvd', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'backup: vollständige Disc-Struktur im RAW-Ordner (einzig gültige Option für DVDs).', 'backup', '[{"label":"Backup","value":"backup"}]', '{}', 505); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_dvd', 'backup'); +UPDATE settings_schema SET default_value = 'backup', description = 'backup: vollständige Disc-Struktur im RAW-Ordner (einzig gültige Option für DVDs).', options_json = '[{"label":"Backup","value":"backup"}]' WHERE key = 'makemkv_rip_mode_dvd'; +UPDATE settings_values SET value = 'backup' WHERE key = 'makemkv_rip_mode_dvd'; + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_analyze_extra_args_dvd', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (DVD).', NULL, '[]', '{}', 510); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_analyze_extra_args_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('makemkv_rip_extra_args_dvd', 'Tools', 'MakeMKV Rip Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Rip (DVD).', NULL, '[]', '{}', 515); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_extra_args_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_preset_dvd', 'Tools', 'HandBrake Preset', 'string', 0, 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.', 'H.264 MKV 480p30', '[]', '{}', 520); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_preset_dvd', 'H.264 MKV 480p30'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_extra_args_dvd', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (DVD).', NULL, '[]', '{}', 525); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_dvd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (DVD).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 530); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'); + +-- Tools – CD +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('cdparanoia_command', 'cdparanoia'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ( + 'cd_output_template', + 'Pfade', + 'CD Output Template', + 'string', + 1, + 'Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', + '{artist} - {album} ({year})/{trackNr} {artist} - {title}', + '[]', + '{"minLength":1}', + 235 +); +INSERT OR IGNORE INTO settings_values (key, value) +VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}'); + +-- Tools – Audiobook +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})'); + +-- Pfade – CD +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD RAW-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL); + +-- Pfade – Audiobook +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL); + +-- Metadaten +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_api_key', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6'); + +-- Benachrichtigungen +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_token', 'Benachrichtigungen', 'PushOver Token', 'string', 0, 'Application Token für PushOver.', NULL, '[]', '{}', 510); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_token', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_user', 'Benachrichtigungen', 'PushOver User', 'string', 0, 'User-Key für PushOver.', NULL, '[]', '{}', 520); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_user', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_device', 'Benachrichtigungen', 'PushOver Device (optional)', 'string', 0, 'Optionales Ziel-Device in PushOver.', NULL, '[]', '{}', 530); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_device', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_title_prefix', 'Benachrichtigungen', 'PushOver Titel-Präfix', 'string', 1, 'Prefix im PushOver Titel.', 'Ripster', '[]', '{"minLength":1}', 540); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_title_prefix', 'Ripster'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_priority', 'Benachrichtigungen', 'PushOver Priority', 'number', 1, 'Priorität -2 bis 2.', '0', '[]', '{"min":-2,"max":2}', 550); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_priority', '0'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_timeout_ms', 'Benachrichtigungen', 'PushOver Timeout (ms)', 'number', 1, 'HTTP Timeout für PushOver Requests.', '7000', '[]', '{"min":1000,"max":60000}', 560); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_timeout_ms', '7000'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei Metadaten-Auswahl senden', 'boolean', 1, 'Sendet wenn Metadaten zur Auswahl bereitstehen.', 'true', '[]', '{}', 570); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_metadata_ready', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_rip_started', 'Benachrichtigungen', 'Bei Rip-Start senden', 'boolean', 1, 'Sendet beim Start des MakeMKV-Rips.', 'true', '[]', '{}', 580); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_rip_started', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_encoding_started', 'Benachrichtigungen', 'Bei Encode-Start senden', 'boolean', 1, 'Sendet beim Start von HandBrake.', 'true', '[]', '{}', 590); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_encoding_started', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_job_finished', 'Benachrichtigungen', 'Bei Erfolg senden', 'boolean', 1, 'Sendet bei erfolgreich abgeschlossenem Job.', 'true', '[]', '{}', 600); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_finished', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_job_error', 'Benachrichtigungen', 'Bei Fehler senden', 'boolean', 1, 'Sendet bei Fehlern in der Pipeline.', 'true', '[]', '{}', 610); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_error', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_job_cancelled', 'Benachrichtigungen', 'Bei Abbruch senden', 'boolean', 1, 'Sendet wenn Job manuell abgebrochen wurde.', 'true', '[]', '{}', 620); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_job_cancelled', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_reencode_started', 'Benachrichtigungen', 'Bei Re-Encode Start senden', 'boolean', 1, 'Sendet beim Start von RAW Re-Encode.', 'true', '[]', '{}', 630); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reencode_started', 'true'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('pushover_notify_reencode_finished', 'Benachrichtigungen', 'Bei Re-Encode Erfolg senden', 'boolean', 1, 'Sendet bei erfolgreichem RAW Re-Encode.', 'true', '[]', '{}', 640); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reencode_finished', 'true'); + +-- ============================================================================= +-- Converter Settings +-- ============================================================================= + +-- Converter – Pfade und Templates (Kategorie: Pfade) +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_raw_dir', 'Pfade', 'Converter Raw-Ordner', 'path', 0, 'Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw).', NULL, '[]', '{}', 800); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_movie_dir', 'Pfade', 'Converter Ausgabe (Video)', 'path', 0, 'Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies).', NULL, '[]', '{}', 801); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_audio_dir', 'Pfade', 'Converter Ausgabe (Audio)', 'path', 0, 'Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio).', NULL, '[]', '{}', 802); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_output_template_video', 'Pfade', 'Output-Template (Video)', 'string', 1, 'Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}.', '{title}', '[]', '{"minLength":1}', 810); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', '{title}'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_output_template_audio', 'Pfade', 'Output-Template (Audio)', 'string', 1, 'Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}.', '{artist} - {title}', '[]', '{"minLength":1}', 811); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', '{artist} - {title}'); + +-- Migration: Converter-Pfad-Settings in Kategorie 'Pfade' verschieben (falls bereits in DB vorhanden) +UPDATE settings_schema SET category = 'Pfade' WHERE key IN ('converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio') AND category = 'Converter'; + +-- Converter – Scan +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_scan_extensions', 'Converter', 'Erlaubte Datei-Endungen*', 'string', 1, 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus', '[]', '{"minLength":1}', 820); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_scan_extensions', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus'); +UPDATE settings_schema +SET + label = 'Erlaubte Datei-Endungen*', + description = 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', + default_value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus' +WHERE key = 'converter_scan_extensions'; +UPDATE settings_values +SET value = TRIM( + REPLACE( + REPLACE( + REPLACE(',' || LOWER(COALESCE(value, '')) || ',', ',aac,', ','), + ',,', ',' + ), + ',,', ',' + ), + ',' +) +WHERE key = 'converter_scan_extensions'; +UPDATE settings_values +SET value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus' +WHERE key = 'converter_scan_extensions' AND (value IS NULL OR TRIM(value) = ''); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_polling_enabled', 'Converter', 'Auto-Scan (Polling)', 'boolean', 1, 'Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen.', 'false', '[]', '{}', 830); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_enabled', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('converter_polling_interval', 'Converter', 'Polling-Intervall (Sekunden)', 'number', 1, 'Intervall für automatischen Scan des Converter-Ordners.', '300', '[]', '{"min":30,"max":86400}', 840); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_interval', '300'); 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..b9f4f34 --- /dev/null +++ b/docs/api/history.md @@ -0,0 +1,221 @@ +# History API + +Endpunkte für Job-Historie, Orphan-Import und Löschoperationen. + +--- + +## GET /api/history + +Liefert Jobs (optionale Filter). + +**Query-Parameter:** + +| Parameter | Typ | Beschreibung | +|----------|-----|-------------| +| `status` | string | Filter nach Job-Status | +| `search` | string | Suche in Titel-Feldern | + +**Beispiel:** + +```text +GET /api/history?status=FINISHED&search=Inception +``` + +**Response:** + +```json +{ + "jobs": [ + { + "id": 42, + "status": "FINISHED", + "title": "Inception", + "raw_path": "/mnt/raw/Inception - RAW - job-42", + "output_path": "/mnt/movies/Inception (2010)/Inception (2010).mkv", + "mediaType": "bluray", + "ripSuccessful": true, + "encodeSuccess": true, + "created_at": "2026-03-10T08:00:00.000Z", + "updated_at": "2026-03-10T10:00:00.000Z" + } + ] +} +``` + +--- + +## GET /api/history/:id + +Liefert Job-Detail. + +**Query-Parameter:** + +| Parameter | Typ | Standard | Beschreibung | +|----------|-----|---------|-------------| +| `includeLogs` | bool | `false` | Prozesslog laden | +| `includeLiveLog` | bool | `false` | alias-artig ebenfalls Prozesslog laden | +| `includeAllLogs` | bool | `false` | vollständiges Log statt Tail | +| `logTailLines` | number | `800` | Tail-Länge falls nicht `includeAllLogs` | + +**Response:** + +```json +{ + "job": { + "id": 42, + "status": "FINISHED", + "makemkvInfo": {}, + "mediainfoInfo": {}, + "handbrakeInfo": {}, + "encodePlan": {}, + "log": "...", + "log_count": 1, + "logMeta": { + "loaded": true, + "total": 800, + "returned": 800, + "truncated": true + } + } +} +``` + +--- + +## GET /api/history/orphan-raw + +Sucht RAW-Ordner ohne zugehörigen Job. + +**Response:** + +```json +{ + "rawDir": "/mnt/raw", + "rawDirs": ["/mnt/raw", "/mnt/raw-bluray"], + "rows": [ + { + "rawPath": "/mnt/raw/Inception (2010) [tt1375666] - RAW - job-99", + "folderName": "Inception (2010) [tt1375666] - RAW - job-99", + "title": "Inception", + "year": 2010, + "imdbId": "tt1375666", + "folderJobId": 99, + "entryCount": 4, + "hasBlurayStructure": true, + "lastModifiedAt": "2026-03-10T09:00:00.000Z" + } + ] +} +``` + +--- + +## POST /api/history/orphan-raw/import + +Importiert RAW-Ordner als FINISHED-Job. + +**Request:** + +```json +{ "rawPath": "/mnt/raw/Inception (2010) [tt1375666] - RAW - job-99" } +``` + +**Response:** + +```json +{ + "job": { "id": 77, "status": "FINISHED" }, + "uiReset": { "reset": true, "state": "IDLE" } +} +``` + +--- + +## POST /api/history/:id/omdb/assign + +Weist OMDb-/Metadaten nachträglich zu. + +**Request:** + +```json +{ + "imdbId": "tt1375666", + "title": "Inception", + "year": 2010, + "poster": "https://...", + "fromOmdb": true +} +``` + +**Response:** + +```json +{ "job": { "id": 42, "imdb_id": "tt1375666" } } +``` + +--- + +## POST /api/history/:id/delete-files + +Löscht Dateien eines Jobs, behält DB-Eintrag. + +**Request:** + +```json +{ "target": "both" } +``` + +`target`: `raw` | `movie` | `both` + +**Response:** + +```json +{ + "summary": { + "target": "both", + "raw": { "attempted": true, "deleted": true, "filesDeleted": 12, "dirsRemoved": 3, "reason": null }, + "movie": { "attempted": true, "deleted": false, "filesDeleted": 0, "dirsRemoved": 0, "reason": "Movie-Datei/Pfad existiert nicht." } + }, + "job": { "id": 42 } +} +``` + +--- + +## POST /api/history/:id/delete + +Löscht Job aus DB; optional auch Dateien. + +**Request:** + +```json +{ "target": "none" } +``` + +`target`: `none` | `raw` | `movie` | `both` + +**Response:** + +```json +{ + "deleted": true, + "jobId": 42, + "fileTarget": "both", + "fileSummary": { + "target": "both", + "raw": { "filesDeleted": 10 }, + "movie": { "filesDeleted": 1 } + }, + "uiReset": { + "reset": true, + "state": "IDLE" + } +} +``` + +--- + +## Hinweise + +- Ein aktiver Pipeline-Job kann nicht gelöscht werden (`409`). +- Alle Löschoperationen sind irreversibel. 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..e6a26fd --- /dev/null +++ b/docs/api/pipeline.md @@ -0,0 +1,369 @@ +# Pipeline API + +Endpunkte zur Steuerung des Pipeline-Workflows. + +--- + +## GET /api/pipeline/state + +Liefert aktuellen Pipeline- und Hardware-Monitoring-Snapshot. + +**Response (Beispiel):** + +```json +{ + "pipeline": { + "state": "READY_TO_ENCODE", + "activeJobId": 42, + "progress": 0, + "eta": null, + "statusText": "Mediainfo bestätigt - Encode manuell starten", + "context": { + "jobId": 42 + }, + "jobProgress": { + "42": { + "state": "MEDIAINFO_CHECK", + "progress": 68.5, + "eta": null, + "statusText": "MEDIAINFO_CHECK 68.50%" + } + }, + "queue": { + "maxParallelJobs": 1, + "runningCount": 1, + "queuedCount": 2, + "runningJobs": [], + "queuedJobs": [] + } + }, + "hardwareMonitoring": { + "enabled": true, + "intervalMs": 5000, + "updatedAt": "2026-03-10T09:00:00.000Z", + "sample": { + "cpu": {}, + "memory": {}, + "gpu": {}, + "storage": {} + }, + "error": null + } +} +``` + +--- + +## POST /api/pipeline/analyze + +Startet Disc-Analyse und legt Job an. + +**Response:** + +```json +{ + "result": { + "jobId": 42, + "detectedTitle": "INCEPTION", + "omdbCandidates": [] + } +} +``` + +--- + +## POST /api/pipeline/rescan-disc + +Erzwingt erneute Laufwerksprüfung. + +**Response (Beispiel):** + +```json +{ + "result": { + "present": true, + "changed": true, + "emitted": "discInserted", + "device": { + "path": "/dev/sr0", + "discLabel": "INCEPTION", + "mediaProfile": "bluray" + } + } +} +``` + +--- + +## GET /api/pipeline/omdb/search?q= + +OMDb-Titelsuche. + +**Response:** + +```json +{ + "results": [ + { + "imdbId": "tt1375666", + "title": "Inception", + "year": "2010", + "type": "movie", + "poster": "https://..." + } + ] +} +``` + +--- + +## POST /api/pipeline/select-metadata + +Setzt Metadaten (und optional Playlist) für einen Job. + +**Request:** + +```json +{ + "jobId": 42, + "title": "Inception", + "year": 2010, + "imdbId": "tt1375666", + "poster": "https://...", + "fromOmdb": true, + "selectedPlaylist": "00800" +} +``` + +**Response:** + +```json +{ "job": { "id": 42, "status": "READY_TO_START" } } +``` + +--- + +## POST /api/pipeline/start/:jobId + +Startet vorbereiteten Job oder queued ihn (je nach Parallel-Limit). + +**Mögliche Responses:** + +```json +{ "result": { "started": true, "stage": "RIPPING" } } +``` + +```json +{ "result": { "queued": true, "started": false, "queuePosition": 2, "action": "START_PREPARED" } } +``` + +--- + +## POST /api/pipeline/confirm-encode/:jobId + +Bestätigt Review-Auswahl (Tracks, Pre/Post-Skripte/Ketten, User-Preset). + +**Request (typisch):** + +```json +{ + "selectedEncodeTitleId": 1, + "selectedTrackSelection": { + "1": { + "audioTrackIds": [1, 2], + "subtitleTrackIds": [3] + } + }, + "selectedPreEncodeScriptIds": [1], + "selectedPostEncodeScriptIds": [2, 7], + "selectedPreEncodeChainIds": [3], + "selectedPostEncodeChainIds": [4], + "selectedUserPresetId": 5, + "skipPipelineStateUpdate": false +} +``` + +**Response:** + +```json +{ "job": { "id": 42, "encode_review_confirmed": 1 } } +``` + +--- + +## POST /api/pipeline/cancel + +Bricht laufenden Job ab oder entfernt Queue-Eintrag. + +**Request (optional):** + +```json +{ "jobId": 42 } +``` + +**Mögliche Responses:** + +```json +{ "result": { "cancelled": true, "queuedOnly": true, "jobId": 42 } } +``` + +```json +{ "result": { "cancelled": true, "queuedOnly": false, "jobId": 42 } } +``` + +```json +{ "result": { "cancelled": true, "queuedOnly": false, "pending": true, "jobId": 42 } } +``` + +--- + +## POST /api/pipeline/retry/:jobId + +Retry für `ERROR`/`CANCELLED`-Jobs (oder Queue-Einreihung). + +## POST /api/pipeline/reencode/:jobId + +Startet Re-Encode aus bestehendem RAW. + +## POST /api/pipeline/restart-review/:jobId + +Berechnet Review aus RAW neu. + +## POST /api/pipeline/restart-encode/:jobId + +Startet Encoding mit letzter bestätigter Review neu. + +## POST /api/pipeline/resume-ready/:jobId + +Lädt `READY_TO_ENCODE`-Job nach Neustart wieder in aktive Session. + +Alle Endpunkte liefern `{ result: ... }` bzw. `{ job: ... }`. + +--- + +## Queue-Endpunkte + +### GET /api/pipeline/queue + +Liefert Queue-Snapshot. + +```json +{ + "queue": { + "maxParallelJobs": 1, + "runningCount": 1, + "queuedCount": 3, + "runningJobs": [ + { + "jobId": 41, + "title": "Inception", + "status": "ENCODING", + "lastState": "ENCODING" + } + ], + "queuedJobs": [ + { + "entryId": 11, + "position": 1, + "type": "job", + "jobId": 42, + "action": "START_PREPARED", + "actionLabel": "Start", + "title": "Matrix", + "status": "READY_TO_ENCODE", + "lastState": "READY_TO_ENCODE", + "hasScripts": true, + "hasChains": false, + "enqueuedAt": "2026-03-10T09:00:00.000Z" + }, + { + "entryId": 12, + "position": 2, + "type": "wait", + "waitSeconds": 30, + "title": "Warten 30s", + "status": "QUEUED", + "enqueuedAt": "2026-03-10T09:01:00.000Z" + } + ], + "updatedAt": "2026-03-10T09:01:02.000Z" + } +} +``` + +### POST /api/pipeline/queue/reorder + +Sortiert Queue-Einträge neu. + +**Request:** + +```json +{ + "orderedEntryIds": [12, 11] +} +``` + +Legacy fallback wird akzeptiert: + +```json +{ + "orderedJobIds": [42, 43] +} +``` + +### POST /api/pipeline/queue/entry + +Fügt Nicht-Job-Queue-Eintrag hinzu (`script`, `chain`, `wait`). + +**Request-Beispiele:** + +```json +{ "type": "script", "scriptId": 3 } +``` + +```json +{ "type": "chain", "chainId": 2, "insertAfterEntryId": 11 } +``` + +```json +{ "type": "wait", "waitSeconds": 45 } +``` + +**Response:** + +```json +{ + "result": { "entryId": 12, "type": "wait", "position": 2 }, + "queue": { "...": "..." } +} +``` + +### DELETE /api/pipeline/queue/entry/:entryId + +Entfernt Queue-Eintrag. + +**Response:** + +```json +{ "queue": { "...": "..." } } +``` + +--- + +## Pipeline-Zustände + +| State | Bedeutung | +|------|-----------| +| `IDLE` | Wartet auf Medium | +| `DISC_DETECTED` | Medium erkannt | +| `ANALYZING` | MakeMKV-Analyse läuft | +| `METADATA_SELECTION` | Metadaten-Auswahl | +| `WAITING_FOR_USER_DECISION` | Playlist-Entscheidung erforderlich | +| `READY_TO_START` | Übergang vor Start | +| `RIPPING` | MakeMKV-Rip läuft | +| `MEDIAINFO_CHECK` | Titel-/Track-Auswertung | +| `READY_TO_ENCODE` | Review bereit | +| `ENCODING` | HandBrake-Encoding läuft | +| `FINISHED` | Abgeschlossen | +| `CANCELLED` | Abgebrochen | +| `ERROR` | Fehler | diff --git a/docs/api/runtime-activities.md b/docs/api/runtime-activities.md new file mode 100644 index 0000000..ffec718 --- /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/activities` + +Aktuellen Aktivitäts-Snapshot abrufen. + +**Antwort:** + +```json +{ + "active": [], + "recent": [ + { + "id": 5, + "type": "script", + "name": "notify.sh", + "status": "success", + "outcome": "success", + "startedAt": "2026-03-10T09:58:00.000Z", + "finishedAt": "2026-03-10T09:58:02.000Z", + "durationMs": 2100, + "exitCode": 0, + "success": true, + "canCancel": false, + "canNextStep": false + } + ], + "updatedAt": "2026-03-10T10:05:00.000Z" +} +``` + +--- + +### POST `/api/activities/:id/cancel` + +Aktive Aktivität abbrechen (nur wenn `canCancel: true`). + +**Parameter:** + +| Name | In | Typ | Beschreibung | +|------|----|-----|--------------| +| `id` | path | `number` | Aktivitäts-ID | +| `reason` | body | `string` | Optionaler Abbruchgrund | + +**Request Body:** + +```json +{ "reason": "Manueller Abbruch durch Benutzer" } +``` + +**Antwort (Erfolg):** + +```json +{ + "ok": true, + "action": null, + "snapshot": { "active": [], "recent": [], "updatedAt": "..." } +} +``` + +**Fehlercodes:** + +| HTTP | Bedeutung | +|------|-----------| +| `404` | Aktivität nicht gefunden oder bereits abgeschlossen | +| `409` | Abbrechen wird von dieser Aktivität nicht unterstützt | + +--- + +### POST `/api/activities/:id/next-step` + +Nächsten Schritt einer Aktivität auslösen (nur wenn `canNextStep: true`). + +**Parameter:** + +| Name | In | Typ | Beschreibung | +|------|----|-----|--------------| +| `id` | path | `number` | Aktivitäts-ID | + +**Antwort (Erfolg):** + +```json +{ + "ok": true, + "action": null, + "snapshot": { "active": [], "recent": [], "updatedAt": "..." } +} +``` + +**Fehlercodes:** + +| HTTP | Bedeutung | +|------|-----------| +| `404` | Aktivität nicht gefunden | +| `409` | Nächster Schritt wird von dieser Aktivität nicht unterstützt | + +--- + +### POST `/api/activities/clear-recent` + +Alle abgeschlossenen Aktivitäten aus `recent` löschen. + +**Antwort:** + +```json +{ + "ok": true, + "removed": 14, + "snapshot": { "active": [], "recent": [], "updatedAt": "..." } +} +``` + +--- + +## Grenzwerte + +| Wert | Limit | +|------|-------| +| Maximale `recent`-Einträge | 120 | +| Maximale Länge `stdout` / `stderr` / `output` | 12.000 Zeichen | +| Maximale Länge `errorMessage` / `message` | 2.000 Zeichen | +| Maximale Länge `outcome` | 40 Zeichen | + +Gekürzte Ausgaben erhalten den Suffix ` ...[gekürzt]` (bei Inline-Text) bzw. `\n...[gekürzt]` (bei mehrzeiligem Output). + +--- + +## Echtzeit-Updates + +Änderungen werden automatisch als [`RUNTIME_ACTIVITY_CHANGED`](websocket.md#runtime_activity_changed) WebSocket-Event gesendet. Die Frontend-Komponente braucht `GET /api/activities` nur beim initialen Laden aufzurufen. diff --git a/docs/api/settings.md b/docs/api/settings.md new file mode 100644 index 0000000..a893b02 --- /dev/null +++ b/docs/api/settings.md @@ -0,0 +1,338 @@ +# Settings API + +Endpunkte für Einstellungen, Skripte, Skript-Ketten und User-Presets. + +--- + +## GET /api/settings + +Liefert alle Einstellungen kategorisiert. + +**Response (Struktur):** + +```json +{ + "categories": [ + { + "category": "Pfade", + "settings": [ + { + "key": "raw_dir", + "label": "Raw Ausgabeordner", + "type": "path", + "required": true, + "description": "...", + "defaultValue": "data/output/raw", + "options": [], + "validation": { "minLength": 1 }, + "value": "data/output/raw", + "orderIndex": 100 + } + ] + } + ] +} +``` + +--- + +## PUT /api/settings/:key + +Aktualisiert eine einzelne Einstellung. + +**Request:** + +```json +{ "value": "/mnt/storage/raw" } +``` + +**Response:** + +```json +{ + "setting": { + "key": "raw_dir", + "value": "/mnt/storage/raw" + }, + "reviewRefresh": { + "triggered": false, + "reason": "not_ready" + } +} +``` + +`reviewRefresh` ist `null` oder ein Objekt mit Status der optionalen Review-Neuberechnung. + +--- + +## PUT /api/settings + +Aktualisiert mehrere Einstellungen atomar. + +**Request:** + +```json +{ + "settings": { + "raw_dir": "/mnt/storage/raw", + "movie_dir": "/mnt/storage/movies", + "handbrake_preset_bluray": "H.264 MKV 1080p30" + } +} +``` + +**Response:** + +```json +{ + "changes": [ + { "key": "raw_dir", "value": "/mnt/storage/raw" }, + { "key": "movie_dir", "value": "/mnt/storage/movies" } + ], + "reviewRefresh": { + "triggered": true, + "jobId": 42, + "relevantKeys": ["handbrake_preset_bluray"] + } +} +``` + +Bei Validierungsfehlern kommt `400` mit `error.details[]`. + +--- + +## GET /api/settings/handbrake-presets + +Liest Preset-Liste via `HandBrakeCLI -z` (mit Fallback auf konfigurierte Presets). + +**Response (Beispiel):** + +```json +{ + "source": "handbrake-cli", + "message": null, + "options": [ + { "label": "General/", "value": "__group__general", "disabled": true, "category": "General" }, + { "label": " Fast 1080p30", "value": "Fast 1080p30", "category": "General" } + ] +} +``` + +--- + +## POST /api/settings/pushover/test + +Sendet Testnachricht über aktuelle PushOver-Settings. + +**Request (optional):** + +```json +{ + "title": "Test", + "message": "Ripster Test" +} +``` + +**Response:** + +```json +{ + "result": { + "sent": true, + "eventKey": "test", + "requestId": "..." + } +} +``` + +Wenn PushOver deaktiviert ist oder Credentials fehlen, kommt i. d. R. ebenfalls `200` mit `sent: false` + `reason`. + +--- + +## Skripte + +Basis: `/api/settings/scripts` + +### GET /api/settings/scripts + +```json +{ "scripts": [ { "id": 1, "name": "...", "scriptBody": "...", "orderIndex": 1, "createdAt": "...", "updatedAt": "..." } ] } +``` + +### POST /api/settings/scripts + +```json +{ "name": "Move", "scriptBody": "mv \"$RIPSTER_OUTPUT_PATH\" /mnt/movies/" } +``` + +Response: `201` mit `{ "script": { ... } }` + +### PUT /api/settings/scripts/:id + +Body wie `POST`, Response `{ "script": { ... } }`. + +### DELETE /api/settings/scripts/:id + +Response `{ "removed": { ... } }`. + +### POST /api/settings/scripts/reorder + +```json +{ "orderedScriptIds": [3, 1, 2] } +``` + +Response `{ "scripts": [ ... ] }`. + +### POST /api/settings/scripts/:id/test + +Führt Skript als Testlauf aus. + +```json +{ + "result": { + "scriptId": 1, + "scriptName": "Move", + "success": true, + "exitCode": 0, + "signal": null, + "timedOut": false, + "durationMs": 120, + "stdout": "...", + "stderr": "...", + "stdoutTruncated": false, + "stderrTruncated": false + } +} +``` + +### Umgebungsvariablen für Skripte + +Diese Variablen werden beim Ausführen gesetzt: + +- `RIPSTER_SCRIPT_RUN_AT` +- `RIPSTER_JOB_ID` +- `RIPSTER_JOB_TITLE` +- `RIPSTER_MODE` +- `RIPSTER_INPUT_PATH` +- `RIPSTER_OUTPUT_PATH` +- `RIPSTER_RAW_PATH` +- `RIPSTER_SCRIPT_ID` +- `RIPSTER_SCRIPT_NAME` +- `RIPSTER_SCRIPT_SOURCE` + +--- + +## Skript-Ketten + +Basis: `/api/settings/script-chains` + +Eine Kette hat Schritte vom Typ: + +- `script` (`scriptId` erforderlich) +- `wait` (`waitSeconds` 1..3600) + +### GET /api/settings/script-chains + +Response `{ "chains": [ ... ] }` (inkl. `steps[]`). + +### GET /api/settings/script-chains/:id + +Response `{ "chain": { ... } }`. + +### POST /api/settings/script-chains + +```json +{ + "name": "After Encode", + "steps": [ + { "stepType": "script", "scriptId": 1 }, + { "stepType": "wait", "waitSeconds": 15 }, + { "stepType": "script", "scriptId": 2 } + ] +} +``` + +Response: `201` mit `{ "chain": { ... } }` + +### PUT /api/settings/script-chains/:id + +Body wie `POST`, Response `{ "chain": { ... } }`. + +### DELETE /api/settings/script-chains/:id + +Response `{ "removed": { ... } }`. + +### POST /api/settings/script-chains/reorder + +```json +{ "orderedChainIds": [2, 1, 3] } +``` + +Response `{ "chains": [ ... ] }`. + +### POST /api/settings/script-chains/:id/test + +Response: + +```json +{ + "result": { + "chainId": 2, + "chainName": "After Encode", + "steps": 3, + "succeeded": 3, + "failed": 0, + "aborted": false, + "results": [] + } +} +``` + +--- + +## User-Presets + +Basis: `/api/settings/user-presets` + +### GET /api/settings/user-presets + +Optionaler Query-Parameter: `media_type=bluray|dvd|other|all` + +```json +{ + "presets": [ + { + "id": 1, + "name": "Blu-ray HQ", + "mediaType": "bluray", + "handbrakePreset": "H.264 MKV 1080p30", + "extraArgs": "--encoder-preset slow", + "description": "...", + "createdAt": "...", + "updatedAt": "..." + } + ] +} +``` + +### POST /api/settings/user-presets + +```json +{ + "name": "Blu-ray HQ", + "mediaType": "bluray", + "handbrakePreset": "H.264 MKV 1080p30", + "extraArgs": "--encoder-preset slow", + "description": "optional" +} +``` + +Response: `201` mit `{ "preset": { ... } }` + +### PUT /api/settings/user-presets/:id + +Body mit beliebigen Feldern aus `POST`, Response `{ "preset": { ... } }`. + +### DELETE /api/settings/user-presets/:id + +Response `{ "removed": { ... } }`. 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..67c5ec1 --- /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-Registration-Command aus `makemkv_registration_key` +- Pfadauflösung für alle Medienprofile inkl. Audiobook und Converter + +--- + +## `historyService.js` + +Historie + Dateioperationen. + +Features: + +- Job-Liste/Detail inkl. Log-Tail +- Orphan-RAW-Erkennung und Import +- OMDb-Nachzuweisung +- Dateilöschung (`raw|movie|both`) +- Job-Löschung (`none|raw|movie|both`) + +--- + +## `audiobookService.js` + +Audiobook-Verarbeitung (AAX/M4B). + +Features: + +- FFprobe-basierte Analyse (Kapitel, Metadaten) +- FFmpeg-basiertes Encoding mit Kapitel-Splitting +- Ausgabeformate: M4B, MP3, FLAC +- Template-basierte Pfade (`{author}`, `{title}`, `{year}`, `{narrator}`, `{series}`, `{part}`) + +--- + +## `activationBytesService.js` + +Verwaltung von Audible-Activation-Bytes für AAX-DRM-Handling. + +Features: + +- SHA-1-Prüfsumme der AAX-Datei berechnen +- Activation Bytes in `aax_activation_bytes`-Tabelle cachen +- Lookup via `audnexService` (wenn konfiguriert) + +--- + +## `converterScanService.js` + +Dateisystem-Überwachung des Converter-Eingangsordners. + +Features: + +- Vollständiger FS-Baum (`getTree()`) +- DB-basierter Datei-Explorer (`getEntries()`) +- Manueller und automatischer Scan (Polling) +- Pfad-Normalisierung mit Traversal-Schutz +- WebSocket-Broadcast: `CONVERTER_SCAN_UPDATE` + +--- + +## `downloadService.js` + +Download-Queue für Ausgabedateien aus der Historie. + +Features: + +- Job in Download-Queue einreihen (`enqueueHistoryJob`) +- ZIP-Archiv aus Ausgabedateien erstellen (`archiver`) +- Datei-Streaming via `res.download()` +- WebSocket-Broadcast: `DOWNLOADS_UPDATED` bei Status-Änderungen +- Download-Item löschen + +--- + +## `cronService.js` + +Integriertes Cron-System ohne externe Parser-Library. + +Features: + +- 5-Feld-Cron-Parser + `nextRun`-Berechnung +- Quellen: `script` oder `chain` +- Laufzeitlogs (`cron_run_logs`) +- manuelles Triggern +- WebSocket-Events: `CRON_JOBS_UPDATED`, `CRON_JOB_UPDATED` + +--- + +## `runtimeActivityService.js` + +In-Memory-Tracking aller laufenden und kürzlich abgeschlossenen Aktivitäten (Skripte, Ketten, Cron-Jobs, Tasks). + +Features: + +- `startActivity(type, payload)` → Aktivität registrieren, ID zurückgeben +- `updateActivity(id, patch)` → Laufende Aktivität aktualisieren +- `completeActivity(id, payload)` → Aktivität abschließen und in `recent` verschieben +- `setControls(id, { cancel, nextStep })` → Steuer-Handler registrieren (für `canCancel`/`canNextStep`) +- `requestCancel(id)` / `requestNextStep(id)` → Steuer-Handler aufrufen +- `clearRecent()` → Abgeschlossene Aktivitäten löschen +- `getSnapshot()` → Snapshot mit `active` + `recent` + `updatedAt` +- Broadcasts `RUNTIME_ACTIVITY_CHANGED` über WebSocket bei jeder Änderung + +Limits: + +- `recent` max. 120 Einträge +- `stdout`/`stderr`/`output` max. 12.000 Zeichen +- `message`/`errorMessage` max. 2.000 Zeichen + +Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activities.md) + +--- + +## Weitere Services + +- `scriptService.js` (CRUD + Test + Wrapper-Ausführung) +- `scriptChainService.js` (CRUD + Step-Execution) +- `userPresetService.js` (HandBrake User-Presets) +- `hardwareMonitorService.js` (CPU/RAM/GPU/Storage) +- `websocketService.js` (Client-Registry + Broadcast) +- `notificationService.js` (PushOver) +- `cdRipService.js` (CD-Ripping mit cdparanoia) +- `musicBrainzService.js` (MusicBrainz-Metadaten-Lookup) +- `audnexService.js` (Audnex-API für Audiobook-Metadaten) +- `coverArtRecoveryService.js` (Cover-Art-Wiederherstellung) +- `thumbnailService.js` (Vorschaubild-Generierung) +- `omdbService.js` (OMDb-Metadaten-Suche) +- `logger.js` (rotierende Datei-Logs) + +--- + +## Bootstrapping (`src/index.js`) + +Beim Start: + +1. DB init/migrate +2. Pipeline-Init (inkl. Plugin-Registry) +3. Cron-Init +4. Express-Routes + Error-Handler +5. WebSocket-Server auf `/ws` +6. Hardware-Monitoring-Init +7. Disk-Detection-Start +8. Converter-Scan-Service-Init (Polling falls aktiviert) diff --git a/docs/architecture/database.md b/docs/architecture/database.md new file mode 100644 index 0000000..15f1577 --- /dev/null +++ b/docs/architecture/database.md @@ -0,0 +1,186 @@ +# Datenbank + +Ripster verwendet SQLite (`backend/data/ripster.db`). + +--- + +## Tabellen + +```text +settings_schema +settings_values +jobs +job_lineage_artifacts +job_output_folders +pipeline_state +scripts +script_chains +script_chain_steps +user_presets +cron_jobs +cron_run_logs +aax_activation_bytes +user_prefs +converter_scan_entries +``` + +--- + +## `jobs` + +Speichert Pipeline-Lifecycle und Artefakte pro Job. + +Zentrale Felder: + +- Metadaten: `title`, `year`, `imdb_id`, `poster_url`, `omdb_json`, `selected_from_omdb` +- Laufzeit: `start_time`, `end_time`, `status`, `last_state` +- Pfade: `raw_path`, `output_path`, `encode_input_path` +- Tool-Ausgaben: `makemkv_info_json`, `handbrake_info_json`, `mediainfo_info_json`, `encode_plan_json` +- Kontrolle: `encode_review_confirmed`, `rip_successful`, `error_message` +- Medientyp: `media_type` (`bluray|dvd|cd|audiobook|converter`) +- Prüfsumme: `aax_checksum` (für AAX-Dateien) +- Audit: `created_at`, `updated_at` + +--- + +## `job_lineage_artifacts` + +Verknüpft Jobs mit ihren Quell-Jobs (z. B. Re-Encode aus einem vorherigen Rip). + +Felder: + +- `job_id` — der abgeleitete Job +- `source_job_id` — der Quell-Job +- `artifact_type` — Art der Verknüpfung (z. B. `reencode`, `restart_review`) +- `created_at` + +--- + +## `job_output_folders` + +Verfolgt Ausgabepfade pro Job (mehrere Ausgaben möglich, z. B. bei Kapitel-Splitting). + +Felder: + +- `job_id` +- `folder_path` +- `label` — optionaler Bezeichner +- `created_at` + +--- + +## `pipeline_state` + +Singleton-Tabelle (`id = 1`) für aktiven Snapshot: + +- `state` +- `active_job_id` +- `progress` +- `eta` +- `status_text` +- `context_json` +- `updated_at` + +--- + +## `settings_schema` + `settings_values` + +- `settings_schema`: Definition (Typ, Default, Validation, Reihenfolge) +- `settings_values`: aktueller Wert pro Key + +--- + +## `scripts`, `script_chains`, `script_chain_steps` + +- `scripts`: Shell-Skripte (`name`, `script_body`, `order_index`) +- `script_chains`: Ketten (`name`, `order_index`) +- `script_chain_steps`: Schritte je Kette + - `step_type`: `script` oder `wait` + - `script_id` oder `wait_seconds` + +--- + +## `user_presets` + +Benannte HandBrake-Preset-Sets: + +- `name` +- `media_type` (`bluray|dvd|other|all`) +- `handbrake_preset` +- `extra_args` +- `description` + +--- + +## `cron_jobs` + `cron_run_logs` + +- `cron_jobs`: Zeitplan + Status +- `cron_run_logs`: einzelne Läufe + - `status`: `running|success|error` + - `output` + - `error_message` + +--- + +## `aax_activation_bytes` + +Cache für Audible-Activation-Bytes (AAX-DRM). + +Felder: + +- `checksum` — SHA-1-Prüfsumme der AAX-Datei (Primärschlüssel) +- `activation_bytes` — Hex-String der Activation Bytes +- `created_at` + +--- + +## `user_prefs` + +Benutzer-spezifische Einstellungen (Key-Value). + +Felder: + +- `key` +- `value` +- `updated_at` + +--- + +## `converter_scan_entries` + +DB-seitige Erfassung gescannter Dateien im Converter-Eingangsordner. + +Felder: + +- `rel_path` — relativer Pfad zum `converter_raw_dir` +- `is_dir` — Verzeichnis-Flag +- `size` — Dateigröße in Bytes +- `modified_at` — Datei-Änderungsdatum +- `job_id` — zugewiesener Converter-Job (falls vorhanden) +- `scanned_at` + +--- + +## Migration/Recovery + +Beim Start werden Schema und Settings-Metadaten automatisch abgeglichen. + +Bei korruptem SQLite-File: + +1. Datei wird nach `backend/data/corrupt-backups/` verschoben +2. neue DB wird initialisiert +3. Schema wird neu aufgebaut + +--- + +## Direkte Inspektion + +```bash +sqlite3 backend/data/ripster.db + +.mode table +SELECT id, status, title, media_type, created_at FROM jobs ORDER BY created_at DESC; +SELECT key, value FROM settings_values ORDER BY key; +SELECT checksum, activation_bytes FROM aax_activation_bytes; +SELECT rel_path, job_id FROM converter_scan_entries; +``` diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md new file mode 100644 index 0000000..5820558 --- /dev/null +++ b/docs/architecture/frontend.md @@ -0,0 +1,117 @@ +# Frontend-Komponenten + +Frontend: React + PrimeReact + Vite. + +--- + +## Hauptseiten + +### `RipperPage.jsx` + +Pipeline-Steuerung: + +- Status/Progress/ETA +- Metadaten-Dialog +- Playlist-Entscheidung +- Review-Panel (Track-Auswahl) +- Queue-Interaktion (reorder/add/remove) +- Job-Aktionen (Start/Cancel/Retry/Re-Encode) +- Hardware-Monitoring-Anzeige +- Aktivitäts-Tracking (Skripte, Ketten, Cron) + +### `SettingsPage.jsx` + +Konfiguration: + +- dynamisches Settings-Formular (`DynamicSettingsForm`) +- Skripte/Ketten inkl. Reorder/Test +- User-Presets +- Cron-Jobs (`CronJobsTab`) + +### `HistoryPage.jsx` + +Historie: + +- Job-Liste/Filter +- Job-Details + Logs +- OMDb-Nachzuweisung +- Re-Encode/Restart-Workflows + +### `ConverterPage.jsx` + +Datei-Converter: + +- Datei-Explorer des Converter-Eingangsordners (Baum-Ansicht) +- Upload von Audio/Video-Dateien (bis zu 50 Dateien gleichzeitig) +- Dateiverwaltung: Umbenennen, Verschieben, Löschen, Ordner erstellen +- Jobs aus Dateiauswahl erstellen +- Converter-Job-Konfiguration (Ausgabeformat, Preset, Tracks) +- Job-Start, Abbruch, Löschung +- Automatischer Scan-Status + +### `DownloadsPage.jsx` + +Download-Queue: + +- Ausgabedateien aus der Job-Historie in die Queue einreihen +- Download-Status verfolgen (ausstehend, wird verarbeitet, bereit, fehlgeschlagen) +- Dateien als ZIP herunterladen +- Download-Einträge löschen + +### `DatabasePage.jsx` + +Expert-Modus: + +- Tabellarische Rohsicht der Job-Datenbank +- Orphan-RAW-Import + +--- + +## Wichtige Komponenten + +- `PipelineStatusCard.jsx` — Pipeline-Status-Anzeige mit Progress +- `MetadataSelectionDialog.jsx` — OMDb-Metadaten-Auswahl +- `MediaInfoReviewPanel.jsx` — Track-Auswahl-Interface (Video/Audio/Untertitel) +- `JobDetailDialog.jsx` — Job-Detailansicht mit Logs +- `CronJobsTab.jsx` — Cron-Job-Verwaltung +- `DynamicSettingsForm.jsx` — Schema-gesteuertes Einstellungsformular +- `ConverterJobCard.jsx` — Converter-Job-Darstellung +- `CdRipConfigPanel.jsx` — Konfiguration für Audio-CD-Ripping + +--- + +## API-Client (`api/client.js`) + +- zentraler `request()` mit JSON-Handling +- Fehlerobjekt aus API wird auf `Error(message)` gemappt +- `VITE_API_BASE` default `/api` + +--- + +## WebSocket (`hooks/useWebSocket.js`) + +- URL: `VITE_WS_URL` oder automatisch `ws(s):///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..d92784b --- /dev/null +++ b/docs/configuration/settings-reference.md @@ -0,0 +1,181 @@ +# Einstellungsreferenz + +Diese Seite listet die Felder so, wie sie in der GUI unter `Settings` angezeigt werden. + +Hinweis: Interne Schlüsselnamen werden hier bewusst nicht verwendet. Falls du sie für Integrationen brauchst, nutze die API-Dokumentation. + +--- + +## Profil-System + +Viele Felder sind pro Medientyp getrennt vorhanden: + +- Blu-ray +- DVD +- Sonstiges +- Audiobook (neu ab v0.12.0) + +--- + +## Template-Platzhalter + +### Video-Templates +- `${title}` — Titel +- `${year}` — Erscheinungsjahr +- `${imdbId}` — IMDb-ID + +### Audiobook-Templates +- `{author}` — Autor +- `{title}` — Titel +- `{year}` — Erscheinungsjahr +- `{narrator}` — Sprecher +- `{series}` — Buchreihe +- `{part}` — Teil-Nummer +- `{format}` — Ausgabeformat + +### Converter-Templates (Video) +- `{title}`, `{year}` + +### Converter-Templates (Audio) +- `{artist}`, `{title}`, `{album}`, `{year}`, `{trackNr}` + +Nicht gesetzte Werte werden zu `unknown`. + +--- + +## Kategorie: Pfade + +| Feldname in der GUI | Typ | Default | +|---|---|---| +| `Raw Ausgabeordner` | path | `data/output/raw` | +| `Raw Ausgabeordner (Blu-ray)` | path | `null` | +| `Raw Ausgabeordner (DVD)` | path | `null` | +| `Raw Ausgabeordner (Sonstiges)` | path | `null` | +| `Eigentümer Raw-Ordner (Blu-ray)` | string | `null` | +| `Eigentümer Raw-Ordner (DVD)` | string | `null` | +| `Eigentümer Raw-Ordner (Sonstiges)` | string | `null` | +| `Film Ausgabeordner` | path | `data/output/movies` | +| `Film Ausgabeordner (Blu-ray)` | path | `null` | +| `Film Ausgabeordner (DVD)` | path | `null` | +| `Film Ausgabeordner (Sonstiges)` | path | `null` | +| `Eigentümer Film-Ordner (Blu-ray)` | string | `null` | +| `Eigentümer Film-Ordner (DVD)` | string | `null` | +| `Eigentümer Film-Ordner (Sonstiges)` | string | `null` | +| `Log Ordner` | path | `data/logs` | +| `Audiobook RAW-Ordner` | path | `null` | +| `Eigentümer Audiobook RAW-Ordner` | string | `null` | +| `Audiobook Output-Ordner` | path | `null` | +| `Eigentümer Audiobook Output-Ordner` | string | `null` | +| `Output Template (Audiobook)` | string | `{author}/{author} - {title} ({year})` | +| `Audiobook RAW Template` | string | `{author} - {title} ({year})` | +| `Converter Raw-Ordner` | path | `null` | +| `Converter Ausgabe (Video)` | path | `null` | +| `Converter Ausgabe (Audio)` | path | `null` | +| `Output-Template (Video)` | string | `{title}` | +| `Output-Template (Audio)` | string | `{artist} - {title}` | + +--- + +## Kategorie: Laufwerk + +| Feldname in der GUI | Typ | Default | Hinweis | +|---|---|---|---| +| `Laufwerksmodus` | select | `auto` | `Auto Discovery` oder `Explizites Device` | +| `Device Pfad` | path | `/dev/sr0` | relevant bei `Explizites Device` | +| `MakeMKV Source Index` | number | `0` | Disc-Index im Auto-Modus | +| `Disc Auto-Erkennung aktiviert` | boolean | `true` | Automatische Disc-Erkennung per Polling | +| `Polling Intervall (ms)` | number | `4000` | 1000..60000 | + +--- + +## Kategorie: Monitoring + +| Feldname in der GUI | Typ | Default | +|---|---|---| +| `Hardware Monitoring aktiviert` | boolean | `true` | +| `Hardware Monitoring Intervall (ms)` | number | `5000` | + +--- + +## Kategorie: Tools (global) + +| Feldname in der GUI | Typ | Default | +|---|---|---| +| `MakeMKV Kommando` | string | `makemkvcon` | +| `MakeMKV Key` | string | `null` | +| `Mediainfo Kommando` | string | `mediainfo` | +| `Minimale Titellaenge (Minuten)` | number | `60` | +| `HandBrake Kommando` | string | `HandBrakeCLI` | +| `FFmpeg Kommando` | string | `ffmpeg` | +| `FFprobe Kommando` | string | `ffprobe` | +| `Encode-Neustart: unvollständige Ausgabe löschen` | boolean | `true` | +| `Parallele Jobs` | number | `1` | + +### Blu-ray-spezifisch + +| Feldname in der GUI | Typ | Default | +|---|---|---| +| `Mediainfo Extra Args` (Blu-ray) | string | `null` | +| `MakeMKV Rip Modus` (Blu-ray) | select | `backup` | +| `MakeMKV Analyze Extra Args` (Blu-ray) | string | `null` | +| `MakeMKV Rip Extra Args` (Blu-ray) | string | `null` | +| `HandBrake Preset` (Blu-ray) | string | `H.264 MKV 1080p30` | +| `HandBrake Extra Args` (Blu-ray) | string | `null` | +| `Ausgabeformat` (Blu-ray) | select | `mkv` | +| `Dateiname Template` (Blu-ray) | string | `${title} (${year})` | +| `Ordnername Template` (Blu-ray) | string | `null` | + +### DVD-spezifisch + +| Feldname in der GUI | Typ | Default | +|---|---|---| +| `Mediainfo Extra Args` (DVD) | string | `null` | +| `MakeMKV Rip Modus` (DVD) | select | `mkv` | +| `MakeMKV Analyze Extra Args` (DVD) | string | `null` | +| `MakeMKV Rip Extra Args` (DVD) | string | `null` | +| `HandBrake Preset` (DVD) | string | `H.264 MKV 480p30` | +| `HandBrake Extra Args` (DVD) | string | `null` | +| `Ausgabeformat` (DVD) | select | `mkv` | +| `Dateiname Template` (DVD) | string | `${title} (${year})` | +| `Ordnername Template` (DVD) | string | `null` | + +--- + +## Kategorie: Converter + +| Feldname in der GUI | Typ | Default | Hinweis | +|---|---|---|---| +| `Erlaubte Datei-Endungen*` | string | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus` | In der UI als Checkbox-Liste, ohne Punkt | +| `Auto-Scan (Polling)` | boolean | `false` | Converter-Ordner automatisch scannen | +| `Polling-Intervall (Sekunden)` | number | `300` | 30..86400 | + +--- + +## Kategorie: Metadaten + +| Feldname in der GUI | Typ | Default | +|---|---|---| +| `OMDb API Key` | string | `null` | +| `OMDb Typ` | select | `movie` | + +--- + +## Kategorie: Benachrichtigungen (PushOver) + +| Feldname in der GUI | Typ | Default | +|---|---|---| +| `PushOver aktiviert` | boolean | `false` | +| `PushOver Token` | string | `null` | +| `PushOver User` | string | `null` | +| `PushOver Device (optional)` | string | `null` | +| `PushOver Titel-Präfix` | string | `Ripster` | +| `PushOver Priority` | number | `0` | +| `PushOver Timeout (ms)` | number | `7000` | +| `Bei Metadaten-Auswahl senden` | boolean | `true` | +| `Bei Rip-Start senden` | boolean | `true` | +| `Bei Encode-Start senden` | boolean | `true` | +| `Bei Erfolg senden` | boolean | `true` | +| `Bei Fehler senden` | boolean | `true` | +| `Bei Abbruch senden` | boolean | `true` | +| `Bei Re-Encode Start senden` | boolean | `true` | +| `Bei Re-Encode Erfolg senden` | boolean | `true` | 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..c190141 --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,71 @@ +# Ersteinrichtung + +Nach der Installation erfolgt die tägliche Konfiguration fast vollständig in der GUI unter `Settings`. + +## Ziel + +Vor dem ersten echten Job müssen Pfade, Tools und Metadatenzugriff sauber gesetzt sein. + +## Reihenfolge (empfohlen) + +### 1. `Settings` -> Tab `Konfiguration` + +Setze zuerst diese Pflichtwerte: + +| Bereich | Wichtige Felder | +|---|---| +| Pfade | `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` | +| Tools | `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando` | +| Metadaten | `OMDb API Key`, optional `OMDb Typ` | + +Danach `Änderungen speichern`. + +### 2. Medienprofile prüfen + +Wenn du Blu-ray und DVD unterschiedlich behandeln willst, pflege die profilbezogenen Felder: + +- `*_bluray` +- `*_dvd` +- optional `*_other` + +Typische Beispiele: + +- `HandBrake Preset` (Blu-ray und DVD) +- `Raw Ausgabeordner` (Blu-ray und DVD) +- `Dateiname Template` (Blu-ray und DVD) + +### 3. Queue und Monitoring festlegen + +- `Parallele Jobs` für den gleichzeitigen Durchsatz +- `Hardware Monitoring aktiviert` + `Hardware Monitoring Intervall (ms)` für Live-Metriken im Ripper + +### 4. Optional: Push-Benachrichtigungen + +In den Benachrichtigungsfeldern setzen: + +- `PushOver aktiviert` +- `PushOver Token` +- `PushOver User` + +Dann über `PushOver Test` direkt prüfen. + +## 2-Minuten-Funktionstest + +1. `Ripper` öffnen +2. Disc einlegen +3. `Analyse starten` +4. Metadaten übernehmen +5. Bis `Bereit zum Encodieren` laufen lassen + +Wenn diese Schritte funktionieren, ist die Grundkonfiguration korrekt. + +## Wenn Werte nicht gespeichert werden + +- Feld mit Fehler markieren lassen (rote Validierung im Formular) +- Pfadangaben und numerische Werte prüfen +- bei Tool-Pfaden direkt CLI-Aufruf im Terminal testen + +## Weiter + +- [Erster Lauf](quickstart.md) +- [GUI-Seiten im Detail](../gui/index.md) 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..77058cf --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,109 @@ +# Installation + +Die empfohlene Installation läuft über `install.sh` und richtet Ripster vollständig ein. + +Du musst dafür **keine Tools manuell vorinstallieren**. Das Skript installiert die benötigten Abhängigkeiten automatisch, sofern sie nicht explizit mit `--no-*` übersprungen werden. + +## Unterstützte Systeme und Anforderungen + +- unterstützt laut Script: `debian`, `ubuntu`, `linuxmint`, `pop` +- Ausführung als `root` (oder via `sudo`) +- Internetzugang während der Installation + +## Schritt-für-Schritt + +### 1. Installationsskript herunterladen + +```bash +wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh +``` + +### 2. Installation ausführen + +```bash +sudo bash install.sh +``` + +Während der Installation wählst du den HandBrake-Modus: + +- `1` Standard (Paketinstallation) +- `2` GPU/NVDEC (gebündeltes Binary) + +### 3. Dienststatus prüfen + +```bash +sudo systemctl status ripster-backend +``` + +### 4. Weboberfläche öffnen + +- mit nginx (Standard): `http://` +- ohne nginx (`--no-nginx`): API auf `http://:3001/api` + +## Was `install.sh` konkret einrichtet + +1. prüft Betriebssystem, Root-Rechte und ermittelt Host/IP +2. aktualisiert Paketquellen und installiert Basispakete (`curl`, `wget`, `git`, `mediainfo`, `udev` ...) +3. installiert Node.js 20 (falls nicht passend vorhanden) +4. installiert optional MakeMKV (Build aus den offiziellen Quellen) +5. installiert optional HandBrakeCLI (Standard oder GPU/NVDEC) +6. installiert optional nginx +7. legt den Systembenutzer `ripster` an (ohne Login-Shell, ohne Home) und ergänzt Gruppen (`cdrom`, `optical`, `disk`, `video`, `render`, falls vorhanden) +8. klont das Repository nach `/opt/ripster` (oder aktualisiert bei `--reinstall`) +9. legt Verzeichnisse an: + - `/opt/ripster/backend/data` + - `/opt/ripster/backend/logs` + - `/opt/ripster/backend/data/output/raw` + - `/opt/ripster/backend/data/output/movies` + - `/opt/ripster/backend/data/logs` +10. installiert npm-Abhängigkeiten (Root, Backend, Frontend) und baut das Frontend +11. erzeugt `backend/.env` (bei `--reinstall` bleibt bestehende `.env` erhalten) +12. setzt Rechte/Besitz und erstellt bei sudo-Installation zusätzlich `~/.MakeMKV` für den aufrufenden Benutzer +13. erzeugt und startet `ripster-backend.service` +14. konfiguriert und startet nginx (falls nicht übersprungen) + +## Wichtige Optionen + +| Option | Default laut Script | Zweck | +|---|---|---| +| `--branch ` | `dev` | Branch für die Installation | +| `--dir ` | `/opt/ripster` | Installationsverzeichnis | +| `--user ` | `ripster` | Service-Benutzer | +| `--port ` | `3001` | Backend-Port | +| `--host ` | automatisch ermittelte Host-IP | Hostname/IP für Webzugriff/CORS | +| `--no-makemkv` | aus | MakeMKV-Installation überspringen | +| `--no-handbrake` | aus | HandBrake-Installation überspringen | +| `--no-nginx` | aus | nginx-Setup überspringen | +| `--reinstall` | aus | bestehende Installation aktualisieren | + +Beispiele: + +```bash +sudo bash install.sh --branch dev +sudo bash install.sh --port 8080 --host ripster.local +sudo bash install.sh --reinstall +``` + +## Betrieb im Alltag + +```bash +# Logs live ansehen +sudo journalctl -u ripster-backend -f + +# Dienst neu starten +sudo systemctl restart ripster-backend + +# Update aus bestehender Installation +sudo bash /opt/ripster/install.sh --reinstall +``` + +## Häufige Stolperstellen + +- Laufwerk nicht zugreifbar: Laufwerksrechte/Gruppen prüfen +- CLI-Tool fehlt wegen `--no-*`: Tool nachinstallieren oder Befehl in Settings korrigieren +- UI nicht erreichbar: nginx-Status und Firewall prüfen + +## Danach weiter + +1. [Ersteinrichtung](configuration.md) +2. [Erster Lauf](quickstart.md) diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md new file mode 100644 index 0000000..9d0d0b3 --- /dev/null +++ b/docs/getting-started/prerequisites.md @@ -0,0 +1,48 @@ +# Voraussetzungen + +Die Voraussetzungen hängen davon ab, **wie** du Ripster betreibst. + +## Produktionsbetrieb mit `install.sh` (Standard) + +Für den normalen Betrieb sind nur wenige Punkte vorab nötig. + +### Pflicht + +- unterstütztes Linux-System (laut Script: Debian, Ubuntu, Linux Mint, Pop!_OS) +- `root`-Rechte +- Internetzugang während der Installation +- optisches Laufwerk für Disc-Betrieb + +`install.sh` installiert die benötigten Tools selbst (u. a. Node.js, MakeMKV, HandBrakeCLI, MediaInfo), sofern sie nicht explizit per `--no-*` übersprungen werden. + +### Laufwerk kurz prüfen + +```bash +ls /dev/sr* +lsblk | grep rom +``` + +Wenn nötig Rechte setzen (Beispiel): + +```bash +sudo chmod a+rw /dev/sr0 +``` + +### Optional vorab + +- OMDb API-Key (kann auch nach Installation in den `Settings` gesetzt werden) +- PushOver-Zugangsdaten (optional) + +## Entwicklungsmodus (nur für Dev) + +Wenn du lokal entwickelst (`./start.sh`, `npm run dev`), gelten zusätzliche Voraussetzungen: + +- Node.js >= 20.19.0 +- `makemkvcon`, `HandBrakeCLI`, `mediainfo` im `PATH` + +Details: [Entwicklungsumgebung](../deployment/development.md) + +## Abschluss-Checkliste + +- [ ] Produktionsbetrieb: Linux + root + Internet + Laufwerk vorhanden +- [ ] Dev-Modus (nur falls benötigt): Node.js und CLI-Tools verfügbar diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..03560bb --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,70 @@ +# Erster Lauf + +Dieser Ablauf zeigt einen vollständigen Job aus Anwendersicht: von Disc-Erkennung bis fertiger Datei. + +## 1. Ripper öffnen und Disc einlegen + +Erwartung: + +- Status wechselt auf `Medium erkannt` +- im Bereich `Disk-Information` sind Laufwerksdaten sichtbar + +Wenn nichts passiert: `Laufwerk neu lesen`. + +## 2. Analyse starten + +Aktion im Ripper: + +- `Analyse starten` + +Erwartung: + +- Status `Analyse` +- danach Metadaten-Dialog + +## 3. Metadaten auswählen + +Im Dialog `Metadaten auswählen`: + +1. OMDb-Suche nutzen oder manuell eintragen +2. passenden Treffer markieren +3. `Auswahl übernehmen` + +## 4. Auf den nächsten Zustand reagieren + +- Normalfall ohne vorhandenes RAW: `Rippen` -> `Mediainfo-Pruefung` -> `Bereit zum Encodieren` +- bei vorhandenem RAW: direkt `Mediainfo-Pruefung` -> `Bereit zum Encodieren` +- bei unklarer Blu-ray-Playlist: `Warte auf Auswahl` (Playlist auswählen und übernehmen) + +## 5. Review in `Bereit zum Encodieren` + +Im aufgeklappten Job (`Pipeline-Status`): + +- Encode-Titel wählen +- Audio-/Subtitle-Spuren prüfen +- optional User-Preset auswählen +- optional Pre-/Post-Skripte bzw. Ketten hinzufügen + +Dann `Encoding starten`. + +## 6. Encoding überwachen + +Während `Encodieren`: + +- Fortschritt + ETA im Ripper +- Live-Log im `Pipeline-Status` +- Queue- und Skript/Cron-Status parallel beobachtbar + +## 7. Ergebnis prüfen + +Bei `Fertig`: + +1. Seite `Historie` öffnen +2. Job in Details öffnen +3. Output-Pfad, Status und Log prüfen + +## Typische Folgeaktionen + +- Falsches OMDb-Match: in `Historie` -> `OMDb neu zuordnen` +- Neue Encodierung aus RAW: `RAW neu encodieren` +- Prüfung komplett neu aufbauen: `Review neu starten` diff --git a/docs/gui/converter.md b/docs/gui/converter.md new file mode 100644 index 0000000..b71f328 --- /dev/null +++ b/docs/gui/converter.md @@ -0,0 +1,91 @@ +# Converter + +Die Converter-Seite ermöglicht das Konvertieren vorhandener Audio- und Video-Dateien sowie das Verarbeiten von Audiobooks (AAX/M4B). + +--- + +## Überblick + +Der Converter arbeitet mit einem konfigurierbaren Eingangsordner (`converter_raw_dir`). Dateien werden dort abgelegt (manuell oder per Upload) und dann als Converter-Jobs in die Pipeline eingereiht. + +--- + +## Datei-Explorer + +Der Datei-Explorer zeigt den Inhalt des `converter_raw_dir` als Baumstruktur. + +### Verfügbare Aktionen + +| Aktion | Beschreibung | +|---|---| +| **Upload** | Dateien direkt aus dem Browser hochladen (bis zu 50 Dateien) | +| **Ordner erstellen** | Neuen Unterordner anlegen | +| **Umbenennen** | Datei oder Ordner umbenennen | +| **Verschieben** | Datei oder Ordner in einen anderen Unterordner verschieben | +| **Löschen** | Datei oder Ordner löschen (unwiderruflich) | +| **Scannen** | Eingangsordner manuell neu scannen | + +### Dateiauswahl + +Dateien können per Checkbox ausgewählt werden. Aus der Auswahl lassen sich Jobs erstellen: + +- **Ein Job pro Datei** — jede Datei wird als eigenständiger Job angelegt +- **Gemeinsamer Job (Audio)** — mehrere Audio-Dateien werden zu einem gemeinsamen Job zusammengefasst + +Erlaubte Dateiendungen werden in den Settings unter `Converter > Erlaubte Datei-Endungen*` als Checkboxen konfiguriert. + +--- + +## Jobs erstellen und konfigurieren + +Nach der Auswahl und Job-Erstellung erscheinen die Jobs in der Job-Liste. + +### Job-Konfiguration + +Jeder Job kann konfiguriert werden: + +- **Medientyp:** Video, Audio oder Audiobook +- **Ausgabeformat:** z. B. `mkv`, `mp4`, `m4b`, `mp3`, `flac` +- **HandBrake-Preset:** für Video-Konvertierung +- **Track-Auswahl:** Video-, Audio- und Untertitel-Spuren +- **Metadaten:** Titel, Autor, Jahr (für Audiobooks) +- **MusicBrainz-Suche:** automatische Metadaten-Suche für Audiodateien + +### Job-Start + +Jobs starten sofort oder reihen sich in die Queue ein (abhängig von `Parallele Jobs`). + +--- + +## Auto-Scan (Polling) + +Wenn in den Settings `Converter > Auto-Scan (Polling)` aktiviert ist, wird der Eingangsordner automatisch in regelmäßigen Abständen gescannt. Neue Dateien werden in der DB erfasst und im Explorer angezeigt. + +Das Scan-Intervall wird unter `Converter > Polling-Intervall (Sekunden)` konfiguriert (Standard: 300 Sekunden). + +--- + +## Audiobook-Verarbeitung + +Für AAX-Dateien (Audible) ist folgendes erforderlich: + +1. AAX-Datei in den Eingangsordner hochladen +2. Job mit Medientyp `Audiobook` erstellen +3. Activation Bytes müssen verfügbar sein (werden beim ersten Mal abgefragt und gecacht) +4. Ausgabeformat wählen: M4B (empfohlen), MP3 oder FLAC +5. Kapitel-Splitting konfigurieren (optional) + +--- + +## Einstellungen + +| Setting | Pfad in GUI | +|---|---| +| Eingangsordner | `Settings > Pfade > Converter Raw-Ordner` | +| Videoausgabe | `Settings > Pfade > Converter Ausgabe (Video)` | +| Audioausgabe | `Settings > Pfade > Converter Ausgabe (Audio)` | +| Erlaubte Endungen | `Settings > Converter > Erlaubte Datei-Endungen*` | +| Auto-Scan | `Settings > Converter > Auto-Scan (Polling)` | +| Scan-Intervall | `Settings > Converter > Polling-Intervall (Sekunden)` | +| Output-Template Video | `Settings > Pfade > Output-Template (Video)` | +| Output-Template Audio | `Settings > Pfade > Output-Template (Audio)` | diff --git a/docs/gui/database.md b/docs/gui/database.md new file mode 100644 index 0000000..5eb451f --- /dev/null +++ b/docs/gui/database.md @@ -0,0 +1,29 @@ +# Database (Expert) + +`/database` ist eine erweiterte Ansicht für Power-User und Recovery-Fälle. + +## Zugriff + +- Route direkt aufrufen: `/database` +- nicht Teil der Standard-Navigation + +## Bereiche + +### `Gefundene RAW-Einträge` + +Listet RAW-Ordner, die keinen zugehörigen Job-Eintrag haben. + +Aktionen: + +- `RAW prüfen` (Scan der konfigurierten RAW-Pfade) +- `Job anlegen` (Orphan-RAW in Historie importieren) + +## Typischer Einsatz + +- nach manuellen Dateioperationen +- nach Migrationen oder Recovery +- wenn RAW-Dateien vorhanden sind, aber kein Historieneintrag existiert + +## Vorsicht + +Diese Seite erlaubt Eingriffe mit direkter Auswirkung auf Datenbestand und Historie. Vor Lösch- oder Importaktionen Pfade und Zieljob sorgfältig prüfen. diff --git a/docs/gui/downloads.md b/docs/gui/downloads.md new file mode 100644 index 0000000..10e79f9 --- /dev/null +++ b/docs/gui/downloads.md @@ -0,0 +1,53 @@ +# Downloads + +Die Downloads-Seite ermöglicht es, Ausgabedateien aus abgeschlossenen Jobs als ZIP-Archiv herunterzuladen. + +--- + +## Überblick + +Jobs, die in der Historie abgeschlossen sind, können in die Download-Queue eingereiht werden. Ripster erstellt daraus ein ZIP-Archiv, das direkt im Browser heruntergeladen werden kann. + +--- + +## Download-Queue + +Die Queue zeigt alle aktuellen Download-Einträge mit ihrem Status: + +| Status | Beschreibung | +|---|---| +| **Ausstehend** | In der Queue, ZIP-Erstellung noch nicht gestartet | +| **Wird verarbeitet** | ZIP-Archiv wird gerade erstellt | +| **Bereit** | ZIP fertig, Download-Button verfügbar | +| **Fehlgeschlagen** | Fehler bei der ZIP-Erstellung | + +--- + +## Download einreihen + +Downloads können über die **Historie** eingereiht werden: + +1. Job in der Historie öffnen +2. **Download einreihen** wählen +3. Ziel auswählen: `RAW-Dateien` oder `Ausgabedateien` +4. Den Eintrag in der Downloads-Queue verfolgen + +--- + +## Download starten + +Sobald der Status **Bereit** angezeigt wird, kann die ZIP-Datei mit dem Download-Button heruntergeladen werden. + +ZIP-Dateinamen folgen dem Muster: `Job__.zip` + +--- + +## Einträge löschen + +Abgeschlossene oder fehlerhafte Einträge können einzeln gelöscht werden. Die dazugehörige ZIP-Datei wird dabei ebenfalls vom Server entfernt. + +--- + +## Echtzeit-Updates + +Die Downloads-Seite aktualisiert sich automatisch per WebSocket (`DOWNLOADS_UPDATED`). Statusänderungen (z. B. von `Wird verarbeitet` zu `Bereit`) werden ohne Seiten-Reload angezeigt. diff --git a/docs/gui/history.md b/docs/gui/history.md new file mode 100644 index 0000000..3f2b049 --- /dev/null +++ b/docs/gui/history.md @@ -0,0 +1,62 @@ +# Historie + +Die Seite `Historie` ist für Suche, Prüfung und Nachbearbeitung bestehender Jobs. + +## Hauptansicht + +Filter und Werkzeuge: + +- Suche (Titel/IMDb) +- Status-Filter +- Medium-Filter (`Blu-ray`, `DVD`, `Sonstiges`) +- Sortierung +- Listen-/Grid-Layout + +Jeder Eintrag zeigt: + +- Poster, Titel, Jahr, IMDb +- Medium-Indikator +- Status +- Start/Ende +- Verfügbarkeit von RAW/Movie +- Ratings (wenn OMDb-Daten vorhanden) + +Klick auf einen Eintrag öffnet die Detailansicht. + +--- + +## Job-Detaildialog + +Bereiche: + +- Film-Infos + OMDb-Details +- Job-Infos (Status, Pfade, Erfolgsflags, Fehler) +- hinterlegte Encode-Auswahl +- ausgeführter HandBrake-Befehl +- strukturierte JSON-Blöcke (OMDb/MakeMKV/MediaInfo/EncodePlan/HandBrake) +- Log-Ladefunktionen (`Tail`, `Vollständig`) + +## Typische Aktionen im Detaildialog + +- `OMDb neu zuordnen` +- `Encode neu starten` +- `Review neu starten` +- `RAW neu encodieren` +- `RAW löschen`, `Movie löschen`, `Beides löschen` +- `Historieneintrag löschen` +- bei Queue-Lock: `Aus Queue löschen` + +## Wann welche Aktion? + +| Ziel | Aktion | +|---|---| +| Metadaten korrigieren | `OMDb neu zuordnen` | +| mit gleicher bestätigter Auswahl neu encodieren | `Encode neu starten` | +| Titel-/Spurprüfung komplett neu berechnen | `Review neu starten` | +| aus vorhandenem RAW erneut encodieren | `RAW neu encodieren` | +| Speicher freigeben | Dateilöschaktionen | + +## Logs + +- `Tail laden (800)` für schnelle Fehleranalyse +- `Vollständiges Log laden` für vollständige Nachverfolgung diff --git a/docs/gui/index.md b/docs/gui/index.md new file mode 100644 index 0000000..53f613c --- /dev/null +++ b/docs/gui/index.md @@ -0,0 +1,28 @@ +# GUI-Seiten + +Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite. + +## Seitenüberblick + +| Seite | Zweck | +|---|---| +| [Ripper](ripper.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos | +| [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs | +| [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten | +| [Converter](converter.md) | Audio/Video-Dateien konvertieren, Datei-Explorer, Upload | +| [Downloads](downloads.md) | Ausgabedateien als ZIP herunterladen | +| [Database (Expert)](database.md) | tabellarische Rohsicht inkl. Orphan-RAW-Import | + +## Empfohlene Nutzung im Alltag + +1. **Start eines neuen Disc-Jobs:** `Ripper` +2. **Dateien konvertieren oder Audiobooks verarbeiten:** `Converter` +3. **Regeln/Automatisierung anpassen:** `Settings` +4. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie` +5. **Dateien herunterladen:** `Downloads` +6. **Sonderfälle/Recovery:** `Database` + +## Hinweise zur Navigation + +- `Ripper`, `Settings`, `Historie`, `Converter` und `Downloads` sind direkt in der Kopfnavigation. +- `Database` ist als Expert-Route verfügbar: `/database`. diff --git a/docs/gui/ripper.md b/docs/gui/ripper.md new file mode 100644 index 0000000..2782720 --- /dev/null +++ b/docs/gui/ripper.md @@ -0,0 +1,124 @@ +# Ripper + +Das Ripper ist die **Betriebszentrale** für laufende Jobs. + +## Aufbau der Seite + +Die Bereiche erscheinen in dieser Reihenfolge: + +1. `Hardware Monitoring` +2. `Job Queue` +3. `Skript- / Cron-Status` +4. `Job Übersicht` +5. `Disk-Information` + +--- + +## 1) Hardware Monitoring + +Zeigt live: + +- CPU (gesamt + optional pro Kern) +- RAM +- GPU-Auslastung/Temperatur/VRAM +- freien Speicher in den konfigurierten Pfaden + +Wichtig für den Betrieb: + +- Hohe Speicherauslastung oder fast volle Zielpfade früh erkennen +- über `Settings` aktivierbar/deaktivierbar (`Hardware Monitoring aktiviert`) + +## 2) Job Queue + +Zwei Spalten: + +- `Laufende Jobs` +- `Warteschlange` + +Mögliche Aktionen: + +- Queue per Drag-and-Drop umsortieren +- Queue-Job entfernen (`X`) +- zusätzliche Queue-Elemente einfügen (`+`): + - Skript + - Skriptkette + - Wartezeit + +Hinweis: + +- `Parallel` zeigt den Wert aus `Settings` -> `Parallele Jobs`. + +## 3) Skript- / Cron-Status + +Zeigt: + +- aktive Ausführungen (Skripte, Ketten, Cron) +- zuletzt abgeschlossene Ausführungen + +Mögliche Aktionen: + +- laufende Ketten: `Nächster Schritt` +- laufende Einträge: `Abbrechen` +- Historie der Aktivitäten: `Liste leeren` + +## 4) Job Übersicht + +Kompakte Jobliste mit Status, Fortschritt, ETA. Klick auf einen Job klappt die Detailsteuerung auf. + +Im aufgeklappten Zustand erscheint die Karte `Pipeline-Status` mit allen zustandsabhängigen Aktionen. + +### Zustandsabhängige Hauptaktionen + +| Zustand | Typische Aktion | +|---|---| +| `Medium erkannt` / `Bereit` | `Analyse starten` | +| `Metadatenauswahl` | `Metadaten öffnen` | +| `Warte auf Auswahl` | Playlist wählen und `Playlist übernehmen` | +| `Startbereit` | `Job starten` | +| `Bereit zum Encodieren` | Tracks/Skripte prüfen, dann `Encoding starten` | +| laufend (`Analyse`/`Rippen`/`Encodieren`) | `Abbrechen` | +| `Fehler` / `Abgebrochen` | `Retry Rippen`, `Disk-Analyse neu starten` | + +Zusätzlich je nach Job: + +- `Review neu starten` +- `Encode neu starten` +- `Aus Queue löschen` + +### Titel-/Spurprüfung (`Bereit zum Encodieren`) + +Im selben Block siehst du: + +- Auswahl des Encode-Titels +- Audio-/Subtitle-Trackauswahl +- User-Preset-Auswahl +- Pre-/Post-Encode-Skripte und Ketten +- Preview des finalen HandBrakeCLI-Befehls + +## 5) Disk-Information + +Zeigt aktuelles Laufwerk und Disc-Metadaten (`Pfad`, `Modell`, `Disc-Label`, `Mount`). + +Aktionen: + +- `Laufwerk neu lesen` +- `Disk neu analysieren` +- `Metadaten-Modal öffnen` + +--- + +## Wichtige Dialoge im Ripper + +### Metadaten auswählen + +- OMDb-Suche + Ergebnisliste +- manuelle Eingabe als Fallback +- `Auswahl übernehmen` startet den nächsten Pipeline-Schritt + +### Abbruch-Bereinigung + +Nach Abbruch kann Ripster optional fragen, ob erzeugte RAW- oder Movie-Dateien gelöscht werden sollen. + +### Queue-Eintrag einfügen + +Erstellt gezielt einen Skript-, Ketten- oder Warte-Eintrag an einer bestimmten Queue-Position. diff --git a/docs/gui/settings.md b/docs/gui/settings.md new file mode 100644 index 0000000..c8f8d1c --- /dev/null +++ b/docs/gui/settings.md @@ -0,0 +1,92 @@ +# Settings + +Die Seite `Settings` steuert Konfiguration und Automatisierung. + +## Tabs im Überblick + +| Tab | Zweck | +|---|---| +| `Konfiguration` | alle Kernsettings (Pfade, Tools, Monitoring, Metadaten, Queue, Benachrichtigungen) | +| `Scripte` | einzelne Bash-Skripte verwalten und testen | +| `Skriptketten` | Sequenzen aus Skript- und Warte-Schritten bauen | +| `Encode-Presets` | benutzerdefinierte Presets für das Review im Ripper | +| `Cronjobs` | zeitgesteuerte Skript-/Kettenausführung | + +--- + +## Tab `Konfiguration` + +Wichtiges Bedienmuster: + +1. Werte ändern +2. `Änderungen speichern` +3. bei Bedarf `Änderungen verwerfen` oder `Neu laden` + +Zusätzlich: + +- `PushOver Test` sendet eine Testnachricht +- Änderungen werden erst nach Speichern wirksam +- Tool-Preset-Felder bieten HandBrake-Presetauswahl direkt im Formular + +## Tab `Scripte` + +Funktionen: + +- Skript anlegen, bearbeiten, löschen +- Skript testen (`Test`) +- Reihenfolge per Drag-and-Drop + +Praxis: + +- Reihenfolge ist wichtig, weil ausgewählte Skripte später sequentiell abgearbeitet werden. +- Testresultate zeigen Exit-Code, Dauer und stdout/stderr. + +## Tab `Skriptketten` + +Funktionen: + +- Kette anlegen/bearbeiten/löschen +- Kette testen +- Reihenfolge der Ketten per Drag-and-Drop + +Im Ketten-Editor: + +- Bausteine links (`Warten`, vorhandene Skripte) +- Schritte rechts per Klick oder Drag-and-Drop hinzufügen +- Schrittreihenfolge im Canvas ändern + +## Tab `Encode-Presets` + +Ein Preset bündelt: + +- optional HandBrake-Preset (`-Z`) +- optionale Extra-Args +- Medientyp (`Universell`, `Blu-ray`, `DVD`, `Sonstiges`) + +Verwendung: + +- Diese Presets erscheinen später im Ripper im Review (`Bereit zum Encodieren`). + +## Tab `Cronjobs` + +Funktionen: + +- Cronjob anlegen und bearbeiten +- Quelle wählen: Skript oder Skriptkette +- Cron-Ausdruck validieren +- `Jetzt ausführen` +- Logs je Cronjob anzeigen +- `Aktiviert` und `Pushover` toggeln + +Hilfen: + +- Beispiele für Cron-Ausdrücke direkt im Dialog +- Link zu `crontab.guru` im Editor + +--- + +## Empfehlung für stabile Nutzung + +1. Erst `Konfiguration` sauber setzen +2. dann Skripte/Ketten testen +3. danach Cronjobs aktivieren diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..0307b8d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,33 @@ +# Ripster Handbuch + +Dieses Dokumentationsset ist als **Benutzerhandbuch** aufgebaut: erst Bedienung und Alltag, dann Technik im Anhang. + +--- + +## Schnellstart in 3 Schritten + +1. Voraussetzungen prüfen und installieren: [Installation](getting-started/installation.md) +2. Grundkonfiguration in der UI setzen: [Ersteinrichtung](getting-started/configuration.md) +3. Ersten vollständigen Job durchlaufen: [Erster Lauf](getting-started/quickstart.md) + +--- + +## Was du hier findest + +- **Benutzerhandbuch** + - Installation + - GUI-Seiten im Detail (`Ripper`, `Settings`, `Historie`, `Database`) + - typische Arbeitsabläufe aus Anwendersicht +- **Technischer Anhang** + - vollständige Einstellungsreferenz + - Pipeline-/API-/Architekturdetails + - Deployment und Tool-Hintergründe + +--- + +## Empfohlene Lesereihenfolge + +1. [Benutzerhandbuch Überblick](getting-started/index.md) +2. [GUI-Seiten](gui/index.md) +3. [Workflows aus Nutzersicht](workflows/index.md) +4. Bei Bedarf: [Technischer Anhang](appendix/index.md) 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..b96d683 --- /dev/null +++ b/docs/pipeline/playlist-analysis.md @@ -0,0 +1,60 @@ +# Playlist-Analyse + +Ripster analysiert bei Blu-ray-ähnlichen Quellen Playlists und fordert bei Mehrdeutigkeit eine manuelle Auswahl an. + +--- + +## Ziel + +Erkennen, welche Playlist sehr wahrscheinlich der Hauptfilm ist, statt versehentlich eine Fake-/Dummy-Playlist zu verwenden. + +--- + +## Eingabedaten + +Die Analyse basiert auf MakeMKV-Infos (u. a. Playlist-/Segment-Struktur, Laufzeiten, Titelzuordnung). + +--- + +## Auswertung (vereinfacht) + +Für Kandidaten werden u. a. berücksichtigt: + +- Laufzeit +- Segment-Reihenfolge +- Rückwärtssprünge/große Sprünge +- Kohärenz linearer Segmentfolgen +- Duplikatgruppen mit ähnlicher Laufzeit + +Daraus entstehen intern Kandidatenlisten, Bewertungen und eine Empfehlung. + +--- + +## Wann muss der Benutzer entscheiden? + +Wenn nach Filterung mehr als ein relevanter Kandidat übrig bleibt, wechselt der Job in der GUI auf: + +- `Warte auf Auswahl` + +Dann muss eine Playlist bestätigt werden, bevor der Workflow weiterläuft. + +--- + +## Konfigurationseinfluss + +| Feld in `Settings` | Wirkung | +|---|---| +| `Minimale Titellaenge (Minuten)` | Mindestlaufzeit für Kandidaten | + +Default ist aktuell `60` Minuten. + +--- + +## UI-Verhalten + +Bei manueller Entscheidung zeigt das Ripper Kandidaten inkl. Score/Bewertung und markiert eine Empfehlung. + +Nach Bestätigung: + +- mit vorhandenem RAW -> zurück zu `Mediainfo-Pruefung` +- ohne RAW -> Startpfad über `Startbereit` / `Rippen` 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..1e877fe --- /dev/null +++ b/docs/pipeline/workflow.md @@ -0,0 +1,87 @@ +# Workflow & Zustände + +Ripster steuert den Ablauf als Zustandsmaschine. + +--- + +## Zustandsdiagramm (vereinfacht) + +```mermaid +flowchart LR + A[Bereit] --> B[Medium erkannt] + B --> C[Analyse] + C --> D[Metadatenauswahl] + D --> E[Startbereit] + E --> F[Rippen] + E --> G[Mediainfo-Pruefung] + G --> H[Warte auf Auswahl] + H --> G + G --> I[Bereit zum Encodieren] + I --> J[Encodieren] + J --> K[Fertig] + J --> L[Fehler] + F --> L + F --> M[Abgebrochen] +``` + +--- + +## Statusliste (GUI-Anzeige) + +| Status in der GUI | Bedeutung | +|---|---| +| `Bereit` | Wartet auf Disc | +| `Medium erkannt` | Disc wurde erkannt | +| `Analyse` | MakeMKV-Analyse läuft | +| `Metadatenauswahl` | Metadaten müssen bestätigt werden | +| `Warte auf Auswahl` | Playlist-Auswahl ist erforderlich | +| `Startbereit` | kurzer Übergang vor Start | +| `Rippen` | MakeMKV-Rip läuft | +| `Mediainfo-Pruefung` | Titel/Spuren werden ausgewertet | +| `Bereit zum Encodieren` | Review ist bereit | +| `Encodieren` | HandBrake läuft | +| `Fertig` | erfolgreich abgeschlossen | +| `Abgebrochen` | manuell oder technisch abgebrochen | +| `Fehler` | fehlgeschlagen | + +--- + +## Typische Pfade + +### Standardfall (kein vorhandenes RAW) + +1. Medium erkannt +2. Analyse + Metadaten +3. Rippen +4. Mediainfo-Pruefung +5. Bereit zum Encodieren +6. Encodieren +7. Fertig + +### Vorhandenes RAW + +`Startbereit` springt direkt zu `Mediainfo-Pruefung` (kein neuer Rip). + +### Mehrdeutige Blu-ray-Playlist + +`Mediainfo-Pruefung` -> `Warte auf Auswahl` bis Playlist bestätigt wurde. + +--- + +## Queue-Verhalten + +Wenn der Wert `Parallele Jobs` erreicht ist: + +- neue Starts werden als Queue-Einträge abgelegt +- die Queue kann zusätzlich Nicht-Job-Einträge enthalten (`Skript`, `Kette`, `Warten`) +- Reihenfolge ist per UI/API änderbar + +--- + +## Abbruch, Wiederaufnahme, Neustart + +- `Abbrechen`: laufenden Job stoppen oder Queue-Eintrag entfernen +- `Retry Rippen`: Fehler-/Abbruch-Job erneut starten +- `RAW neu encodieren`: aus vorhandenem RAW neu encodieren +- `Review neu starten`: Review aus RAW neu aufbauen +- `Encode neu starten`: Encoding mit letzter bestätigter Auswahl neu starten 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..41ddf46 --- /dev/null +++ b/docs/tools/makemkv.md @@ -0,0 +1,61 @@ +# 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, führt Ripster vor Analyse/Rip aus: + +```bash +makemkvcon reg +``` + +--- + +## 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..f72df9b --- /dev/null +++ b/docs/workflows/index.md @@ -0,0 +1,61 @@ +# Workflows aus Nutzersicht + +Diese Seite beschreibt typische Abläufe mit den passenden UI-Aktionen. + +## Workflow 1: Standardlauf (Disc -> fertige Datei) + +1. `Ripper`: Disc einlegen, `Analyse starten` +2. Metadaten im Dialog übernehmen +3. bei `Bereit zum Encodieren` Titel/Tracks prüfen +4. `Encoding starten` +5. Ergebnis in `Historie` kontrollieren + +## Workflow 2: Playlist-Entscheidung bei Blu-ray + +1. Job landet in `Warte auf Auswahl` +2. im `Pipeline-Status` Playlist-Kandidaten vergleichen +3. gewünschte Playlist auswählen +4. `Playlist übernehmen` +5. danach normal weiter bis `Bereit zum Encodieren` + +## Workflow 3: Mehrere Jobs mit Queue + +1. Parallel-Limit in `Settings` über `Parallele Jobs` setzen +2. neue Jobs starten; überschüssige Starts gehen in `Job Queue` +3. Reihenfolge per Drag-and-Drop anpassen +4. bei Bedarf Skript/Kette/Warten als Queue-Eintrag ergänzen + +## Workflow 4: Nachbearbeitung eines bestehenden Jobs + +In `Historie` -> Detaildialog: + +- Metadaten korrigieren: `OMDb neu zuordnen` +- gleiche Einstellungen erneut nutzen: `Encode neu starten` +- Analyse neu aufbauen: `Review neu starten` +- aus RAW erneut encodieren: `RAW neu encodieren` + +## Workflow 5: Automatisierung mit Skripten und Cron + +1. `Settings` -> `Scripte`: Skripte anlegen und testen +2. `Settings` -> `Skriptketten`: Ketten bauen und testen +3. im Ripper-Review Pre-/Post-Ausführungen pro Job auswählen +4. `Settings` -> `Cronjobs`: zeitgesteuerte Ausführung konfigurieren +5. Status im Ripper (`Skript- / Cron-Status`) überwachen + +## Workflow 6: Abbruch und Recovery + +### Fall A: Job wurde abgebrochen + +- im Ripper optional erzeugte RAW/Movie-Datei bereinigen +- anschließend je nach Ziel: `Retry Rippen` oder `Disk-Analyse neu starten` + +### Fall B: Job steht in `Bereit zum Encodieren`, ist aber nicht aktive Session + +- in `Historie`: `Im Ripper öffnen` +- im Ripper Review erneut prüfen und starten + +### Fall C: RAW ohne Historieneintrag + +- `/database` öffnen +- Bereich `Gefundene RAW-Einträge` +- `Job anlegen` 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..65e6606 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1713 @@ +{ + "name": "ripster-frontend", + "version": "0.13.1-4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ripster-frontend", + "version": "0.13.1-4", + "dependencies": { + "primeicons": "^7.0.0", + "primereact": "^10.9.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.12" + } + }, + "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/@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/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..2c2e0af --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "ripster-frontend", + "version": "0.13.1-4", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "primeicons": "^7.0.0", + "primereact": "^10.9.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.12" + } +} 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..d968f23 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,684 @@ +import { useEffect, useRef, useState } from 'react'; +import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; +import { Button } from 'primereact/button'; +import { ProgressBar } from 'primereact/progressbar'; +import { Tag } from 'primereact/tag'; +import { Toast } from 'primereact/toast'; +import { 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 JobsInboxPage from './pages/JobsInboxPage'; +import AudiobooksPage from './pages/AudiobooksPage'; + +function normalizeJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function clampPercent(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return 0; + } + return Math.max(0, Math.min(100, parsed)); +} + +function normalizeStage(value) { + return String(value || '').trim().toUpperCase(); +} + +function isTerminalStage(value) { + const normalized = normalizeStage(value); + return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; +} + +function formatBytes(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return 'n/a'; + } + if (parsed === 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let unitIndex = 0; + let current = parsed; + while (current >= 1024 && unitIndex < units.length - 1) { + current /= 1024; + unitIndex += 1; + } + const digits = unitIndex <= 1 ? 0 : 2; + return `${current.toFixed(digits)} ${units[unitIndex]}`; +} + +function createInitialAudiobookUploadState() { + return { + phase: 'idle', + fileName: null, + loadedBytes: 0, + totalBytes: 0, + progressPercent: 0, + statusText: null, + errorMessage: null, + jobId: null, + startedAt: null, + finishedAt: null + }; +} + +function getAudiobookUploadTagMeta(phase) { + const normalized = String(phase || '').trim().toLowerCase(); + if (normalized === 'uploading') { + return { label: 'Upload läuft', severity: 'warning' }; + } + if (normalized === 'processing') { + return { label: 'Server verarbeitet', severity: 'info' }; + } + if (normalized === 'completed') { + return { label: 'Bereit', severity: 'success' }; + } + if (normalized === 'error') { + return { label: 'Fehler', severity: 'danger' }; + } + return { label: 'Inaktiv', severity: 'secondary' }; +} + +function getDownloadIndicatorMeta(summary) { + const activeCount = Number(summary?.activeCount || 0); + const failedCount = Number(summary?.failedCount || 0); + const totalCount = Number(summary?.totalCount || 0); + + if (activeCount > 0) { + return { + icon: 'pi pi-spinner pi-spin', + label: activeCount === 1 ? '1 ZIP aktiv' : `${activeCount} ZIPs aktiv`, + className: 'zip-status-indicator-active' + }; + } + if (totalCount > 0) { + return { + icon: 'pi pi-check', + label: failedCount > 0 ? 'ZIP-Jobs beendet' : 'ZIPs fertig', + className: 'zip-status-indicator-ready' + }; + } + return { + icon: 'pi pi-download', + label: 'ZIPs', + className: 'zip-status-indicator-idle' + }; +} + +function App() { + const appVersion = __APP_VERSION__; + const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} }); + const [hardwareMonitoring, setHardwareMonitoring] = useState(null); + const [lastDiscEvent, setLastDiscEvent] = useState(null); + const [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 [pendingRipperJobId, setPendingRipperJobId] = useState(null); + const location = useLocation(); + const navigate = useNavigate(); + const globalToastRef = useRef(null); + const prevCdDrivesRef = useRef({}); + + // 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]); + + const refreshPipeline = async () => { + const response = await api.getPipelineState(); + setPipeline(response.pipeline); + setHardwareMonitoring(response?.hardwareMonitoring || null); + return response; + }; + + const clearAudiobookUpload = () => { + setAudiobookUpload(createInitialAudiobookUploadState()); + }; + + const handleAudiobookUpload = async (file, payload = {}) => { + if (!file) { + throw new Error('Bitte zuerst eine AAX-Datei auswählen.'); + } + + const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0 + ? Number(file.size) + : 0; + + setAudiobookUpload({ + phase: 'uploading', + fileName: String(file.name || '').trim() || 'upload.aax', + loadedBytes: 0, + totalBytes: fallbackTotalBytes, + progressPercent: 0, + statusText: 'AAX-Datei wird hochgeladen ...', + errorMessage: null, + jobId: null, + startedAt: new Date().toISOString(), + finishedAt: null + }); + + try { + const response = await api.uploadAudiobook(file, payload, { + onProgress: ({ loaded, total, percent }) => { + const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0 + ? Number(loaded) + : 0; + const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0 + ? Number(total) + : fallbackTotalBytes; + const nextPercent = Number.isFinite(Number(percent)) + ? clampPercent(Number(percent)) + : (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0); + const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal; + + setAudiobookUpload((prev) => ({ + ...prev, + phase: transferComplete ? 'processing' : 'uploading', + loadedBytes: nextLoaded, + totalBytes: nextTotal, + progressPercent: nextPercent, + statusText: transferComplete + ? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...' + : 'AAX-Datei wird hochgeladen ...' + })); + } + }); + + const uploadedJobId = normalizeJobId(response?.result?.jobId); + await refreshPipeline().catch(() => null); + setRipperJobsRefreshToken((prev) => prev + 1); + setHistoryJobsRefreshToken((prev) => prev + 1); + if (uploadedJobId) { + setPendingRipperJobId(uploadedJobId); + } + + setAudiobookUpload((prev) => ({ + ...prev, + phase: 'completed', + loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes, + totalBytes: prev.totalBytes || fallbackTotalBytes, + progressPercent: 100, + statusText: uploadedJobId + ? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.` + : 'Upload abgeschlossen.', + errorMessage: null, + jobId: uploadedJobId, + finishedAt: new Date().toISOString() + })); + + return response; + } catch (error) { + setAudiobookUpload((prev) => ({ + ...prev, + phase: 'error', + errorMessage: error?.message || 'Upload fehlgeschlagen.', + statusText: error?.message || 'Upload fehlgeschlagen.', + finishedAt: new Date().toISOString() + })); + throw error; + } + }; + + const handleRipperJobFocusConsumed = (jobId) => { + const normalizedJobId = normalizeJobId(jobId); + if (!normalizedJobId) { + return; + } + setPendingRipperJobId((prev) => ( + normalizeJobId(prev) === normalizedJobId ? null : prev + )); + }; + + 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); + }, []); + + useWebSocket({ + onMessage: (message) => { + if (message.type === 'PIPELINE_STATE_CHANGED') { + setPipeline(message.payload); + } + + if (message.type === 'PIPELINE_PROGRESS') { + const payload = message.payload; + const progressJobId = payload?.activeJobId; + const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object' + ? payload.contextPatch + : null; + setPipeline((prev) => { + const next = { ...prev }; + const normalizedProgressJobId = normalizeJobId(progressJobId); + const progressStage = normalizeStage(payload?.state); + const isCdProgressStage = progressStage === 'CD_ANALYZING' + || progressStage === 'CD_RIPPING' + || progressStage === 'CD_ENCODING'; + 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 prevJobProgressIsTerminal = isTerminalStage(prevJobProgressState); + const matchingCdDriveEntry = normalizedProgressJobId + ? Object.values(prev?.cdDrives || {}).find((driveState) => normalizeJobId(driveState?.jobId) === normalizedProgressJobId) + : null; + const matchingCdDriveIsTerminal = isTerminalStage(matchingCdDriveEntry?.state); + const hasKnownBinding = Boolean( + normalizedProgressJobId + && ( + prevActiveJobId === normalizedProgressJobId + || prevJobProgress + || matchingCdDriveEntry + ) + ); + + // Ignore late/stale progress packets that arrive after a terminal state + // or for jobs that are no longer bound in pipeline state. + if ( + normalizedProgressJobId + && !incomingIsTerminal + && ( + prevJobProgressIsTerminal + || matchingCdDriveIsTerminal + || !hasKnownBinding + ) + ) { + return prev; + } + + if (progressJobId != null) { + const previousJobProgress = prev?.jobProgress?.[progressJobId] || {}; + const previousJobStage = normalizeStage(previousJobProgress?.state); + const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal; + 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 previousGlobalStage = normalizeStage(prev?.state); + const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal; + 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 + })); + } + + 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); + } + } + + if (message.type === 'SETTINGS_BULK_UPDATED') { + const keys = message.payload?.keys || []; + 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 (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: 'Jobs', path: '/jobs' }, + { 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 uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); + const showAudiobookUploadBanner = uploadPhase !== 'idle'; + const uploadProgress = clampPercent(audiobookUpload?.progressPercent); + const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase); + const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0); + const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0); + const uploadBytesLabel = uploadTotalBytes > 0 + ? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}` + : (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null); + const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error'; + const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId)); + const isAudiobooksRoute = location.pathname === '/audiobooks'; + 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) => ( +
+
+ + {showAudiobookUploadBanner ? ( +
+
+
+ Audiobook Upload + +
+ {audiobookUpload?.statusText || 'Upload aktiv.'} + {audiobookUpload?.fileName ? Datei: {audiobookUpload.fileName} : null} +
+ +
+ + + {uploadPhase === 'processing' + ? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}` + : uploadBytesLabel + ? `${Math.round(uploadProgress)}% | ${uploadBytesLabel}` + : `${Math.round(uploadProgress)}%`} + +
+ +
+ {hasUploadedJob && !isAudiobooksRoute ? ( +
+
+ ) : null} + +
+ + + + + } + > + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + + +
+
+ ); +} + +export default App; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..1d10f4f --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,1063 @@ +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(() => { + 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); + }, + getScripts(options = {}) { + return requestCachedGet('/settings/scripts', { + ttlMs: 2 * 60 * 1000, + forceRefresh: options.forceRefresh + }); + }, + async createScript(payload = {}) { + const result = await request('/settings/scripts', { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/settings/scripts']); + return result; + }, + async reorderScripts(orderedScriptIds = []) { + const result = await request('/settings/scripts/reorder', { + method: 'POST', + body: JSON.stringify({ + orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : [] + }) + }); + afterMutationInvalidate(['/settings/scripts']); + return result; + }, + async updateScript(scriptId, payload = {}) { + const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { + method: 'PUT', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/settings/scripts']); + return result; + }, + async deleteScript(scriptId) { + const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { + method: 'DELETE' + }); + afterMutationInvalidate(['/settings/scripts']); + return result; + }, + testScript(scriptId) { + return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, { + method: 'POST' + }); + }, + getScriptChains(options = {}) { + return requestCachedGet('/settings/script-chains', { + ttlMs: 2 * 60 * 1000, + forceRefresh: options.forceRefresh + }); + }, + async createScriptChain(payload = {}) { + const result = await request('/settings/script-chains', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/settings/script-chains']); + return result; + }, + async reorderScriptChains(orderedChainIds = []) { + const result = await request('/settings/script-chains/reorder', { + method: 'POST', + body: JSON.stringify({ + orderedChainIds: Array.isArray(orderedChainIds) ? orderedChainIds : [] + }) + }); + afterMutationInvalidate(['/settings/script-chains']); + return result; + }, + async updateScriptChain(chainId, payload = {}) { + const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/settings/script-chains']); + return result; + }, + async deleteScriptChain(chainId) { + const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, { + method: 'DELETE' + }); + afterMutationInvalidate(['/settings/script-chains']); + return result; + }, + testScriptChain(chainId) { + return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, { + method: 'POST' + }); + }, + async 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'); + }, + 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; + }, + searchOmdb(q) { + return request(`/pipeline/omdb/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) { + return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`); + }, + getMusicBrainzRelease(mbId) { + return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`); + }, + async selectCdMetadata(payload) { + const result = await request('/pipeline/cd/select-metadata', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async startCdRip(jobId, ripConfig) { + const result = await request(`/pipeline/cd/start/${jobId}`, { + method: 'POST', + body: JSON.stringify(ripConfig || {}) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async uploadAudiobook(file, payload = {}, options = {}) { + const formData = new FormData(); + if (file) { + formData.append('file', file); + } + if (payload?.format) { + formData.append('format', String(payload.format)); + } + if (payload?.startImmediately !== undefined) { + formData.append('startImmediately', String(payload.startImmediately)); + } + const result = await requestWithXhr('/pipeline/audiobook/upload', { + method: 'POST', + body: formData, + signal: options?.signal, + onUploadProgress: options?.onProgress + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async startAudiobook(jobId, payload = {}) { + const result = await request(`/pipeline/audiobook/start/${jobId}`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + getAudiobookJobs() { + return request('/pipeline/audiobook/jobs'); + }, + getAudiobookOutputTree() { + return request('/pipeline/audiobook/output-tree'); + }, + async selectMetadata(payload) { + const result = await request('/pipeline/select-metadata', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async startJob(jobId) { + const result = await request(`/pipeline/start/${jobId}`, { + method: 'POST' + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async confirmEncodeReview(jobId, payload = {}) { + const result = await request(`/pipeline/confirm-encode/${jobId}`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async cancelPipeline(jobId = null) { + const result = await request('/pipeline/cancel', { + method: 'POST', + body: JSON.stringify({ jobId }) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async retryJob(jobId) { + const result = await request(`/pipeline/retry/${jobId}`, { + method: 'POST' + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async resumeReadyJob(jobId) { + const result = await request(`/pipeline/resume-ready/${jobId}`, { + method: 'POST' + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async reencodeJob(jobId, 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; + 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; + 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}`); + }, + getOrphanRawFolders() { + return request('/history/orphan-raw'); + }, + async importOrphanRawFolder(rawPath) { + const result = await request('/history/orphan-raw/import', { + method: 'POST', + body: JSON.stringify({ rawPath }) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async assignJobOmdb(jobId, payload = {}) { + const result = await request(`/history/${jobId}/omdb/assign`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/history']); + return result; + }, + async assignJobCdMetadata(jobId, payload = {}) { + const result = await request(`/history/${jobId}/cd/assign`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/history']); + return result; + }, + async acknowledgeJobError(jobId) { + const result = await request(`/history/${jobId}/error/ack`, { + method: 'POST' + }); + afterMutationInvalidate(['/history']); + return result; + }, + async deleteJobFiles(jobId, target = 'both') { + const result = await request(`/history/${jobId}/delete-files`, { + method: 'POST', + body: JSON.stringify({ target }) + }); + afterMutationInvalidate(['/history']); + return result; + }, + getJobDeletePreview(jobId, options = {}) { + const includeRelated = options?.includeRelated !== false; + const query = new URLSearchParams(); + query.set('includeRelated', includeRelated ? '1' : '0'); + return request(`/history/${jobId}/delete-preview?${query.toString()}`); + }, + async deleteJobEntry(jobId, target = 'none', options = {}) { + const includeRelated = Boolean(options?.includeRelated); + const resetDriveState = Boolean(options?.resetDriveState); + 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, + ...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}), + ...(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 + }); + }, + async createUserPreset(payload = {}) { + const result = await request('/settings/user-presets', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/settings/user-presets']); + return result; + }, + async updateUserPreset(id, payload = {}) { + const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/settings/user-presets']); + return result; + }, + async deleteUserPreset(id) { + const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, { + method: 'DELETE' + }); + afterMutationInvalidate(['/settings/user-presets']); + return result; + }, + + // ── Cron Jobs ────────────────────────────────────────────────────────────── + getCronJobs() { + return request('/crons'); + }, + getCronJob(id) { + return request(`/crons/${encodeURIComponent(id)}`); + }, + createCronJob(payload = {}) { + return request('/crons', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, + updateCronJob(id, payload = {}) { + return request(`/crons/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + }, + deleteCronJob(id) { + return request(`/crons/${encodeURIComponent(id)}`, { + method: 'DELETE' + }); + }, + getCronJobLogs(id, limit = 20) { + return request(`/crons/${encodeURIComponent(id)}/logs?limit=${limit}`); + }, + runCronJobNow(id) { + return request(`/crons/${encodeURIComponent(id)}/run`, { + method: 'POST' + }); + }, + validateCronExpression(cronExpression) { + return request('/crons/validate-expression', { + method: 'POST', + body: JSON.stringify({ cronExpression }) + }); + }, + + // ── 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-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..6ab0c9c --- /dev/null +++ b/frontend/src/components/AudiobookConfigPanel.jsx @@ -0,0 +1,406 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Dialog } from 'primereact/dialog'; +import { Dropdown } from 'primereact/dropdown'; +import { Slider } from 'primereact/slider'; +import { Button } from 'primereact/button'; +import { ProgressBar } from 'primereact/progressbar'; +import { Tag } from 'primereact/tag'; +import { InputText } from 'primereact/inputtext'; +import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas'; +import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; + +function normalizeJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeFormat(value) { + const raw = String(value || '').trim().toLowerCase(); + return AUDIOBOOK_FORMATS.some((entry) => entry.value === raw) ? raw : 'mp3'; +} + +function isFieldVisible(field, values) { + if (!field?.showWhen) { + return true; + } + return values?.[field.showWhen.field] === field.showWhen.value; +} + +function buildFormatOptions(format, existingOptions = {}) { + return { + ...getDefaultAudiobookFormatOptions(format), + ...(existingOptions && typeof existingOptions === 'object' ? existingOptions : {}) + }; +} + +function formatChapterTime(secondsValue) { + const totalSeconds = Number(secondsValue || 0); + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) { + return '-'; + } + const rounded = Math.max(0, Math.round(totalSeconds)); + const hours = Math.floor(rounded / 3600); + const minutes = Math.floor((rounded % 3600) / 60); + const seconds = rounded % 60; + if (hours > 0) { + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function 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 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, + onRetry, + 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); + + useEffect(() => { + const nextFormat = normalizeFormat(audiobookConfig?.format); + setFormat(nextFormat); + setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions)); + }, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]); + + useEffect(() => { + setEditableChapters(normalizeEditableChapters(chapters)); + }, [jobId, JSON.stringify(chapters || [])]); + + const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3; + const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED'); + const isRunning = state === 'ENCODING'; + const isFinished = state === 'FINISHED'; + const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0; + const outputPath = String(context?.outputPath || '').trim() || null; + const isSplitOutput = format === 'mp3' || format === 'flac'; + 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] + ); + + 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} +
+
+ +
+ + + {metadata?.durationMs ? : null} + {posterUrl ? : 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. + +
+ +
+

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} + /> +
+ ))} +
+ )} +
+
+ + {isRunning ? ( +
+ + {Math.round(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''} +
+ ) : 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 statusIcon = isDone ? 'pi pi-check-circle' : isActive ? 'pi pi-spin pi-spinner' : 'pi pi-circle'; + const statusClass = isDone ? 'chapter-status-done' : isActive ? 'chapter-status-active' : 'chapter-status-pending'; + return ( +
+ + #{String(chIdx).padStart(2, '0')} + {chapter.title} +
+ ); + })} +
+
+ ) : null} + + {outputPath ? ( +
+ Ausgabe: {outputPath} +
+ ) : null} + +
+ {canStart ? ( +
+ + setDescriptionDialogVisible(false)} + > +
Keine Beschreibung vorhanden.

' }} + /> +
+
+ ); +} diff --git a/frontend/src/components/AudiobookOutputExplorer.jsx b/frontend/src/components/AudiobookOutputExplorer.jsx new file mode 100644 index 0000000..c4d5efc --- /dev/null +++ b/frontend/src/components/AudiobookOutputExplorer.jsx @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button } from 'primereact/button'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { api } from '../api/client'; + +function formatBytes(value) { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) { + return ''; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let index = 0; + let current = n; + while (current >= 1024 && index < units.length - 1) { + current /= 1024; + index += 1; + } + return `${current.toFixed(index <= 1 ? 0 : 1)} ${units[index]}`; +} + +function formatDateTime(value) { + if (!value) { + return '-'; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return '-'; + } + return parsed.toLocaleString('de-DE'); +} + +function getNodeByPath(root, targetPath) { + if (!root) { + return null; + } + if ((root.path || '') === (targetPath || '')) { + return root; + } + for (const child of (root.children || [])) { + if (child.type !== 'folder') { + continue; + } + const found = getNodeByPath(child, targetPath); + if (found) { + return found; + } + } + return null; +} + +function listChildren(node) { + if (!node || !Array.isArray(node.children)) { + return []; + } + return node.children; +} + +function buildBreadcrumb(pathValue) { + if (!pathValue) { + return []; + } + const parts = String(pathValue).split('/').filter(Boolean); + return parts.map((part, index) => ({ + name: part, + path: parts.slice(0, index + 1).join('/') + })); +} + +function filterFolderTree(node, query) { + if (!node || node.type !== 'folder') { + return null; + } + if (!query || !query.trim()) { + return node; + } + const normalized = query.toLowerCase(); + const children = (node.children || []) + .filter((child) => child.type === 'folder') + .map((child) => filterFolderTree(child, query)) + .filter(Boolean); + const nameMatches = String(node.name || '').toLowerCase().includes(normalized); + if (nameMatches || children.length > 0) { + return { ...node, children }; + } + return null; +} + +function defaultExpandedSet(tree) { + const next = new Set(['']); + const firstLevel = Array.isArray(tree?.children) ? tree.children : []; + for (const entry of firstLevel) { + if (entry?.type === 'folder' && entry?.path) { + next.add(entry.path); + } + } + return next; +} + +export default function AudiobookOutputExplorer({ refreshToken = 0 }) { + const [tree, setTree] = useState(null); + const [outputDir, setOutputDir] = useState(null); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [currentPath, setCurrentPath] = useState(''); + const [expandedFolders, setExpandedFolders] = useState(() => new Set([''])); + const [sidebarQuery, setSidebarQuery] = useState(''); + + const loadTree = useCallback(async () => { + setLoading(true); + setErrorMessage(''); + try { + const response = await api.getAudiobookOutputTree(); + const nextTree = response?.tree || null; + setTree(nextTree); + setOutputDir(response?.outputDir || null); + setExpandedFolders(defaultExpandedSet(nextTree)); + setCurrentPath(''); + } catch (error) { + setTree(null); + setErrorMessage(error?.message || 'Explorer konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadTree(); + }, [loadTree, refreshToken]); + + const navigateTo = (pathValue) => { + const normalized = String(pathValue || '').trim(); + const node = getNodeByPath(tree, normalized); + if (node && node.type === 'folder') { + setCurrentPath(normalized); + } + }; + + const toggleFolder = (pathValue) => { + const normalized = String(pathValue || '').trim(); + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(normalized)) { + next.delete(normalized); + } else { + next.add(normalized); + } + return next; + }); + }; + + const renderTreeNode = (node, depth = 0) => { + if (!node || node.type !== 'folder') { + return null; + } + const key = node.path || ''; + const isExpanded = expandedFolders.has(key); + const isCurrent = currentPath === key; + const children = Array.isArray(node.children) ? node.children.filter((entry) => entry.type === 'folder') : []; + + return ( +
+ + {isExpanded && children.map((child) => renderTreeNode(child, depth + 1))} +
+ ); + }; + + const filteredTree = useMemo(() => filterFolderTree(tree, sidebarQuery), [tree, sidebarQuery]); + const currentNode = getNodeByPath(tree, currentPath); + const currentChildren = listChildren(currentNode); + const breadcrumb = buildBreadcrumb(currentPath); + + if (loading && !tree) { + return ( +
+ +
+ ); + } + + if (!tree) { + return ( +
+ {errorMessage ? ( + {errorMessage} + ) : ( + Kein Audiobook-Output vorhanden. Ausgabepfad: {outputDir || 'nicht konfiguriert'} + )} +
+
+
+ ); + } + + return ( +
+
+
+ setSidebarQuery(event.target.value)} + className="sidebar-search" + /> +
+
+ {filteredTree ? renderTreeNode(filteredTree, 0) : Keine Ordner gefunden.} +
+
+ +
+
+
+
+ +
+
+ Name + Größe + Geändert +
+ + {currentChildren.length === 0 ? ( +
+ Keine Einträge in diesem Ordner. + + +
+ ) : ( + currentChildren.map((entry) => ( + + )) + )} +
+ +
+ + Root: {outputDir || '-'} + +
+
+ + ); +} diff --git a/frontend/src/components/AudiobookUploadPanel.jsx b/frontend/src/components/AudiobookUploadPanel.jsx new file mode 100644 index 0000000..c437f95 --- /dev/null +++ b/frontend/src/components/AudiobookUploadPanel.jsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState } from 'react'; +import { FileUpload } from 'primereact/fileupload'; +import { Button } from 'primereact/button'; +import { Tag } from 'primereact/tag'; +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); +} + +export default function AudiobookUploadPanel({ + audiobookUpload, + onAudiobookUpload, + onUploaded = null +}) { + const toastRef = useRef(null); + const fileUploadRef = useRef(null); + const [uploadFile, setUploadFile] = useState(null); + const [statusVisible, setStatusVisible] = useState(false); + + 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 fileName = String(audiobookUpload?.fileName || '').trim() + || String(uploadFile?.name || '').trim() + || null; + const statusTone = phase === 'error' + ? 'danger' + : phase === 'completed' + ? 'success' + : phase === 'processing' + ? 'info' + : phase === 'uploading' + ? 'warning' + : 'secondary'; + const statusLabel = phase === 'uploading' + ? 'Upload laeuft' + : phase === 'processing' + ? 'Server verarbeitet' + : phase === 'completed' + ? 'Bereit' + : phase === 'error' + ? 'Fehler' + : 'Inaktiv'; + + useEffect(() => { + if (phase === 'idle') { + setStatusVisible(false); + return; + } + setStatusVisible(true); + if (phase === 'completed') { + const timer = setTimeout(() => setStatusVisible(false), 5000); + return () => clearTimeout(timer); + } + return undefined; + }, [phase]); + + 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 = normalizeJobId(response?.result?.jobId); + 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) { + toastRef.current?.show({ + severity: 'error', + summary: 'Upload fehlgeschlagen', + detail: error?.message || 'Bitte Logs pruefen.', + life: 4200 + }); + } + }; + + return ( +
+ + void handleUpload()} + disabled={uploadBusy} + onSelect={(event) => setUploadFile(event.files[0] || null)} + onClear={() => setUploadFile(null)} + onRemove={() => setUploadFile(null)} + 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' }} + itemTemplate={(file, options) => ( +
+ +
+ {file.name} + {options.formatSize} +
+
+ )} + emptyTemplate={() => ( +
+ +

AAX-Datei hier ablegen

+ oder oben "Auswaehlen" klicken +
+ )} + /> + + {statusVisible ? ( +
+
+ {statusLabel} + +
+ {audiobookUpload?.statusText ? {audiobookUpload.statusText} : null} + {fileName ? ( + + Datei: {fileName} + + ) : null} +
+ + + {phase === 'processing' + ? '100% | Upload fertig, Job wird vorbereitet ...' + : totalBytes > 0 + ? `${Math.round(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}` + : `${Math.round(progress)}%`} + +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/CdMetadataDialog.jsx b/frontend/src/components/CdMetadataDialog.jsx new file mode 100644 index 0000000..67d93a9 --- /dev/null +++ b/frontend/src/components/CdMetadataDialog.jsx @@ -0,0 +1,315 @@ +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 searchResults = await onSearch(trimmedQuery); + const normalizedResults = Array.isArray(searchResults) ? searchResults : []; + await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl))); + if (searchRunRef.current !== searchRunId) { + return; + } + setResults(normalizedResults); + setSelected(null); + } finally { + if (searchRunRef.current === searchRunId) { + setSearchBusy(false); + } + } + }; + + const handleSubmit = async () => { + const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + let releaseDetails = selected; + if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') { + const fetched = await onFetchRelease(selected.mbId); + if (fetched && typeof fetched === 'object') { + releaseDetails = fetched; + } + } + + const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : []; + const releaseTracksByPosition = new Map(); + releaseTracks.forEach((track, index) => { + const parsedPosition = Number(track?.position); + const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0 + ? Math.trunc(parsedPosition) + : index + 1; + if (!releaseTracksByPosition.has(normalizedPosition)) { + releaseTracksByPosition.set(normalizedPosition, track); + } + }); + + const tracks = tocTracks.map((t, index) => { + const position = Number(t.position); + const byPosition = releaseTracksByPosition.get(position); + const byIndex = releaseTracks[index]; + return { + position, + title: normalizeTrackText( + byPosition?.title + || byIndex?.title + || trackTitles[t.position] + ) || `Track ${t.position}`, + artist: normalizeTrackText( + byPosition?.artist + || byIndex?.artist + || manualArtist.trim() + || releaseDetails?.artist + ) || null, + selected: true + }; + }); + + const payload = { + jobId: context.jobId, + title: manualTitle.trim() || context?.detectedTitle || 'Audio CD', + artist: manualArtist.trim() || null, + year: manualYear || null, + mbId: releaseDetails?.mbId || selected?.mbId || null, + coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null, + tracks + }; + + await onSubmit(payload); + }; + + const mbTitleBody = (row) => ( +
+ +
+
{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..bc3461a --- /dev/null +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -0,0 +1,1353 @@ +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 rounded = Math.round(clamped * 10) / 10; + if (Number.isInteger(rounded)) { + return `${rounded}%`; + } + return `${rounded.toFixed(1)}%`; +} + +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 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 roundedProgress = Math.round(clampedProgress * 10) / 10; + 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 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)} + /> + + {`Fortschritt: ${formatProgressLabel(roundedProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`} + + {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}
+
Format: {formatLabel}
+
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)}
+
Laufwerk: {devicePath}
+
Output-Pfad: {outputPath}
+ {lastState ?
Letzter Pipeline-State: {lastStateLabel}
: null} + {jobId ?
Job-ID: #{jobId}
: null} +
+
+
+ + {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} +
+ ); + } + + 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..f298ffc --- /dev/null +++ b/frontend/src/components/ConverterJobCard.jsx @@ -0,0 +1,1504 @@ +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 []; +} + +// ── 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(() => ({ + title: String(planMetadata?.title || job.title || job.detected_title || '').trim(), + year: Number.isFinite(Number(planMetadata?.year)) ? Math.trunc(Number(planMetadata.year)) : null, + imdbId: String(planMetadata?.imdbId || '').trim() || null, + poster: String(planMetadata?.poster || '').trim() || null + })); + + // ── 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 + } + : { + title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null, + year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null, + imdbId: String(videoMetadata?.imdbId || '').trim() || null, + poster: String(videoMetadata?.poster || '').trim() || null + }; + + 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: { + title: videoMetadata?.title || job.title || job.detected_title || '', + year: videoMetadata?.year || null, + imdbId: videoMetadata?.imdbId || null, + poster: videoMetadata?.poster || null + }, + omdbCandidates: [] + }), [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 handleOmdbSearch = async (query) => { + try { + const response = await api.searchOmdb(query); + return Array.isArray(response?.results) ? response.results : []; + } catch { return []; } + }; + + const handleOmdbSubmit = async (payload) => { + setSearchDialogBusy(true); + try { + setVideoMetadata({ + title: String(payload?.title || job.title || job.detected_title || '').trim(), + year: Number.isFinite(Number(payload?.year)) ? Math.trunc(Number(payload.year)) : null, + imdbId: String(payload?.imdbId || '').trim() || null, + poster: String(payload?.poster || '').trim() || null + }); + setMetadataDialogVisible(false); + } finally { + setSearchDialogBusy(false); + } + }; + + const handleMusicBrainzSearchDialog = async (query) => { + try { + const response = await api.searchMusicBrainz(query); + 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 + } + : { + title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null, + year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null, + imdbId: String(videoMetadata?.imdbId || '').trim() || null, + poster: String(videoMetadata?.poster || '').trim() || null + }; + + 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={handleOmdbSubmit} + onSearch={handleOmdbSearch} + 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.round(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 ( +