From 31d3e365977c34ff67bfa2fcc66976400c99361f Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 4 Mar 2026 14:18:33 +0000 Subject: [PATCH] Initial commit mit MkDocs-Dokumentation --- .github/workflows/docs.yml | 66 + .gitignore | 76 + .nvmrc | 1 + README.md | 168 +- backend/.env.example | 5 + backend/package-lock.json | 2509 ++++++++ backend/package.json | 21 + backend/src/config.js | 13 + backend/src/db/database.js | 603 ++ backend/src/db/defaultSettings.js | 463 ++ backend/src/index.js | 95 + backend/src/middleware/asyncHandler.js | 5 + backend/src/middleware/errorHandler.js | 23 + backend/src/middleware/requestLogger.js | 53 + backend/src/routes/historyRoutes.js | 154 + backend/src/routes/pipelineRoutes.js | 160 + backend/src/routes/settingsRoutes.js | 128 + backend/src/services/diskDetectionService.js | 385 ++ backend/src/services/historyService.js | 1098 ++++ backend/src/services/logPathService.js | 46 + backend/src/services/logger.js | 151 + backend/src/services/notificationService.js | 165 + backend/src/services/omdbService.js | 92 + backend/src/services/pipelineService.js | 5104 +++++++++++++++++ backend/src/services/processRunner.js | 99 + backend/src/services/settingsService.js | 710 +++ backend/src/services/websocketService.js | 65 + backend/src/utils/commandLine.js | 57 + backend/src/utils/encodePlan.js | 1017 ++++ backend/src/utils/errorMeta.js | 18 + backend/src/utils/files.js | 70 + backend/src/utils/playlistAnalysis.js | 576 ++ backend/src/utils/progressParsers.js | 72 + backend/src/utils/validators.js | 112 + db/schema.sql | 65 + deploy-ripster.sh | 39 + dev-script.sh | 1389 +++++ docs/api/history.md | 222 + docs/api/index.md | 85 + docs/api/pipeline.md | 249 + docs/api/settings.md | 140 + docs/api/websocket.md | 225 + docs/architecture/backend.md | 221 + docs/architecture/database.md | 161 + docs/architecture/frontend.md | 190 + docs/architecture/index.md | 112 + docs/architecture/overview.md | 144 + docs/configuration/environment.md | 96 + docs/configuration/index.md | 21 + docs/configuration/settings-reference.md | 138 + docs/deployment/development.md | 137 + docs/deployment/index.md | 21 + docs/deployment/production.md | 193 + docs/getting-started/configuration.md | 118 + docs/getting-started/index.md | 41 + docs/getting-started/installation.md | 140 + docs/getting-started/prerequisites.md | 158 + docs/getting-started/quickstart.md | 144 + docs/index.md | 130 + docs/pipeline/encoding.md | 159 + docs/pipeline/index.md | 31 + docs/pipeline/playlist-analysis.md | 119 + docs/pipeline/workflow.md | 222 + docs/stylesheets/extra.css | 64 + docs/tools/handbrake.md | 137 + docs/tools/index.md | 31 + docs/tools/makemkv.md | 118 + docs/tools/mediainfo.md | 108 + frontend/.env.example | 5 + frontend/index.html | 12 + frontend/package-lock.json | 1713 ++++++ frontend/package.json | 22 + frontend/public/logo.png | Bin 0 -> 286251 bytes frontend/src/App.jsx | 98 + frontend/src/api/client.js | 175 + frontend/src/assets/media-bluray.svg | 11 + frontend/src/assets/media-disc.svg | 13 + .../src/components/DiscDetectedDialog.jsx | 39 + .../src/components/DynamicSettingsForm.jsx | 224 + frontend/src/components/JobDetailDialog.jsx | 230 + .../src/components/MediaInfoReviewPanel.jsx | 827 +++ .../components/MetadataSelectionDialog.jsx | 172 + .../src/components/PipelineStatusCard.jsx | 598 ++ frontend/src/hooks/useWebSocket.js | 62 + frontend/src/main.jsx | 21 + frontend/src/pages/DashboardPage.jsx | 629 ++ frontend/src/pages/DatabasePage.jsx | 557 ++ frontend/src/pages/HistoryPage.jsx | 197 + frontend/src/pages/SettingsPage.jsx | 204 + frontend/src/styles/app.css | 974 ++++ frontend/vite.config.js | 51 + kill.sh | 133 + mkdocs.yml | 124 + package-lock.json | 304 + package.json | 15 + requirements-docs.txt | 4 + start.sh | 157 + 97 files changed, 27518 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 backend/.env.example create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/src/config.js create mode 100644 backend/src/db/database.js create mode 100644 backend/src/db/defaultSettings.js create mode 100644 backend/src/index.js create mode 100644 backend/src/middleware/asyncHandler.js create mode 100644 backend/src/middleware/errorHandler.js create mode 100644 backend/src/middleware/requestLogger.js create mode 100644 backend/src/routes/historyRoutes.js create mode 100644 backend/src/routes/pipelineRoutes.js create mode 100644 backend/src/routes/settingsRoutes.js create mode 100644 backend/src/services/diskDetectionService.js create mode 100644 backend/src/services/historyService.js create mode 100644 backend/src/services/logPathService.js create mode 100644 backend/src/services/logger.js create mode 100644 backend/src/services/notificationService.js create mode 100644 backend/src/services/omdbService.js create mode 100644 backend/src/services/pipelineService.js create mode 100644 backend/src/services/processRunner.js create mode 100644 backend/src/services/settingsService.js create mode 100644 backend/src/services/websocketService.js create mode 100644 backend/src/utils/commandLine.js create mode 100644 backend/src/utils/encodePlan.js create mode 100644 backend/src/utils/errorMeta.js create mode 100644 backend/src/utils/files.js create mode 100644 backend/src/utils/playlistAnalysis.js create mode 100644 backend/src/utils/progressParsers.js create mode 100644 backend/src/utils/validators.js create mode 100644 db/schema.sql create mode 100755 deploy-ripster.sh create mode 100755 dev-script.sh create mode 100644 docs/api/history.md create mode 100644 docs/api/index.md create mode 100644 docs/api/pipeline.md create mode 100644 docs/api/settings.md create mode 100644 docs/api/websocket.md create mode 100644 docs/architecture/backend.md create mode 100644 docs/architecture/database.md create mode 100644 docs/architecture/frontend.md create mode 100644 docs/architecture/index.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/configuration/environment.md create mode 100644 docs/configuration/index.md create mode 100644 docs/configuration/settings-reference.md create mode 100644 docs/deployment/development.md create mode 100644 docs/deployment/index.md create mode 100644 docs/deployment/production.md create mode 100644 docs/getting-started/configuration.md create mode 100644 docs/getting-started/index.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/getting-started/prerequisites.md create mode 100644 docs/getting-started/quickstart.md create mode 100644 docs/index.md create mode 100644 docs/pipeline/encoding.md create mode 100644 docs/pipeline/index.md create mode 100644 docs/pipeline/playlist-analysis.md create mode 100644 docs/pipeline/workflow.md create mode 100644 docs/stylesheets/extra.css create mode 100644 docs/tools/handbrake.md create mode 100644 docs/tools/index.md create mode 100644 docs/tools/makemkv.md create mode 100644 docs/tools/mediainfo.md create mode 100644 frontend/.env.example create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/logo.png create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/assets/media-bluray.svg create mode 100644 frontend/src/assets/media-disc.svg create mode 100644 frontend/src/components/DiscDetectedDialog.jsx create mode 100644 frontend/src/components/DynamicSettingsForm.jsx create mode 100644 frontend/src/components/JobDetailDialog.jsx create mode 100644 frontend/src/components/MediaInfoReviewPanel.jsx create mode 100644 frontend/src/components/MetadataSelectionDialog.jsx create mode 100644 frontend/src/components/PipelineStatusCard.jsx create mode 100644 frontend/src/hooks/useWebSocket.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/DashboardPage.jsx create mode 100644 frontend/src/pages/DatabasePage.jsx create mode 100644 frontend/src/pages/HistoryPage.jsx create mode 100644 frontend/src/pages/SettingsPage.jsx create mode 100644 frontend/src/styles/app.css create mode 100644 frontend/vite.config.js create mode 100755 kill.sh create mode 100644 mkdocs.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 requirements-docs.txt create mode 100755 start.sh diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..6192b56 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,66 @@ +name: Deploy Docs to GitHub Pages + +on: + push: + branches: + - main + paths: + - 'docs/**' + - 'mkdocs.yml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + name: Build Documentation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Für git-dates Plugin + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Cache pip packages + uses: actions/cache@v4 + with: + key: mkdocs-material-${{ runner.os }}-${{ hashFiles('requirements-docs.txt') }} + path: ~/.cache/pip + restore-keys: | + mkdocs-material-${{ runner.os }}- + + - name: Install MkDocs and dependencies + run: pip install -r requirements-docs.txt + + - name: Build MkDocs site + run: mkdocs build --strict --verbose + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site/ + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bdae91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# ---------------------------- +# 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/ +# ---------------------------- +# 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/ 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/README.md b/README.md index c56094a..f77e5b5 100644 --- a/README.md +++ b/README.md @@ -1 +1,167 @@ -# ripster +# Ripster + +Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit MakeMKV + HandBrake inklusive Metadaten-Auswahl, Titel-/Spurprüfung und Job-Historie. + +## Was Ripster kann + +- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket) +- Metadaten-Suche und Zuordnung über OMDb +- MakeMKV-Analyse und Rip (MKV oder Backup-Modus) +- HandBrake-Encode mit Preset + Extra-Args + Track-Override +- Manuelle Playlist-/Titel-Auswahl bei komplexen Blu-rays +- Historie mit Re-Encode, Löschfunktionen und Detailansicht +- Dateibasierte Logs (Backend + Job-Prozesslogs) + +## Tech-Stack + +- Backend: Node.js, Express, SQLite, WebSocket (`ws`) +- Frontend: React, Vite, PrimeReact +- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo` + +## Voraussetzungen + +- Linux-System mit optischem Laufwerk (oder gemountete Quelle) +- Node.js `>= 20.19.0` (siehe [.nvmrc](.nvmrc)) +- Installierte CLI-Tools im `PATH`: + - `makemkvcon` + - `HandBrakeCLI` + - `mediainfo` + +## Schnellstart + +```bash +./start.sh +``` + +`start.sh` erledigt: + +1. Node-Version prüfen/umschalten (inkl. `nvm`/`node@20`-Fallback) +2. Dependencies installieren (Root, Backend, Frontend) +3. Dev-Umgebung starten (`backend` + `frontend`) + +Danach: + +- Frontend: `http://localhost:5173` +- Backend API: `http://localhost:3001/api` + +Stoppen: + +```bash +./kill.sh +``` + +## Manueller Start + +```bash +npm install +npm --prefix backend install +npm --prefix frontend install +npm run dev +``` + +Einzeln starten: + +```bash +npm run dev:backend +npm run dev:frontend +``` + +Frontend Build: + +```bash +npm run build:frontend +``` + +Backend (ohne Dev-Mode): + +```bash +npm run start +``` + +## Konfiguration + +### 1) UI-Settings (empfohlen) + +Die meisten Einstellungen werden in der App unter `Settings` gepflegt und in SQLite gespeichert: + +- Pfade: `raw_dir`, `movie_dir`, `log_dir` +- Tools: `makemkv_command`, `handbrake_command`, `mediainfo_command` +- Encode: `handbrake_preset`, `handbrake_extra_args`, `output_extension`, `filename_template` +- Laufwerk/Scan: `drive_mode`, `drive_device`, Polling +- Benachrichtigungen: PushOver + +### 2) Umgebungsvariablen + +Backend (`backend/src/config.js`): + +- `PORT` (Default: `3001`) +- `DB_PATH` (Default: `backend/data/ripster.db`) +- `LOG_DIR` (Fallback-Logpfad, 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) +- `VITE_PUBLIC_ORIGIN`, `VITE_ALLOWED_HOSTS`, `VITE_HMR_*` (Remote-Dev/HMR) + +## 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` (inkl. Job-/Settings-Daten) + +Hinweis: Beim DB-Init wird das Schema gegen die Soll-Struktur abgeglichen und migriert. + +## Projektstruktur + +```text +ripster/ + backend/ + src/ + routes/ + services/ + db/ + utils/ + frontend/ + src/ + pages/ + components/ + api/ + start.sh + kill.sh + deploy-ripster.sh +``` + +## API-Überblick + +- `GET /api/pipeline/state` +- `POST /api/pipeline/analyze` +- `POST /api/pipeline/start/:jobId` +- `POST /api/pipeline/confirm-encode/:jobId` +- `GET /api/history` +- `GET /api/history/:id` +- `GET /api/settings` +- `PUT /api/settings` + +## Troubleshooting + +- WebSocket verbindet nicht: + - prüfen, ob Frontend über Vite-Proxy läuft (`/ws` -> Backend) + - bei Reverse-Proxy `VITE_PUBLIC_ORIGIN`/HMR korrekt setzen +- Keine Disc erkannt: + - `drive_mode=explicit` testen und `drive_device` setzen (z. B. `/dev/sr0`) +- HandBrake/MakeMKV Fehler: + - CLI-Binaries im `PATH` prüfen + - Preset-Name exakt wie in `HandBrakeCLI -z` hinterlegen +- Startfehler wegen Schema: + - sicherstellen, dass die erwartete Schema-Datei vorhanden ist (`db/schema.sql`) + +## 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..3156f23 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,5 @@ +PORT=3001 +DB_PATH=./data/ripster.db +CORS_ORIGIN=http://localhost:5173 +LOG_DIR=./logs +LOG_LEVEL=debug diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..9578e5e --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2509 @@ +{ + "name": "ripster-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ripster-backend", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7", + "ws": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } + }, + "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/@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/@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/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==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "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/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/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/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/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/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-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/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/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/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/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/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==", + "optional": true + }, + "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/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/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/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==", + "optional": true + }, + "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==", + "optional": true, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true + }, + "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/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==", + "dev": true, + "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/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-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/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/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/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/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==", + "optional": true, + "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==", + "optional": true, + "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/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/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==", + "optional": true, + "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/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==" + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..59c56e9 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "ripster-backend", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7", + "ws": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } +} diff --git a/backend/src/config.js b/backend/src/config.js new file mode 100644 index 0000000..c8adce4 --- /dev/null +++ b/backend/src/config.js @@ -0,0 +1,13 @@ +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'); + +module.exports = { + port: process.env.PORT ? Number(process.env.PORT) : 3001, + dbPath: path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath), + corsOrigin: process.env.CORS_ORIGIN || '*', + logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir), + logLevel: process.env.LOG_LEVEL || 'info' +}; diff --git a/backend/src/db/database.js b/backend/src/db/database.js new file mode 100644 index 0000000..5777d92 --- /dev/null +++ b/backend/src/db/database.js @@ -0,0 +1,603 @@ +const fs = require('fs'); +const path = require('path'); +const sqlite3 = require('sqlite3'); +const { open } = require('sqlite'); +const { dbPath } = require('../config'); +const { defaultSchema } = require('./defaultSettings'); +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'); + +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 seedDefaultSettings(dbInstance); + await removeDeprecatedSettings(dbInstance); + await ensurePipelineStateRow(dbInstance); + const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true }); + logger.info('log-root:synced', { + configured: syncedLogRoot.configured || null, + resolved: syncedLogRoot.resolved + }); + logger.info('init:done'); + return dbInstance; +} + +async function initDatabase({ allowRecovery = true } = {}) { + if (dbInstance) { + return dbInstance; + } + + try { + return await openAndPrepareDatabase(); + } catch (error) { + logger.error('init:failed', { error: errorToMeta(error), allowRecovery }); + + if (dbInstance) { + try { + await dbInstance.close(); + } catch (_closeError) { + // ignore close errors during failed init + } + dbInstance = undefined; + } + + if (allowRecovery && isSqliteCorruptionError(error)) { + logger.warn('recovery:corrupt-db-detected', { dbPath }); + quarantineCorruptDatabaseFiles(); + return initDatabase({ allowRecovery: false }); + } + + throw error; + } + +} + +async function seedDefaultSettings(db) { + let seeded = 0; + for (const item of defaultSchema) { + await db.run( + ` + INSERT INTO settings_schema + (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + category = excluded.category, + label = excluded.label, + type = excluded.type, + required = excluded.required, + description = excluded.description, + default_value = COALESCE(settings_schema.default_value, excluded.default_value), + options_json = excluded.options_json, + validation_json = excluded.validation_json, + order_index = excluded.order_index, + updated_at = CURRENT_TIMESTAMP + `, + [ + item.key, + item.category, + item.label, + item.type, + item.required, + item.description || null, + item.defaultValue || null, + JSON.stringify(item.options || []), + JSON.stringify(item.validation || {}), + item.orderIndex || 0 + ] + ); + + await db.run( + ` + INSERT INTO settings_values (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO NOTHING + `, + [item.key, item.defaultValue || null] + ); + seeded += 1; + } + logger.info('seed:settings', { count: seeded }); +} + +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 removeDeprecatedSettings(db) { + const deprecatedKeys = ['pushover_notify_disc_detected']; + for (const key of deprecatedKeys) { + const result = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]); + if (result?.changes > 0) { + logger.info('migrate:remove-deprecated-setting', { key }); + } + } +} + +async function getDb() { + return initDatabase(); +} + +module.exports = { + initDatabase, + getDb +}; diff --git a/backend/src/db/defaultSettings.js b/backend/src/db/defaultSettings.js new file mode 100644 index 0000000..4e24ad1 --- /dev/null +++ b/backend/src/db/defaultSettings.js @@ -0,0 +1,463 @@ +const defaultSchema = [ + { + key: 'drive_mode', + category: 'Laufwerk', + label: 'Laufwerksmodus', + type: 'select', + required: 1, + description: 'Auto-Discovery oder explizites Device.', + defaultValue: 'auto', + options: [ + { label: 'Auto Discovery', value: 'auto' }, + { label: 'Explizites Device', value: 'explicit' } + ], + validation: {}, + orderIndex: 10 + }, + { + key: 'drive_device', + category: 'Laufwerk', + label: 'Device Pfad', + type: 'path', + required: 0, + description: 'Nur für expliziten Modus, z.B. /dev/sr0.', + defaultValue: '/dev/sr0', + options: [], + validation: {}, + orderIndex: 20 + }, + { + key: 'makemkv_source_index', + category: 'Laufwerk', + label: 'MakeMKV Source Index', + type: 'number', + required: 1, + description: 'Disc Index im Auto-Modus.', + defaultValue: '0', + options: [], + validation: { min: 0, max: 20 }, + orderIndex: 30 + }, + { + key: 'disc_poll_interval_ms', + category: 'Laufwerk', + label: 'Polling Intervall (ms)', + type: 'number', + required: 1, + description: 'Intervall für Disk-Erkennung.', + defaultValue: '4000', + options: [], + validation: { min: 1000, max: 60000 }, + orderIndex: 40 + }, + { + key: 'raw_dir', + category: 'Pfade', + label: 'Raw Ausgabeordner', + type: 'path', + required: 1, + description: 'Zwischenablage für MakeMKV Rip.', + defaultValue: '/mnt/arm-storage/media/raw', + options: [], + validation: { minLength: 1 }, + orderIndex: 100 + }, + { + key: 'movie_dir', + category: 'Pfade', + label: 'Film Ausgabeordner', + type: 'path', + required: 1, + description: 'Finale HandBrake Ausgabe.', + defaultValue: '/mnt/arm-storage/media/movies', + options: [], + validation: { minLength: 1 }, + orderIndex: 110 + }, + { + key: 'log_dir', + category: 'Pfade', + label: 'Log Ordner', + type: 'path', + required: 1, + description: 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', + defaultValue: '/mnt/arm-storage/logs', + options: [], + validation: { minLength: 1 }, + orderIndex: 120 + }, + { + key: 'makemkv_command', + category: 'Tools', + label: 'MakeMKV Kommando', + type: 'string', + required: 1, + description: 'Pfad oder Befehl für makemkvcon.', + defaultValue: 'makemkvcon', + options: [], + validation: { minLength: 1 }, + orderIndex: 200 + }, + { + key: 'makemkv_registration_key', + category: 'Tools', + label: 'MakeMKV Key', + type: 'string', + required: 0, + description: 'Optionaler Registrierungsschlüssel. Wird vor Analyze/Rip automatisch per "makemkvcon reg" gesetzt.', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 202 + }, + { + key: 'mediainfo_command', + category: 'Tools', + label: 'Mediainfo Kommando', + type: 'string', + required: 1, + description: 'Pfad oder Befehl für mediainfo.', + defaultValue: 'mediainfo', + options: [], + validation: { minLength: 1 }, + orderIndex: 205 + }, + { + key: 'mediainfo_extra_args', + category: 'Tools', + label: 'Mediainfo Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für mediainfo.', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 206 + }, + { + key: 'makemkv_min_length_minutes', + category: 'Tools', + label: 'Minimale Titellänge (Minuten)', + type: 'number', + required: 1, + description: 'Filtert kurze Titel beim Rip.', + defaultValue: '60', + options: [], + validation: { min: 1, max: 1000 }, + orderIndex: 210 + }, + { + key: 'makemkv_rip_mode', + category: 'Tools', + label: 'MakeMKV Rip Modus', + type: 'select', + required: 1, + description: 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.', + defaultValue: 'backup', + options: [ + { label: 'MKV', value: 'mkv' }, + { label: 'Backup', value: 'backup' } + ], + validation: {}, + orderIndex: 212 + }, + { + key: 'makemkv_analyze_extra_args', + category: 'Tools', + label: 'MakeMKV Analyze Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für Analyze.', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 220 + }, + { + key: 'makemkv_rip_extra_args', + category: 'Tools', + label: 'MakeMKV Rip Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Parameter für Rip.', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 230 + }, + { + key: 'handbrake_command', + category: 'Tools', + label: 'HandBrake Kommando', + type: 'string', + required: 1, + description: 'Pfad oder Befehl für HandBrakeCLI.', + defaultValue: 'HandBrakeCLI', + options: [], + validation: { minLength: 1 }, + orderIndex: 300 + }, + { + key: 'handbrake_preset', + category: 'Tools', + label: 'HandBrake Preset', + type: 'string', + required: 1, + description: 'Preset Name für -Z.', + defaultValue: 'H.264 MKV 1080p30', + options: [], + validation: { minLength: 1 }, + orderIndex: 310 + }, + { + key: 'handbrake_extra_args', + category: 'Tools', + label: 'HandBrake Extra Args', + type: 'string', + required: 0, + description: 'Zusätzliche CLI-Argumente.', + defaultValue: '--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --audio-fallback ac3 --encoder-preset slow --quality 18 --encoder-tune film --encoder-profile high --encoder-level 4.1', + options: [], + validation: {}, + orderIndex: 320 + }, + { + key: 'output_extension', + category: 'Tools', + label: 'Ausgabeformat', + type: 'select', + required: 1, + description: 'Dateiendung für finale Datei.', + defaultValue: 'mkv', + options: [ + { label: 'MKV', value: 'mkv' }, + { label: 'MP4', value: 'mp4' } + ], + validation: {}, + orderIndex: 330 + }, + { + key: 'filename_template', + category: 'Tools', + label: 'Dateiname Template', + type: 'string', + required: 1, + description: 'Verfügbare Tokens: ${title}, ${year}, ${imdbId}.', + defaultValue: '${title} (${year})', + options: [], + validation: { minLength: 1 }, + orderIndex: 340 + }, + { + key: 'omdb_api_key', + category: 'Metadaten', + label: 'OMDb API Key', + type: 'string', + required: 0, + description: 'API Key für Metadatensuche.', + defaultValue: '186322c4', + options: [], + validation: {}, + orderIndex: 400 + }, + { + key: 'omdb_default_type', + category: 'Metadaten', + label: 'OMDb Typ', + type: 'select', + required: 1, + description: 'Vorauswahl für Suche.', + defaultValue: 'movie', + options: [ + { label: 'Movie', value: 'movie' }, + { label: 'Series', value: 'series' }, + { label: 'Episode', value: 'episode' } + ], + validation: {}, + orderIndex: 410 + }, + { + key: 'pushover_enabled', + category: 'Benachrichtigungen', + label: 'PushOver aktiviert', + type: 'boolean', + required: 1, + description: 'Master-Schalter für PushOver Versand.', + defaultValue: 'false', + options: [], + validation: {}, + orderIndex: 500 + }, + { + key: 'pushover_token', + category: 'Benachrichtigungen', + label: 'PushOver Token', + type: 'string', + required: 0, + description: 'Application Token für PushOver.', + defaultValue: 'a476diddeew53w8fi4kv88n6ghbfqq', + options: [], + validation: {}, + orderIndex: 510 + }, + { + key: 'pushover_user', + category: 'Benachrichtigungen', + label: 'PushOver User', + type: 'string', + required: 0, + description: 'User-Key für PushOver.', + defaultValue: 'u47227hupodan28a629az1k43644jg', + options: [], + validation: {}, + orderIndex: 520 + }, + { + key: 'pushover_device', + category: 'Benachrichtigungen', + label: 'PushOver Device (optional)', + type: 'string', + required: 0, + description: 'Optionales Ziel-Device in PushOver.', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 530 + }, + { + key: 'pushover_title_prefix', + category: 'Benachrichtigungen', + label: 'PushOver Titel-Präfix', + type: 'string', + required: 1, + description: 'Prefix im PushOver Titel.', + defaultValue: 'Ripster', + options: [], + validation: { minLength: 1 }, + orderIndex: 540 + }, + { + key: 'pushover_priority', + category: 'Benachrichtigungen', + label: 'PushOver Priority', + type: 'number', + required: 1, + description: 'Priorität -2 bis 2.', + defaultValue: '0', + options: [], + validation: { min: -2, max: 2 }, + orderIndex: 550 + }, + { + key: 'pushover_timeout_ms', + category: 'Benachrichtigungen', + label: 'PushOver Timeout (ms)', + type: 'number', + required: 1, + description: 'HTTP Timeout für PushOver Requests.', + defaultValue: '7000', + options: [], + validation: { min: 1000, max: 60000 }, + orderIndex: 560 + }, + { + key: 'pushover_notify_metadata_ready', + category: 'Benachrichtigungen', + label: 'Bei Metadaten-Auswahl senden', + type: 'boolean', + required: 1, + description: 'Sendet wenn Metadaten zur Auswahl bereitstehen.', + defaultValue: 'true', + options: [], + validation: {}, + orderIndex: 570 + }, + { + key: 'pushover_notify_rip_started', + category: 'Benachrichtigungen', + label: 'Bei Rip-Start senden', + type: 'boolean', + required: 1, + description: 'Sendet beim Start des MakeMKV-Rips.', + defaultValue: 'true', + options: [], + validation: {}, + orderIndex: 580 + }, + { + key: 'pushover_notify_encoding_started', + category: 'Benachrichtigungen', + label: 'Bei Encode-Start senden', + type: 'boolean', + required: 1, + description: 'Sendet beim Start von HandBrake.', + defaultValue: 'true', + options: [], + validation: {}, + orderIndex: 590 + }, + { + key: 'pushover_notify_job_finished', + category: 'Benachrichtigungen', + label: 'Bei Erfolg senden', + type: 'boolean', + required: 1, + description: 'Sendet bei erfolgreich abgeschlossenem Job.', + defaultValue: 'true', + options: [], + validation: {}, + orderIndex: 600 + }, + { + key: 'pushover_notify_job_error', + category: 'Benachrichtigungen', + label: 'Bei Fehler senden', + type: 'boolean', + required: 1, + description: 'Sendet bei Fehlern in der Pipeline.', + defaultValue: 'true', + options: [], + validation: {}, + orderIndex: 610 + }, + { + key: 'pushover_notify_job_cancelled', + category: 'Benachrichtigungen', + label: 'Bei Abbruch senden', + type: 'boolean', + required: 1, + description: 'Sendet wenn Job manuell abgebrochen wurde.', + defaultValue: 'true', + options: [], + validation: {}, + orderIndex: 620 + }, + { + key: 'pushover_notify_reencode_started', + category: 'Benachrichtigungen', + label: 'Bei Re-Encode Start senden', + type: 'boolean', + required: 1, + description: 'Sendet beim Start von RAW Re-Encode.', + defaultValue: 'true', + options: [], + validation: {}, + orderIndex: 630 + }, + { + key: 'pushover_notify_reencode_finished', + category: 'Benachrichtigungen', + label: 'Bei Re-Encode Erfolg senden', + type: 'boolean', + required: 1, + description: 'Sendet bei erfolgreichem RAW Re-Encode.', + defaultValue: 'true', + options: [], + validation: {}, + orderIndex: 640 + } +]; + +module.exports = { + defaultSchema +}; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..bfccfd2 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,95 @@ +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 wsService = require('./services/websocketService'); +const pipelineService = require('./services/pipelineService'); +const diskDetectionService = require('./services/diskDetectionService'); +const logger = require('./services/logger').child('BOOT'); +const { errorToMeta } = require('./utils/errorMeta'); + +async function start() { + logger.info('backend:start:init'); + await initDatabase(); + await pipelineService.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(errorHandler); + + const server = http.createServer(app); + wsService.init(server); + + 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 }); + }); + + const shutdown = () => { + logger.warn('backend:shutdown:received'); + diskDetectionService.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/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js new file mode 100644 index 0000000..2abe40c --- /dev/null +++ b/backend/src/routes/historyRoutes.js @@ -0,0 +1,154 @@ +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) => { + logger.info('get:jobs', { + reqId: req.reqId, + status: req.query.status, + search: req.query.search + }); + + const jobs = await historyService.getJobs({ + status: req.query.status, + search: req.query.search + }); + + res.json({ jobs }); + }) +); + +router.get( + '/database', + asyncHandler(async (req, res) => { + logger.info('get:database', { + reqId: req.reqId, + status: req.query.status, + search: req.query.search + }); + + const rows = await historyService.getDatabaseRows({ + status: req.query.status, + search: req.query.search + }); + + res.json({ rows }); + }) +); + +router.get( + '/orphan-raw', + asyncHandler(async (req, res) => { + logger.info('get:orphan-raw', { reqId: req.reqId }); + const result = await historyService.getOrphanRawFolders(); + res.json(result); + }) +); + +router.post( + '/orphan-raw/import', + asyncHandler(async (req, res) => { + const rawPath = String(req.body?.rawPath || '').trim(); + logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath }); + const job = await historyService.importOrphanRawFolder(rawPath); + const uiReset = await pipelineService.resetFrontendState('history_orphan_import'); + res.json({ job, uiReset }); + }) +); + +router.post( + '/:id/omdb/assign', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const payload = req.body || {}; + logger.info('post:job:omdb:assign', { + reqId: req.reqId, + id, + imdbId: payload?.imdbId || null, + hasTitle: Boolean(payload?.title), + hasYear: Boolean(payload?.year) + }); + + const job = await historyService.assignOmdbMetadata(id, payload); + 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.post( + '/:id/delete', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const target = String(req.body?.target || 'none'); + + logger.warn('post:delete-job', { + reqId: req.reqId, + id, + target + }); + + const result = await historyService.deleteJob(id, target); + const uiReset = await pipelineService.resetFrontendState('history_delete'); + res.json({ ...result, uiReset }); + }) +); + +router.get( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase()); + const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase()); + const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase()); + const parsedTail = Number(req.query.logTailLines); + const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 + ? Math.trunc(parsedTail) + : null; + + logger.info('get:job-detail', { + reqId: req.reqId, + id, + includeLiveLog, + includeLogs, + includeAllLogs, + logTailLines + }); + const job = await historyService.getJobWithLogs(id, { + includeLiveLog, + includeLogs, + includeAllLogs, + logTailLines + }); + 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..033fbd1 --- /dev/null +++ b/backend/src/routes/pipelineRoutes.js @@ -0,0 +1,160 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const pipelineService = require('../services/pipelineService'); +const diskDetectionService = require('../services/diskDetectionService'); +const logger = require('../services/logger').child('PIPELINE_ROUTE'); + +const router = express.Router(); + +router.get( + '/state', + asyncHandler(async (req, res) => { + logger.debug('get:state', { reqId: req.reqId }); + res.json({ pipeline: pipelineService.getSnapshot() }); + }) +); + +router.post( + '/analyze', + asyncHandler(async (req, res) => { + logger.info('post:analyze', { reqId: req.reqId }); + const result = await pipelineService.analyzeDisc(); + res.json({ result }); + }) +); + +router.post( + '/rescan-disc', + asyncHandler(async (req, res) => { + logger.info('post:rescan-disc', { reqId: req.reqId }); + const result = await diskDetectionService.rescanAndEmit(); + res.json({ result }); + }) +); + +router.get( + '/omdb/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + logger.info('get:omdb:search', { reqId: req.reqId, query }); + const results = await pipelineService.searchOmdb(String(query)); + res.json({ results }); + }) +); + +router.post( + '/select-metadata', + asyncHandler(async (req, res) => { + const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist } = req.body; + + if (!jobId) { + const error = new Error('jobId fehlt.'); + error.statusCode = 400; + throw error; + } + + logger.info('post:select-metadata', { + reqId: req.reqId, + jobId, + title, + year, + imdbId, + poster, + fromOmdb, + selectedPlaylist + }); + + const job = await pipelineService.selectMetadata({ + jobId: Number(jobId), + title, + year, + imdbId, + poster, + fromOmdb, + selectedPlaylist + }); + + res.json({ job }); + }) +); + +router.post( + '/start/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:start-job', { reqId: req.reqId, jobId }); + const result = await pipelineService.startPreparedJob(jobId); + res.json({ result }); + }) +); + +router.post( + '/confirm-encode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null; + const selectedTrackSelection = req.body?.selectedTrackSelection ?? null; + logger.info('post:confirm-encode', { + reqId: req.reqId, + jobId, + selectedEncodeTitleId, + selectedTrackSelectionProvided: Boolean(selectedTrackSelection) + }); + const job = await pipelineService.confirmEncodeReview(jobId, { + selectedEncodeTitleId, + selectedTrackSelection + }); + res.json({ job }); + }) +); + +router.post( + '/cancel', + asyncHandler(async (req, res) => { + logger.warn('post:cancel', { reqId: req.reqId }); + await pipelineService.cancel(); + res.json({ ok: true }); + }) +); + +router.post( + '/retry/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:retry', { reqId: req.reqId, jobId }); + await pipelineService.retry(jobId); + res.json({ ok: true }); + }) +); + +router.post( + '/resume-ready/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:resume-ready', { reqId: req.reqId, jobId }); + const job = await pipelineService.resumeReadyToEncodeJob(jobId); + res.json({ job }); + }) +); + +router.post( + '/reencode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:reencode', { reqId: req.reqId, jobId }); + const result = await pipelineService.reencodeFromRaw(jobId); + res.json({ result }); + }) +); + +router.post( + '/restart-encode/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + logger.info('post:restart-encode', { reqId: req.reqId, jobId }); + const result = await pipelineService.restartEncodeWithLastSettings(jobId); + res.json({ result }); + }) +); + +module.exports = router; diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js new file mode 100644 index 0000000..4c3c79e --- /dev/null +++ b/backend/src/routes/settingsRoutes.js @@ -0,0 +1,128 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const settingsService = require('../services/settingsService'); +const notificationService = require('../services/notificationService'); +const pipelineService = require('../services/pipelineService'); +const wsService = require('../services/websocketService'); +const logger = require('../services/logger').child('SETTINGS_ROUTE'); + +const router = express.Router(); + +function isSensitiveSettingKey(key) { + const normalized = String(key || '').trim().toLowerCase(); + if (!normalized) { + return false; + } + return /(token|password|secret|api_key|registration_key|pushover_user)/i.test(normalized); +} + +router.get( + '/', + asyncHandler(async (req, res) => { + logger.debug('get:settings', { reqId: req.reqId }); + const categories = await settingsService.getCategorizedSettings(); + res.json({ categories }); + }) +); + +router.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' + }; + } + 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' + }; + } + wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) }); + + res.json({ changes, reviewRefresh }); + }) +); + +router.post( + '/pushover/test', + asyncHandler(async (req, res) => { + const title = req.body?.title; + const message = req.body?.message; + logger.info('post:pushover:test', { + reqId: req.reqId, + hasTitle: Boolean(title), + hasMessage: Boolean(message) + }); + const result = await notificationService.sendTest({ title, message }); + res.json({ result }); + }) +); + +module.exports = router; diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js new file mode 100644 index 0000000..adf35a3 --- /dev/null +++ b/backend/src/services/diskDetectionService.js @@ -0,0 +1,385 @@ +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 { errorToMeta } = require('../utils/errorMeta'); + +const execFileAsync = promisify(execFile); + +function flattenDevices(nodes, acc = []) { + for (const node of nodes || []) { + acc.push(node); + if (Array.isArray(node.children)) { + flattenDevices(node.children, acc); + } + } + + return acc; +} + +function buildSignature(info) { + return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}`; +} + +class DiskDetectionService extends EventEmitter { + constructor() { + super(); + this.running = false; + this.timer = null; + this.lastDetected = null; + this.lastPresent = false; + this.deviceLocks = new Map(); + } + + start() { + if (this.running) { + return; + } + this.running = true; + logger.info('start'); + this.scheduleNext(1000); + } + + stop() { + this.running = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + logger.info('stop'); + } + + scheduleNext(delayMs) { + if (!this.running) { + return; + } + + this.timer = setTimeout(async () => { + let nextDelay = 4000; + + try { + const map = await settingsService.getSettingsMap(); + nextDelay = Number(map.disc_poll_interval_ms || 4000); + logger.debug('poll:tick', { + driveMode: map.drive_mode, + driveDevice: map.drive_device, + nextDelay + }); + const detected = await this.detectDisc(map); + this.applyDetectionResult(detected, { forceInsertEvent: false }); + } catch (error) { + logger.error('poll:error', { error: errorToMeta(error) }); + this.emit('error', error); + } + + this.scheduleNext(nextDelay); + }, delayMs); + } + + async rescanAndEmit() { + try { + const map = await settingsService.getSettingsMap(); + logger.info('rescan:requested', { + driveMode: map.drive_mode, + driveDevice: map.drive_device + }); + + const detected = await this.detectDisc(map); + const result = this.applyDetectionResult(detected, { forceInsertEvent: true }); + + logger.info('rescan:done', { + present: result.present, + emitted: result.emitted, + changed: result.changed, + detected: result.device || null + }); + + return result; + } catch (error) { + logger.error('rescan:error', { error: errorToMeta(error) }); + throw error; + } + } + + normalizeDevicePath(devicePath) { + return String(devicePath || '').trim(); + } + + lockDevice(devicePath, owner = null) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return null; + } + + const entry = this.deviceLocks.get(normalized) || { + count: 0, + owners: [] + }; + + entry.count += 1; + if (owner) { + entry.owners.push(owner); + } + this.deviceLocks.set(normalized, entry); + + logger.info('lock:add', { + devicePath: normalized, + count: entry.count, + owner + }); + + return { + devicePath: normalized, + owner + }; + } + + unlockDevice(devicePath, owner = null) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return; + } + + const entry = this.deviceLocks.get(normalized); + if (!entry) { + return; + } + + entry.count = Math.max(0, entry.count - 1); + if (entry.count === 0) { + this.deviceLocks.delete(normalized); + logger.info('lock:remove', { + devicePath: normalized, + owner + }); + return; + } + + this.deviceLocks.set(normalized, entry); + logger.info('lock:decrement', { + devicePath: normalized, + count: entry.count, + owner + }); + } + + isDeviceLocked(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return false; + } + return this.deviceLocks.has(normalized); + } + + getActiveLocks() { + return Array.from(this.deviceLocks.entries()).map(([path, info]) => ({ + path, + count: info.count, + owners: info.owners + })); + } + + applyDetectionResult(detected, { forceInsertEvent = false } = {}) { + const isPresent = Boolean(detected); + const changed = + isPresent && + (!this.lastDetected || buildSignature(this.lastDetected) !== buildSignature(detected)); + + if (isPresent) { + const shouldEmitInserted = forceInsertEvent || !this.lastPresent || changed; + this.lastDetected = detected; + this.lastPresent = true; + + if (shouldEmitInserted) { + logger.info('disc:inserted', { detected, forceInsertEvent, changed }); + this.emit('discInserted', detected); + return { + present: true, + changed, + emitted: 'discInserted', + device: detected + }; + } + + return { + present: true, + changed, + emitted: 'none', + device: detected + }; + } + + if (!isPresent && this.lastPresent) { + const removed = this.lastDetected; + this.lastDetected = null; + this.lastPresent = false; + logger.info('disc:removed', { removed }); + this.emit('discRemoved', removed); + return { + present: false, + changed: true, + emitted: 'discRemoved', + device: null + }; + } + + return { + present: false, + changed: false, + emitted: 'none', + device: null + }; + } + + async detectDisc(settingsMap) { + if (settingsMap.drive_mode === 'explicit') { + return this.detectExplicit(settingsMap.drive_device); + } + + return this.detectAuto(); + } + + async detectExplicit(devicePath) { + if (this.isDeviceLocked(devicePath)) { + logger.debug('detect:explicit:locked', { + devicePath, + activeLocks: this.getActiveLocks() + }); + return null; + } + + if (!devicePath || !fs.existsSync(devicePath)) { + logger.debug('detect:explicit:not-found', { devicePath }); + return null; + } + + const hasMedia = await this.checkMediaPresent(devicePath); + if (!hasMedia) { + logger.debug('detect:explicit:no-media', { devicePath }); + return null; + } + const discLabel = await this.getDiscLabel(devicePath); + + const details = await this.getBlockDeviceInfo(); + const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {}; + + const 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: match.fstype || null, + index: this.guessDiscIndex(match.name || devicePath) + }; + logger.debug('detect:explicit:success', { detected }); + return detected; + } + + async detectAuto() { + const details = await this.getBlockDeviceInfo(); + const romCandidates = details.filter((entry) => entry.type === 'rom'); + + for (const item of romCandidates) { + const path = item.path || (item.name ? `/dev/${item.name}` : null); + if (!path) { + continue; + } + + if (this.isDeviceLocked(path)) { + logger.debug('detect:auto:skip-locked', { + path, + activeLocks: this.getActiveLocks() + }); + continue; + } + + const hasMedia = await this.checkMediaPresent(path); + if (!hasMedia) { + continue; + } + const discLabel = await this.getDiscLabel(path); + + 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: item.fstype || null, + index: this.guessDiscIndex(item.name) + }; + logger.debug('detect:auto:success', { detected }); + return detected; + } + + logger.debug('detect:auto:none'); + return null; + } + + async getBlockDeviceInfo() { + try { + const { stdout } = await execFileAsync('lsblk', [ + '-J', + '-o', + 'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL' + ]); + const parsed = JSON.parse(stdout); + const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({ + name: entry.name, + path: entry.path, + type: entry.type, + mountpoint: entry.mountpoint, + fstype: entry.fstype, + label: entry.label, + model: entry.model + })); + logger.debug('lsblk:ok', { deviceCount: devices.length }); + return devices; + } catch (error) { + logger.warn('lsblk:failed', { error: errorToMeta(error) }); + return []; + } + } + + async checkMediaPresent(devicePath) { + try { + const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]); + const has = stdout.trim().length > 0; + logger.debug('blkid:result', { devicePath, hasMedia: has, type: stdout.trim() }); + return has; + } catch (error) { + logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(error) }); + return false; + } + } + + 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; + } + } + + 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/historyService.js b/backend/src/services/historyService.js new file mode 100644 index 0000000..368ef26 --- /dev/null +++ b/backend/src/services/historyService.js @@ -0,0 +1,1098 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('HISTORY'); +const fs = require('fs'); +const path = require('path'); +const settingsService = require('./settingsService'); +const omdbService = require('./omdbService'); +const { getJobLogDir } = require('./logPathService'); + +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(); + +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 + }; + } + + const entries = fs.readdirSync(dirPath); + return { + path: dirPath, + exists: true, + isDirectory: true, + isEmpty: entries.length === 0, + entryCount: entries.length + }; + } 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 inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) { + const mkInfo = parseInfoFromValue(makemkvInfo, null); + const miInfo = parseInfoFromValue(mediainfoInfo, null); + const plan = parseInfoFromValue(encodePlan, null); + const rawPath = String(job?.raw_path || '').trim(); + const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim(); + + if (hasBlurayStructure(rawPath)) { + return 'bluray'; + } + + const mkSource = String(mkInfo?.source || '').trim().toLowerCase(); + const mkRipMode = String(mkInfo?.ripMode || mkInfo?.rip_mode || '').trim().toLowerCase(); + if ( + mkRipMode === 'backup' + || mkSource.includes('backup') + || mkSource.includes('raw_backup') + || Boolean(mkInfo?.analyzeContext?.playlistAnalysis) + ) { + 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 (mediainfoSource.includes('raw_backup') || Number(miInfo?.handbrakeTitleId) > 0) { + return 'bluray'; + } + + if ( + /(^|\/)bdmv(\/|$)/i.test(rawPath) + || /(^|\/)bdmv(\/|$)/i.test(encodeInputPath) + || /\.m2ts(\.|$)/i.test(encodeInputPath) + ) { + return 'bluray'; + } + + return 'disc'; +} + +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 enrichJobRow(job) { + const rawStatus = inspectDirectory(job.raw_path); + const outputStatus = inspectOutputFile(job.output_path); + const movieDir = job.output_path ? path.dirname(job.output_path) : null; + const movieDirStatus = inspectDirectory(movieDir); + const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null); + const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null); + const mediainfoInfo = parseJsonSafe(job.mediainfo_info_json, null); + const omdbInfo = parseJsonSafe(job.omdb_json, null); + const encodePlan = parseJsonSafe(job.encode_plan_json, null); + const mediaType = inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan); + + return { + ...job, + makemkvInfo, + handbrakeInfo, + mediainfoInfo, + omdbInfo, + encodePlan, + mediaType, + 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 parseRawFolderMetadata(folderName) { + const rawName = String(folderName || '').trim(); + const folderJobIdMatch = rawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); + const folderJobId = folderJobIdMatch ? Number(folderJobIdMatch[1]) : null; + let working = rawName.replace(/\s*-\s*RAW\s*-\s*job-\d+\s*$/i, '').trim(); + + 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 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 replaced = folderName.replace(/(\s-\sRAW\s-\sjob-)\d+\s*$/i, `$1${Math.trunc(normalizedJobId)}`); + if (replaced === folderName) { + return absRawPath; + } + return path.join(path.dirname(absRawPath), replaced); +} + +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; +} + +class HistoryService { + async createJob({ discDevice = null, status = 'ANALYZING', detectedTitle = null }) { + const db = await getDb(); + const startTime = new Date().toISOString(); + + const result = await db.run( + ` + INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + [discDevice, status, startTime, detectedTitle, status] + ); + logger.info('job:created', { + jobId: result.lastID, + discDevice, + status, + detectedTitle + }); + + 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); + } + + async updateJobStatus(jobId, status, extra = {}) { + return this.updateJob(jobId, { + status, + last_state: status, + ...extra + }); + } + + appendLog(jobId, source, message) { + this.appendProcessLog(jobId, source, message); + } + + 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 + }; + } + + async getJobById(jobId) { + const db = await getDb(); + return db.get('SELECT * FROM jobs WHERE id = ?', [jobId]); + } + + async getJobs(filters = {}) { + const db = await getDb(); + const where = []; + const values = []; + + 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 ?)'); + values.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`); + } + + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + const jobs = await db.all( + ` + SELECT j.* + FROM jobs j + ${whereClause} + ORDER BY j.created_at DESC + LIMIT 500 + `, + values + ); + + return jobs.map((job) => ({ + ...enrichJobRow(job), + log_count: hasProcessLogFile(job.id) ? 1 : 0 + })); + } + + async getJobWithLogs(jobId, options = {}) { + const db = await getDb(); + const job = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]); + if (!job) { + return null; + } + + 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 = hasProcessLogFile(jobId); + const baseLogCount = hasProcessLog ? 1 : 0; + + if (!shouldLoadLogs) { + return { + ...enrichJobRow(job), + log_count: baseLogCount, + logs: [], + log: '', + logMeta: { + loaded: false, + total: baseLogCount, + returned: 0, + truncated: false + } + }; + } + + const processLog = await this.readProcessLogLines(jobId, { + includeAll: includeAllLogs, + tailLines: logTailLines + }); + + return { + ...enrichJobRow(job), + log_count: processLog.exists ? processLog.total : 0, + logs: [], + log: processLog.lines.join('\n'), + logMeta: { + loaded: true, + total: includeAllLogs ? processLog.total : processLog.returned, + returned: processLog.returned, + truncated: processLog.truncated + } + }; + } + + async getDatabaseRows(filters = {}) { + const jobs = await this.getJobs(filters); + return jobs.map((job) => ({ + ...job, + rawFolderName: job.raw_path ? path.basename(job.raw_path) : null + })); + } + + async getOrphanRawFolders() { + const settings = await settingsService.getSettingsMap(); + const rawDir = String(settings.raw_dir || '').trim(); + if (!rawDir) { + const error = new Error('raw_dir ist nicht konfiguriert.'); + error.statusCode = 400; + throw error; + } + + const rawDirInfo = inspectDirectory(rawDir); + if (!rawDirInfo.exists || !rawDirInfo.isDirectory) { + return { + rawDir, + rows: [] + }; + } + + const db = await getDb(); + const linkedRows = await db.all( + ` + SELECT id, raw_path, status + FROM jobs + WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' + ` + ); + + const linkedPathMap = new Map(); + for (const row of linkedRows) { + const normalized = normalizeComparablePath(row.raw_path); + if (!normalized) { + continue; + } + if (!linkedPathMap.has(normalized)) { + linkedPathMap.set(normalized, []); + } + linkedPathMap.get(normalized).push({ + id: row.id, + status: row.status + }); + } + + const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true }); + const orphanRows = []; + + for (const entry of dirEntries) { + if (!entry.isDirectory()) { + continue; + } + + const rawPath = path.join(rawDir, entry.name); + const normalizedPath = normalizeComparablePath(rawPath); + if (linkedPathMap.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); + orphanRows.push({ + rawPath, + folderName: entry.name, + title: metadata.title, + year: metadata.year, + imdbId: metadata.imdbId, + folderJobId: metadata.folderJobId, + entryCount: Number(dirInfo.entryCount || 0), + hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')), + lastModifiedAt: stat.mtime.toISOString() + }); + } + + orphanRows.sort((a, b) => String(b.lastModifiedAt).localeCompare(String(a.lastModifiedAt))); + return { + rawDir, + rows: orphanRows + }; + } + + async importOrphanRawFolder(rawPath) { + const settings = await settingsService.getSettingsMap(); + const rawDir = String(settings.raw_dir || '').trim(); + const requestedRawPath = String(rawPath || '').trim(); + + if (!requestedRawPath) { + const error = new Error('rawPath fehlt.'); + error.statusCode = 400; + throw error; + } + + if (!rawDir) { + const error = new Error('raw_dir ist nicht konfiguriert.'); + error.statusCode = 400; + throw error; + } + + if (!isPathInside(rawDir, requestedRawPath)) { + const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${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 + }); + + 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); + } 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; + } + } + + await this.updateJob(created.id, { + status: 'FINISHED', + last_state: 'FINISHED', + title: omdbById?.title || metadata.title || null, + year: Number.isFinite(Number(omdbById?.year)) ? Number(omdbById.year) : metadata.year, + imdb_id: omdbById?.imdbId || metadata.imdbId || null, + poster_url: omdbById?.poster || null, + omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : null, + selected_from_omdb: omdbById ? 1 : 0, + raw_path: finalRawPath, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + error_message: null, + end_time: importedAt, + makemkv_info_json: JSON.stringify({ + status: 'SUCCESS', + source: 'orphan_raw_import', + importedAt, + rawPath: finalRawPath + }) + }); + + await this.appendLog( + created.id, + 'SYSTEM', + shouldRenameRawFolder + ? `Historieneintrag aus RAW erstellt. Ordner umbenannt: ${absRawPath} -> ${finalRawPath}` + : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath}` + ); + 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}` + ); + } + + logger.info('job:import-orphan-raw', { + jobId: created.id, + rawPath: absRawPath + }); + + const imported = await this.getJobById(created.id); + return enrichJobRow(imported); + } + + 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 imdbIdInput = String(payload.imdbId || '').trim().toLowerCase(); + let omdb = null; + if (imdbIdInput) { + omdb = await omdbService.fetchByImdbId(imdbIdInput); + if (!omdb) { + const error = new Error(`OMDb Eintrag für ${imdbIdInput} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + } + + 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); + + 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 + }); + + 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 = await this.getJobById(jobId); + return enrichJobRow(updated); + } + + 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 summary = { + target, + raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null }, + movie: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null } + }; + + if (target === 'raw' || target === 'both') { + summary.raw.attempted = true; + if (!job.raw_path) { + summary.raw.reason = 'Kein raw_path im Job gesetzt.'; + } else if (!isPathInside(settings.raw_dir, job.raw_path)) { + const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${job.raw_path}`); + error.statusCode = 400; + throw error; + } else if (!fs.existsSync(job.raw_path)) { + summary.raw.reason = 'RAW-Pfad existiert nicht.'; + } else { + const result = deleteFilesRecursively(job.raw_path, true); + summary.raw.deleted = true; + summary.raw.filesDeleted = result.filesDeleted; + summary.raw.dirsRemoved = result.dirsRemoved; + } + } + + if (target === 'movie' || target === 'both') { + summary.movie.attempted = true; + if (!job.output_path) { + summary.movie.reason = 'Kein output_path im Job gesetzt.'; + } else if (!isPathInside(settings.movie_dir, job.output_path)) { + const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${job.output_path}`); + error.statusCode = 400; + throw error; + } else if (!fs.existsSync(job.output_path)) { + summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.'; + } else { + const stat = fs.lstatSync(job.output_path); + if (stat.isDirectory()) { + const result = deleteFilesRecursively(job.output_path, true); + summary.movie.deleted = true; + summary.movie.filesDeleted = result.filesDeleted; + summary.movie.dirsRemoved = result.dirsRemoved; + } else { + fs.unlinkSync(job.output_path); + summary.movie.deleted = true; + summary.movie.filesDeleted = 1; + summary.movie.dirsRemoved = 0; + } + } + } + + 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 = await this.getJobById(jobId); + return { + summary, + job: enrichJobRow(updated) + }; + } + + async deleteJob(jobId, fileTarget = 'none') { + 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 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 fileResult = await this.deleteJobFiles(jobId, fileTarget); + fileSummary = fileResult.summary; + } + + 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']); + + 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); + const processLogPath = toProcessLogPath(jobId); + if (processLogPath && fs.existsSync(processLogPath)) { + try { + fs.unlinkSync(processLogPath); + } catch (error) { + logger.warn('job:process-log:delete-failed', { + jobId, + path: processLogPath, + error: error?.message || String(error) + }); + } + } + + logger.warn('job:deleted', { + jobId, + fileTarget, + pipelineStateReset: isActivePipelineJob, + filesDeleted: fileSummary + ? { + raw: fileSummary.raw?.filesDeleted ?? 0, + movie: fileSummary.movie?.filesDeleted ?? 0 + } + : { raw: 0, movie: 0 } + }); + + return { + deleted: true, + jobId, + fileTarget, + 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/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..57d8d6e --- /dev/null +++ b/backend/src/services/omdbService.js @@ -0,0 +1,92 @@ +const settingsService = require('./settingsService'); +const logger = require('./logger').child('OMDB'); + +class OmdbService { + async search(query) { + if (!query || query.trim().length === 0) { + return []; + } + logger.info('search:start', { query }); + + const settings = await settingsService.getSettingsMap(); + const apiKey = settings.omdb_api_key; + if (!apiKey) { + return []; + } + + const type = settings.omdb_default_type || 'movie'; + const url = new URL('https://www.omdbapi.com/'); + url.searchParams.set('apikey', apiKey); + url.searchParams.set('s', query.trim()); + url.searchParams.set('type', type); + + const response = await fetch(url); + if (!response.ok) { + logger.error('search:http-failed', { query, status: response.status }); + throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`); + } + + const data = await response.json(); + if (data.Response === 'False' || !Array.isArray(data.Search)) { + logger.warn('search:no-results', { query, response: data.Response, error: data.Error }); + return []; + } + const results = data.Search.map((item) => ({ + title: item.Title, + year: item.Year, + imdbId: item.imdbID, + type: item.Type, + poster: item.Poster + })); + logger.info('search:done', { query, count: results.length }); + return results; + } + + async fetchByImdbId(imdbId) { + const normalizedId = String(imdbId || '').trim().toLowerCase(); + if (!/^tt\d{6,12}$/.test(normalizedId)) { + return null; + } + + logger.info('fetchByImdbId:start', { imdbId: normalizedId }); + const settings = await settingsService.getSettingsMap(); + const apiKey = settings.omdb_api_key; + if (!apiKey) { + return null; + } + + const url = new URL('https://www.omdbapi.com/'); + url.searchParams.set('apikey', apiKey); + url.searchParams.set('i', normalizedId); + url.searchParams.set('plot', 'full'); + + const response = await fetch(url); + if (!response.ok) { + logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status }); + throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`); + } + + const data = await response.json(); + if (data.Response === 'False') { + logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error }); + return null; + } + + const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/); + const year = yearMatch ? Number(yearMatch[0]) : null; + const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null; + + const result = { + title: data.Title || null, + year: Number.isFinite(year) ? year : null, + imdbId: String(data.imdbID || normalizedId), + type: data.Type || null, + poster, + raw: data + }; + logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title }); + return result; + } +} + +module.exports = new OmdbService(); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js new file mode 100644 index 0000000..68808ce --- /dev/null +++ b/backend/src/services/pipelineService.js @@ -0,0 +1,5104 @@ +const fs = require('fs'); +const path = require('path'); +const { EventEmitter } = require('events'); +const { getDb } = require('../db/database'); +const settingsService = require('./settingsService'); +const historyService = require('./historyService'); +const omdbService = require('./omdbService'); +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, renderTemplate, findMediaFiles } = require('../utils/files'); +const { buildMediainfoReview } = require('../utils/encodePlan'); +const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); +const { errorToMeta } = require('../utils/errorMeta'); + +const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']); +const REVIEW_REFRESH_SETTING_PREFIXES = ['handbrake_', 'mediainfo_']; +const REVIEW_REFRESH_SETTING_KEYS = new Set(['makemkv_min_length_minutes']); + +function nowIso() { + return new Date().toISOString(); +} + +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 buildOutputPathFromJob(settings, job, fallbackJobId = null) { + const movieDir = settings.movie_dir; + const title = job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'); + const year = job.year || new Date().getFullYear(); + const imdbId = job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb'); + const template = settings.filename_template || '${title} (${year})'; + const folderName = sanitizeFileName( + renderTemplate('${title} (${year})', { + title, + year, + imdbId + }) + ); + const baseName = sanitizeFileName( + renderTemplate(template, { + title, + year, + imdbId + }) + ); + const ext = settings.output_extension || 'mkv'; + return path.join(movieDir, folderName, `${baseName}.${ext}`); +} + +function ensureUniqueOutputPath(outputPath) { + if (!fs.existsSync(outputPath)) { + return outputPath; + } + + const ts = fileTimestamp(); + let attempt = withTimestampBeforeExtension(outputPath, ts); + let i = 1; + while (fs.existsSync(attempt)) { + attempt = withTimestampBeforeExtension(outputPath, `${ts}-${i}`); + i += 1; + } + return attempt; +} + +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 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 shouldKeepHighlight(line) { + return /error|fail|warn|title\s+#|saving|encoding:|muxing|copying|decrypt/i.test(line); +} + +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 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 normalizeTrackLanguage(raw) { + const value = String(raw || '').trim(); + if (!value) { + return 'und'; + } + return value.toLowerCase().slice(0, 3); +} + +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 || []; +} + +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 tracks = []; + tracks.push({ + '@type': 'General', + Duration: String(Number(titleInfo?.durationSeconds || 0)) + }); + + 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?.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 + }); + } + + 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 resolveHandBrakeTitleIdForPlaylist(scanJson, playlistIdRaw) { + const playlistId = normalizePlaylistId(playlistIdRaw); + if (!playlistId) { + return null; + } + + const titleList = pickScanTitleList(scanJson); + const matches = titleList + .map((title, idx) => { + const handBrakeTitleId = normalizeScanTrackId( + title?.Index ?? title?.index ?? title?.Title ?? title?.title, + idx + ); + const playlist = normalizePlaylistId( + title?.Playlist + || title?.playlist + || title?.PlaylistName + || title?.playlistName + || null + ); + const durationSeconds = parseHandBrakeDurationSeconds( + title?.Duration ?? title?.duration ?? title?.Length ?? title?.length + ); + return { + handBrakeTitleId, + playlist, + durationSeconds + }; + }) + .filter((item) => item.playlist === playlistId); + + if (matches.length === 0) { + return null; + } + + const best = matches.sort((a, b) => b.durationSeconds - a.durationSeconds || a.handBrakeTitleId - b.handBrakeTitleId)[0]; + return best?.handBrakeTitleId || null; +} + +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 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 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 subtitleTracks = (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 + }; + }) + .filter((track) => Number.isFinite(Number(track?.sourceTrackId)) && Number(track.sourceTrackId) > 0); + + return { + handBrakeTitleId, + playlistId, + durationSeconds, + sizeBytes, + fileName, + audioTracks, + subtitleTracks + }; + }); + + let selected = null; + if (preferredHandBrakeTitleId) { + selected = parsedTitles.find((title) => title.handBrakeTitleId === preferredHandBrakeTitleId) || 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; + } + 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 pickTitleIdForTrackReview(playlistAnalysis, selectedTitleId = null) { + const explicit = Number(selectedTitleId); + if (Number.isFinite(explicit) && explicit >= 0) { + return Math.trunc(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 sortedCandidates = [...candidates].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 buildDiscScanReview({ + scanJson, + settings, + playlistAnalysis = null, + selectedPlaylistId = null, + selectedMakemkvTitleId = null, + sourceArg = null, + mode = 'pre_rip', + preRip = true, + encodeInputPath = null +}) { + const minLengthMinutes = 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 subtitleTracks = 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, + selectedByRule: true, + subtitlePreviewSummary: 'Übernehmen', + subtitlePreviewFlags: [], + subtitlePreviewBurnIn: false, + subtitlePreviewForced: false, + subtitlePreviewForcedOnly: false, + subtitlePreviewDefaultTrack: false + }; + }); + + 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); + return { + ...track, + selectedForEncode, + burnIn: false, + forced: false, + forcedOnly: false, + defaultTrack: false, + flags: [], + subtitleActionSummary: selectedForEncode ? 'Übernehmen' : '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, + 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 prefix = sanitizeFileName(`${metadataBase} - RAW - job-`); + const candidates = entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix)) + .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 toPlaylistFile(playlistId) { + const normalized = normalizePlaylistId(playlistId); + return normalized ? `${normalized}.mpls` : null; +} + +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); + + return { + playlistId, + playlistFile: toPlaylistFile(playlistId), + titleId: Number.isFinite(titleId) ? Math.trunc(titleId) : null, + 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: source?.audioSummary || null, + audioTrackPreview: Array.isArray(source?.audioTrackPreview) ? source.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 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 handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(scanJson, playlistId); + if (!handBrakeTitleId) { + continue; + } + const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { + playlistId, + handBrakeTitleId + }); + if (!titleInfo) { + 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 applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId) { + const normalizedTitleId = normalizeReviewTitleId(selectedEncodeTitleId); + if (!normalizedTitleId) { + return { + plan: encodePlan, + selectedTitle: null + }; + } + + const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : []; + const selectedTitle = titles.find((item) => Number(item?.id) === normalizedTitleId) || null; + if (!selectedTitle) { + const error = new Error(`Gewählter Titel #${normalizedTitleId} 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 #${normalizedTitleId} ist laut MIN_LENGTH_MINUTES nicht encodierbar.`); + error.statusCode = 400; + throw error; + } + + const remappedTitles = titles.map((title) => { + const isEncodeInput = Number(title?.id) === normalizedTitleId; + + const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { + const selectedByRule = Boolean(track?.selectedByRule); + const selectedForEncode = isEncodeInput && selectedByRule; + 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 selectedByRule = Boolean(track?.selectedByRule); + const selectedForEncode = isEncodeInput && 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' + }; + }); + + return { + ...title, + encodeInput: isEncodeInput, + selectedForEncode: isEncodeInput, + audioTracks, + subtitleTracks + }; + }); + + return { + plan: { + ...encodePlan, + titles: remappedTitles, + encodeInputTitleId: normalizedTitleId, + encodeInputPath: selectedTitle?.filePath || null, + titleSelectionRequired: false + }, + selectedTitle + }; +} + +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 applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null; + if (!plan || !Array.isArray(plan.titles)) { + return { + plan: encodePlan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [] + }; + } + + const encodeInputTitleId = normalizeReviewTitleId(plan.encodeInputTitleId); + if (!encodeInputTitleId) { + return { + plan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [] + }; + } + + const selectionPayload = selectedTrackSelection && typeof selectedTrackSelection === 'object' + ? selectedTrackSelection + : null; + if (!selectionPayload) { + return { + plan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [] + }; + } + + const rawSelection = selectionPayload[encodeInputTitleId] + || selectionPayload[String(encodeInputTitleId)] + || selectionPayload; + if (!rawSelection || typeof rawSelection !== 'object') { + return { + plan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [] + }; + } + + const encodeTitle = plan.titles.find((title) => Number(title?.id) === encodeInputTitleId) || null; + if (!encodeTitle) { + return { + plan, + selectionApplied: false, + audioTrackIds: [], + subtitleTrackIds: [] + }; + } + + 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 : []) + .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 audioSelectionSet = new Set(requestedAudioTrackIds.map((id) => String(id))); + const subtitleSelectionSet = new Set(requestedSubtitleTrackIds.map((id) => String(id))); + + const remappedTitles = plan.titles.map((title) => { + const isEncodeInput = Number(title?.id) === encodeInputTitleId; + + const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { + const trackId = Number(track?.id); + const selectedForEncode = isEncodeInput && audioSelectionSet.has(String(Math.trunc(trackId))); + 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))); + 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: isEncodeInput, + audioTracks, + subtitleTracks + }; + }); + + return { + plan: { + ...plan, + titles: remappedTitles, + manualTrackSelection: { + titleId: encodeInputTitleId, + audioTrackIds: requestedAudioTrackIds, + subtitleTrackIds: requestedSubtitleTrackIds, + updatedAt: nowIso() + } + }, + selectionApplied: true, + audioTrackIds: requestedAudioTrackIds, + subtitleTrackIds: requestedSubtitleTrackIds + }; +} + +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 audioTrackIds = normalizeTrackIdList( + (Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)) + .map((track) => track?.sourceTrackId ?? track?.id) + ); + const subtitleTrackIds = normalizeTrackIdList( + (Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)) + .map((track) => track?.sourceTrackId ?? track?.id) + ); + const selectedSubtitleTracks = (Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)); + const subtitleBurnTrackId = normalizeTrackIdList( + selectedSubtitleTracks.filter((track) => Boolean(track?.burnIn)).map((track) => track?.sourceTrackId ?? track?.id) + )[0] || null; + const subtitleDefaultTrackId = normalizeTrackIdList( + selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.sourceTrackId ?? track?.id) + )[0] || null; + const subtitleForcedTrackId = normalizeTrackIdList( + selectedSubtitleTracks.filter((track) => Boolean(track?.forced)).map((track) => track?.sourceTrackId ?? track?.id) + )[0] || null; + const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.forcedOnly)); + + return { + titleId: Number(encodeTitle?.id) || null, + audioTrackIds, + subtitleTrackIds, + 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 primary = findMediaFiles(rawPath, ['.mkv', '.mp4']); + if (primary.length > 0) { + return { + mediaFiles: primary, + source: 'mkv' + }; + } + + const streamDir = path.join(rawPath, 'BDMV', 'STREAM'); + const backupRoot = fs.existsSync(streamDir) ? streamDir : rawPath; + let backupFiles = findMediaFiles(backupRoot, ['.m2ts']); + if (backupFiles.length === 0) { + 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) + }; +} + +class PipelineService extends EventEmitter { + constructor() { + super(); + this.snapshot = { + state: 'IDLE', + activeJobId: null, + progress: 0, + eta: null, + statusText: null, + context: {} + }; + this.detectedDisc = null; + this.activeProcess = null; + this.cancelRequested = false; + this.lastPersistAt = 0; + this.lastProgressKey = null; + } + + async init() { + const db = await getDb(); + 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 }); + } + + if (RUNNING_STATES.has(this.snapshot.state) && this.snapshot.activeJobId) { + const message = `Server-Neustart während ${this.snapshot.state} am ${new Date().toISOString()}`; + await historyService.updateJobStatus(this.snapshot.activeJobId, 'ERROR', { + end_time: nowIso(), + error_message: message + }); + await historyService.appendLog(this.snapshot.activeJobId, 'SYSTEM', message); + + await this.setState('ERROR', { + activeJobId: this.snapshot.activeJobId, + progress: 0, + eta: null, + statusText: message, + context: { + jobId: this.snapshot.activeJobId, + stage: 'RECOVERY', + error: message + } + }); + logger.warn('init:recovered-running-job', { jobId: this.snapshot.activeJobId, previousState: this.snapshot.state }); + } + + // Always start with a clean dashboard/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 + }); + } + } + + safeParseJson(raw) { + if (!raw) { + return {}; + } + + try { + return JSON.parse(raw); + } catch (error) { + logger.warn('safeParseJson:failed', { raw, error: errorToMeta(error) }); + return {}; + } + } + + getSnapshot() { + return { + ...this.snapshot + }; + } + + async resetFrontendState(reason = 'manual', options = {}) { + const force = Boolean(options?.force); + const keepDetectedDevice = options?.keepDetectedDevice !== false; + + if (!force && (this.activeProcess || 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) + }); + } + } + + 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; + } + + async setState(state, patch = {}) { + const previous = this.snapshot.state; + 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 + }; + logger.info('state:changed', { + from: previous, + to: state, + activeJobId: this.snapshot.activeJobId, + statusText: this.snapshot.statusText + }); + + await this.persistSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', this.snapshot); + this.emit('stateChanged', this.snapshot); + } + + 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) { + this.snapshot = { + ...this.snapshot, + state: stage, + progress: percent ?? this.snapshot.progress, + eta: eta ?? this.snapshot.eta, + statusText: statusText ?? this.snapshot.statusText + }; + + await this.persistSnapshot(false); + const rounded = Number((this.snapshot.progress || 0).toFixed(2)); + const key = `${stage}:${rounded}`; + if (key !== this.lastProgressKey) { + this.lastProgressKey = key; + logger.debug('progress:update', { + stage, + activeJobId: this.snapshot.activeJobId, + progress: rounded, + eta: this.snapshot.eta, + statusText: this.snapshot.statusText + }); + } + wsService.broadcast('PIPELINE_PROGRESS', { + state: stage, + activeJobId: this.snapshot.activeJobId, + progress: this.snapshot.progress, + eta: this.snapshot.eta, + statusText: this.snapshot.statusText + }); + } + + async onDiscInserted(deviceInfo) { + 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, deviceInfo) : false; + + this.detectedDisc = deviceInfo; + logger.info('disc:inserted', { deviceInfo }); + + wsService.broadcast('DISC_DETECTED', { + device: deviceInfo + }); + + if (discChanged && !RUNNING_STATES.has(previousState) && previousState !== 'DISC_DETECTED' && previousState !== 'READY_TO_ENCODE') { + const message = `Disk gewechselt (${deviceInfo.discLabel || deviceInfo.path || 'unbekannt'}). Bitte neu analysieren.`; + logger.info('disc:changed:reset', { + fromState: previousState, + previousDevice, + newDevice: deviceInfo, + 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: deviceInfo + } + }); + 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: deviceInfo + } + }); + } + } + + async onDiscRemoved(deviceInfo) { + logger.info('disc:removed', { deviceInfo }); + wsService.broadcast('DISC_REMOVED', { + device: deviceInfo + }); + + 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) { + if (this.activeProcess) { + const error = new Error(`Pipeline ist beschäftigt. Aktion '${action}' aktuell nicht möglich.`); + error.statusCode = 409; + logger.warn('busy:blocked-action', { + action, + activeState: this.snapshot.state, + activeJobId: this.snapshot.activeJobId + }); + throw error; + } + } + + 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 relevantKeys = keys.filter((key) => this.isReviewRefreshSettingKey(key)); + if (relevantKeys.length === 0) { + return { + triggered: false, + reason: 'no_relevant_setting_changes', + relevantKeys: [] + }; + } + + if (this.activeProcess || 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 + }; + } + + if (!job.raw_path || !fs.existsSync(job.raw_path)) { + return { + triggered: false, + reason: 'raw_path_missing', + relevantKeys, + jobId: activeJobId, + rawPath: job.raw_path || null + }; + } + + const existingPlan = this.safeParseJson(job.encode_plan_json); + 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, job.raw_path, { 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 && rawSelectedTitleId !== null && rawSelectedTitleId !== undefined && rawSelectedTitleId !== '') { + const parsedSelectedTitleId = Number(rawSelectedTitleId); + if (Number.isFinite(parsedSelectedTitleId) && parsedSelectedTitleId >= 0) { + selectedTitleId = Math.trunc(parsedSelectedTitleId); + } + } + + const candidatePlaylists = buildPlaylistCandidates(playlistAnalysis); + const recommendation = playlistAnalysis?.recommendation || null; + + return { + playlistAnalysis, + playlistDecisionRequired, + candidatePlaylists, + selectedPlaylist, + selectedTitleId, + recommendation + }; + } + + async analyzeDisc() { + this.ensureNotBusy('analyze'); + logger.info('analyze:start'); + + const 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; + } + + const detectedTitle = String( + device.discLabel + || device.label + || device.model + || 'Unknown Disc' + ).trim(); + + const job = await historyService.createJob({ + discDevice: device.path, + status: 'METADATA_SELECTION', + detectedTitle + }); + + try { + const omdbCandidates = await omdbService.search(detectedTitle).catch(() => []); + logger.info('metadata:prepare:result', { + jobId: job.id, + detectedTitle, + omdbCandidateCount: omdbCandidates.length + }); + + await historyService.updateJob(job.id, { + status: 'METADATA_SELECTION', + last_state: 'METADATA_SELECTION', + detected_title: detectedTitle, + makemkv_info_json: JSON.stringify({ + phase: 'PREPARE', + preparedAt: nowIso(), + analyzeContext: { + playlistAnalysis: null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null + } + }) + }); + await historyService.appendLog( + job.id, + 'SYSTEM', + `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${detectedTitle}".` + ); + + await this.setState('METADATA_SELECTION', { + activeJobId: job.id, + progress: 0, + eta: null, + statusText: 'Metadaten auswählen', + context: { + jobId: job.id, + device, + detectedTitle, + detectedTitleSource: device.discLabel ? 'discLabel' : 'fallback', + omdbCandidates, + playlistAnalysis: null, + playlistDecisionRequired: false, + playlistCandidates: [], + selectedPlaylist: null, + selectedTitleId: null + } + }); + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Metadaten bereit', + message: `Job #${job.id}: ${detectedTitle} (${omdbCandidates.length} Treffer)` + }); + + return { + jobId: job.id, + detectedTitle, + 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 runDiscTrackReviewForJob(jobId, deviceInfo = null, options = {}) { + this.ensureNotBusy('runDiscTrackReviewForJob'); + 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 settings = await settingsService.getSettingsMap(); + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext || {}; + const playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; + const selectedPlaylistId = normalizePlaylistId( + options?.selectedPlaylist + || analyzeContext.selectedPlaylist + || this.snapshot.context?.selectedPlaylist + || null + ); + const selectedMakemkvTitleIdRaw = Number( + options?.selectedTitleId + ?? analyzeContext.selectedTitleId + ?? this.snapshot.context?.selectedTitleId + ?? null + ); + const selectedMakemkvTitleId = Number.isFinite(selectedMakemkvTitleIdRaw) && selectedMakemkvTitleIdRaw >= 0 + ? Math.trunc(selectedMakemkvTitleIdRaw) + : null; + 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('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', + selectedMetadata + } + }); + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + + const lines = []; + const scanConfig = await settingsService.buildHandBrakeScanConfig(deviceInfo); + logger.info('disc-track-review:command', { + jobId, + cmd: scanConfig.cmd, + args: scanConfig.args, + sourceArg: scanConfig.sourceArg, + selectedTitleId: selectedMakemkvTitleId + }); + + const runInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN', + cmd: scanConfig.cmd, + args: scanConfig.args, + collectLines: lines, + collectStderrLines: false + }); + + 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; + } + + const review = buildDiscScanReview({ + scanJson: parsed, + settings, + playlistAnalysis, + selectedPlaylistId, + selectedMakemkvTitleId, + sourceArg: scanConfig.sourceArg + }); + + if (!Array.isArray(review.titles) || review.titles.length === 0) { + const error = new Error('Vorab-Spurprüfung lieferte keine Titel.'); + error.statusCode = 400; + throw error; + } + + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + mediainfo_info_json: JSON.stringify({ + generatedAt: nowIso(), + source: 'disc_scan', + runInfo + }), + 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', + mediaInfoReview: review, + selectedMetadata + } + }); + + 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'); + 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 settings = await settingsService.getSettingsMap(); + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext || {}; + let playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; + let handBrakePlaylistScan = normalizeHandBrakePlaylistScanCache(analyzeContext.handBrakePlaylistScan || null); + if (playlistAnalysis && handBrakePlaylistScan) { + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + } + const selectedPlaylistSource = forcePlaylistReselection + ? (options?.selectedPlaylist || null) + : (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null); + const selectedPlaylistId = normalizePlaylistId( + selectedPlaylistSource + ); + const selectedTitleSource = forcePlaylistReselection + ? (options?.selectedTitleId ?? null) + : (options?.selectedTitleId ?? analyzeContext.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null); + const selectedMakemkvTitleIdRaw = Number( + selectedTitleSource + ); + const selectedMakemkvTitleId = Number.isFinite(selectedMakemkvTitleIdRaw) && selectedMakemkvTitleIdRaw >= 0 + ? Math.trunc(selectedMakemkvTitleIdRaw) + : null; + 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('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, + sourceJobId: options.sourceJobId || null, + selectedMetadata + } + }); + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + 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.' + ); + } + + // 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); + 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 + }); + + 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 selectedTitleForContext = selectedTitleFromPlaylist ?? selectedMakemkvTitleId ?? 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' + ); + try { + const resolveScanLines = []; + const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath); + logger.info('backup-track-review:handbrake-predecision-command', { + jobId, + cmd: resolveScanConfig.cmd, + args: resolveScanConfig.args, + sourceArg: resolveScanConfig.sourceArg, + candidatePlaylists: playlistCandidates.map((item) => item.playlistFile || item.playlistId) + }); + + await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + cmd: resolveScanConfig.cmd, + args: resolveScanConfig.args, + collectLines: resolveScanLines, + collectStderrLines: false + }); + + 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 { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake Playlist-Trackdaten konnten nicht geparst werden (Warteansicht ohne Audiodetails).' + ); + } + } 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}` + ); + } + } 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 updatedMakemkvInfo = { + ...mkInfo, + analyzeContext: { + ...(mkInfo?.analyzeContext || {}), + 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 + } + }; + + if (playlistDecisionRequired && !selectedPlaylistId) { + const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates) + ? playlistAnalysis.evaluatedCandidates + : []; + const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); + + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + makemkv_info_json: JSON.stringify(updatedMakemkvInfo), + mediainfo_info_json: JSON.stringify({ + generatedAt: nowIso(), + source: 'makemkv_backup_robot', + runInfo: makeMkvAnalyzeRunInfo + }), + 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', 'Blu-ray verwendet Playlist-Obfuscation.'); + 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 recommendedLabel = candidate?.recommended ? ' (empfohlen)' : ''; + const evaluationLabel = candidate?.evaluationLabel ? ` | ${candidate.evaluationLabel}` : ''; + await historyService.appendLog( + jobId, + 'SYSTEM', + `${playlistFile} -> Score ${scoreLabel}${recommendedLabel}${evaluationLabel}` + ); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Status=awaiting_playlist_selection${recommendationFile ? ` | Empfehlung=${recommendationFile}` : ''}` + ); + + 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, + sourceJobId: options.sourceJobId || null, + selectedMetadata, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired: true, + playlistCandidates, + selectedPlaylist: null, + selectedTitleId: null, + waitingForManualPlaylistSelection: true, + manualDecisionState: 'awaiting_playlist_selection', + mediaInfoReview: null + } + }); + + const notificationMessage = [ + '⚠️ Manuelle Prüfung erforderlich!', + 'Mehrere gleichlange Playlists erkannt.', + '', + '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 (updatedMakemkvInfo && updatedMakemkvInfo.analyzeContext) { + updatedMakemkvInfo.analyzeContext.selectedTitleId = selectedTitleForReview; + updatedMakemkvInfo.analyzeContext.selectedPlaylist = resolvedPlaylistId; + } + + const cachedHandBrakePlaylistEntry = getCachedHandBrakePlaylistEntry(handBrakePlaylistScan, resolvedPlaylistId); + const hasCachedHandBrakeEntry = Boolean( + cachedHandBrakePlaylistEntry + && cachedHandBrakePlaylistEntry.titleInfo + && Number.isFinite(Number(cachedHandBrakePlaylistEntry.handBrakeTitleId)) + && Number(cachedHandBrakePlaylistEntry.handBrakeTitleId) > 0 + ); + + await this.updateProgress( + 'MEDIAINFO_CHECK', + 30, + null, + hasCachedHandBrakeEntry + ? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})` + : `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})` + ); + + let handBrakeResolveRunInfo = null; + let handBrakeTitleRunInfo = null; + let resolvedHandBrakeTitleId = null; + const reviewTitleSource = 'handbrake'; + let reviewTitleInfo = null; + if (hasCachedHandBrakeEntry) { + resolvedHandBrakeTitleId = Math.trunc(Number(cachedHandBrakePlaylistEntry.handBrakeTitleId)); + reviewTitleInfo = cachedHandBrakePlaylistEntry.titleInfo; + 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 resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath); + logger.info('backup-track-review:handbrake-resolve-command', { + jobId, + cmd: resolveScanConfig.cmd, + args: resolveScanConfig.args, + sourceArg: resolveScanConfig.sourceArg, + selectedPlaylistId: resolvedPlaylistId, + selectedMakemkvTitleId: selectedTitleForReview + }); + + handBrakeResolveRunInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + cmd: resolveScanConfig.cmd, + args: resolveScanConfig.args, + collectLines: resolveScanLines, + collectStderrLines: false + }); + + 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); + if (!resolvedHandBrakeTitleId) { + const error = new Error(`Kein HandBrake-Titel für ${toPlaylistFile(resolvedPlaylistId)} gefunden.`); + error.statusCode = 400; + error.runInfo = handBrakeResolveRunInfo; + throw error; + } + + reviewTitleInfo = parseHandBrakeSelectedTitleInfo(resolveScanJson, { + playlistId: resolvedPlaylistId, + handBrakeTitleId: resolvedHandBrakeTitleId + }); + 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; + } + + 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 (updatedMakemkvInfo && updatedMakemkvInfo.analyzeContext) { + updatedMakemkvInfo.analyzeContext.handBrakePlaylistScan = handBrakePlaylistScan || null; + } + playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + + let presetProfile = null; + try { + presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, { + titleId: resolvedHandBrakeTitleId + }); + } 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 normalizedTitles = (Array.isArray(review.titles) ? review.titles : []) + .slice(0, 1) + .map((title) => ({ + ...title, + filePath: rawPath, + fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`, + durationSeconds: Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0), + durationMinutes: Number((((reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0) / 60)).toFixed(2)), + 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 : []) + })); + + const encodeInputTitleId = Number(normalizedTitles[0]?.id || review.encodeInputTitleId || null) || null; + review = { + ...review, + mode, + 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).` + ] + }; + + 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; + } + + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + makemkv_info_json: JSON.stringify(updatedMakemkvInfo), + mediainfo_info_json: JSON.stringify({ + generatedAt: nowIso(), + source: 'raw_backup_handbrake_playlist_scan', + makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo, + makemkvTitleAnalyzeRunInfo: null, + handbrakePlaylistResolveRunInfo: handBrakeResolveRunInfo, + handbrakeTitleRunInfo: handBrakeTitleRunInfo, + handbrakeTitleId: resolvedHandBrakeTitleId || null + }), + 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 (playlistDecisionRequired) { + const playlistFiles = playlistCandidates.map((item) => item.playlistFile).filter(Boolean); + const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Playlist-Obfuscation erkannt (RAW). Kandidaten: ${playlistFiles.join(', ') || 'keine'}.` + ); + if (recommendationFile) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Playlist-Empfehlung: ${recommendationFile}` + ); + } + } + + const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId); + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 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'), + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath, + inputPath: review.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode, + 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 }) { + this.ensureNotBusy('selectMetadata'); + logger.info('metadata:selected', { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); + 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 (normalizedSelectedPlaylist && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload) { + const currentMkInfo = this.safeParseJson(job.makemkv_info_json); + const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; + const currentPlaylistAnalysis = currentAnalyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; + const selectedTitleId = pickTitleIdForPlaylist(currentPlaylistAnalysis, normalizedSelectedPlaylist); + const updatedMkInfo = { + ...currentMkInfo, + analyzeContext: { + ...currentAnalyzeContext, + playlistAnalysis: currentPlaylistAnalysis || null, + playlistDecisionRequired: Boolean(currentPlaylistAnalysis?.manualDecisionRequired), + selectedPlaylist: normalizedSelectedPlaylist, + selectedTitleId: selectedTitleId ?? null + } + }; + + 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 + }); + } 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; + } + 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 selectedMetadata = { + title: effectiveTitle, + year: effectiveYear, + imdbId: effectiveImdbId, + poster: posterValue + }; + const settings = await settingsService.getSettingsMap(); + const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' + ? 'backup' + : 'mkv'; + const isBackupMode = ripMode === 'backup'; + const metadataBase = sanitizeFileName( + renderTemplate('${title} (${year}) [${imdbId}]', { + title: selectedMetadata.title || job.detected_title || `job-${jobId}`, + year: selectedMetadata.year || new Date().getFullYear(), + imdbId: selectedMetadata.imdbId || `job-${jobId}` + }) + ); + const existingRawPath = findExistingRawDirectory(settings.raw_dir, metadataBase); + const updatedRawPath = existingRawPath || null; + 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 mkInfo = this.safeParseJson(job.makemkv_info_json); + const updatedMakemkvInfo = { + ...mkInfo, + analyzeContext: { + ...(mkInfo?.analyzeContext || {}), + playlistAnalysis: playlistDecision.playlistAnalysis || mkInfo?.analyzeContext?.playlistAnalysis || null, + playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), + selectedPlaylist: playlistDecision.selectedPlaylist || null, + selectedTitleId: playlistDecision.selectedTitleId ?? null + } + }; + + await historyService.updateJob(jobId, { + title: effectiveTitle, + year: effectiveYear, + imdb_id: effectiveImdbId, + poster_url: posterValue, + selected_from_omdb: selectedFromOmdb, + status: nextStatus, + last_state: nextStatus, + raw_path: updatedRawPath, + makemkv_info_json: JSON.stringify(updatedMakemkvInfo) + }); + + 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}).` + ); + } + + 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, + 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 + } + }); + + if (requiresManualPlaylistSelection) { + const playlistFiles = playlistDecision.candidatePlaylists + .map((item) => item.playlistFile) + .filter(Boolean); + const recommendationFile = toPlaylistFile(playlistDecision.recommendation?.playlistId); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Playlist-Obfuscation erkannt. 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) { + this.ensureNotBusy('startPreparedJob'); + logger.info('startPreparedJob:requested', { jobId }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + 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; + } + + if (isPreRipReadyState) { + await historyService.updateJob(jobId, { + 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) }); + }); + + return { started: true, stage: 'ENCODING' }; + } + + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); + const settings = await settingsService.getSettingsMap(); + 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) + }); + } + } + + 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' + }).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 + }); + + this.startRipEncode(jobId).catch((error) => { + logger.error('startPreparedJob:background-failed', { jobId, error: errorToMeta(error) }); + }); + + return { started: true, stage: 'RIPPING' }; + } + + async confirmEncodeReview(jobId, options = {}) { + this.ensureNotBusy('confirmEncodeReview'); + logger.info('confirmEncodeReview:requested', { + jobId, + selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null, + selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection) + }); + + const 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 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 planWithSelectionResult = applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId); + let planForConfirm = planWithSelectionResult.plan; + const trackSelectionResult = applyManualTrackSelectionToPlan( + planForConfirm, + options?.selectedTrackSelection || null + ); + planForConfirm = trackSelectionResult.plan; + const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase(); + const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip); + + if (planForConfirm?.playlistDecisionRequired && !planForConfirm?.encodeInputPath && !planForConfirm?.encodeInputTitleId) { + const error = new Error('Bestätigung nicht möglich: Bitte zuerst einen Titel per Checkbox auswählen.'); + error.statusCode = 400; + throw error; + } + + const confirmedPlan = { + ...planForConfirm, + reviewConfirmed: true, + reviewConfirmedAt: nowIso() + }; + const inputPath = isPreRipMode + ? null + : (job.encode_input_path || confirmedPlan.encodeInputPath || this.snapshot.context?.inputPath || null); + 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 + }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}` + + ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.` + + ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.` + ); + + 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, + mediaInfoReview: confirmedPlan, + reviewConfirmed: true + } + }); + + return historyService.getJobById(jobId); + } + + async reencodeFromRaw(sourceJobId) { + this.ensureNotBusy('reencodeFromRaw'); + logger.info('reencodeFromRaw:requested', { 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); + if (mkInfo && mkInfo.status && mkInfo.status !== 'SUCCESS') { + const error = new Error(`Re-Encode nicht möglich: RAW-Rip ist nicht abgeschlossen (MakeMKV Status ${mkInfo.status}).`); + error.statusCode = 400; + throw error; + } + + if (!fs.existsSync(sourceJob.raw_path)) { + 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); + + const rawInput = findPreferredRawInput(sourceJob.raw_path); + 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); + + await historyService.updateJob(sourceJobId, { + 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 + }); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input-Kandidat: ${rawInput.path}` + ); + + this.runReviewForRawJob(sourceJobId, sourceJob.raw_path, { + mode: 'reencode', + sourceJobId, + forcePlaylistReselection: true + }).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 runMediainfoForFile(jobId, inputPath) { + const lines = []; + const config = await settingsService.buildMediaInfoConfig(inputPath); + 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 runMediainfoReviewForJob(jobId, rawPath, options = {}) { + this.ensureNotBusy('runMediainfoReviewForJob'); + 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 settings = await settingsService.getSettingsMap(); + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext || {}; + const selectedPlaylistId = normalizePlaylistId( + analyzeContext.selectedPlaylist + || this.snapshot.context?.selectedPlaylist + || null + ); + const playlistAnalysis = analyzeContext.playlistAnalysis + || this.snapshot.context?.playlistAnalysis + || null; + const preferredEncodeTitleIdRaw = Number(analyzeContext.selectedTitleId); + const preferredEncodeTitleId = Number.isFinite(preferredEncodeTitleIdRaw) && preferredEncodeTitleIdRaw >= 0 + ? Math.trunc(preferredEncodeTitleIdRaw) + : null; + 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; + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Mediainfo-Quelle: ${rawMedia.source} (${mediaFiles.length} Datei(en))` + ); + let presetProfile = null; + try { + presetProfile = await settingsService.buildHandBrakePresetProfile(mediaFiles[0].path); + } 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}` + }; + } + + 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('MEDIAINFO_CHECK', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'Mediainfo-Prüfung läuft', + context: { + jobId, + rawPath, + reviewConfirmed: false, + mode: options.mode || 'rip', + sourceJobId: options.sourceJobId || null, + selectedMetadata + } + }); + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK' + }); + + const mediaInfoByPath = {}; + const mediaInfoRuns = []; + const buildReviewSnapshot = (processedCount) => { + const processedFiles = mediaFiles + .slice(0, processedCount) + .filter((item) => Boolean(mediaInfoByPath[item.path])); + + if (processedFiles.length === 0) { + return null; + } + + return { + ...buildMediainfoReview({ + mediaFiles: processedFiles, + mediaInfoByPath, + settings, + presetProfile, + playlistAnalysis, + preferredEncodeTitleId, + selectedPlaylistId, + selectedMakemkvTitleId: preferredEncodeTitleId + }), + mode: options.mode || 'rip', + sourceJobId: options.sourceJobId || null, + reviewConfirmed: false, + partial: processedFiles.length < mediaFiles.length, + processedFiles: processedFiles.length, + totalFiles: mediaFiles.length + }; + }; + + 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)}`); + + const result = await this.runMediainfoForFile(jobId, file.path); + mediaInfoByPath[file.path] = result.parsed; + mediaInfoRuns.push({ + filePath: file.path, + runInfo: result.runInfo + }); + + const partialReview = buildReviewSnapshot(i + 1); + 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', + sourceJobId: options.sourceJobId || null, + mediaInfoReview: partialReview, + selectedMetadata + } + }); + } + + const review = buildMediainfoReview({ + mediaFiles, + mediaInfoByPath, + settings, + presetProfile, + playlistAnalysis, + preferredEncodeTitleId, + selectedPlaylistId, + selectedMakemkvTitleId: preferredEncodeTitleId + }); + + const enrichedReview = { + ...review, + mode: options.mode || 'rip', + sourceJobId: options.sourceJobId || null, + reviewConfirmed: false, + partial: false, + processedFiles: mediaFiles.length, + totalFiles: mediaFiles.length + }; + const hasEncodableTitle = Boolean(enrichedReview.encodeInputPath); + const titleSelectionRequired = Boolean(enrichedReview.titleSelectionRequired); + if (!hasEncodableTitle && !titleSelectionRequired) { + enrichedReview.notes = [ + ...(Array.isArray(enrichedReview.notes) ? enrichedReview.notes : []), + 'Kein Titel erfüllt aktuell MIN_LENGTH_MINUTES. Bitte Konfiguration prüfen.' + ]; + } + + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + mediainfo_info_json: JSON.stringify({ + generatedAt: nowIso(), + files: mediaInfoRuns + }), + 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')}` + ); + + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 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'), + context: { + jobId, + rawPath, + inputPath: enrichedReview.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode: options.mode || 'rip', + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + } + }); + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Mediainfo geprüft', + message: `Job #${jobId}: bereit zum manuellen Encode-Start` + }); + + return enrichedReview; + } + + async startEncodingFromPrepared(jobId) { + this.ensureNotBusy('startEncodingFromPrepared'); + logger.info('encode:start-from-prepared', { jobId }); + + const settings = await settingsService.getSettingsMap(); + const movieDir = settings.movie_dir; + ensureDir(movieDir); + + const job = 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 mode = encodePlan?.mode || this.snapshot.context?.mode || 'rip'; + let inputPath = job.encode_input_path || encodePlan?.encodeInputPath || this.snapshot.context?.inputPath || null; + + if (!inputPath && job.raw_path) { + const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); + inputPath = findPreferredRawInput(job.raw_path, { + playlistAnalysis: playlistDecision.playlistAnalysis, + selectedPlaylistId: playlistDecision.selectedPlaylist + })?.path || null; + } + + 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 preferredOutputPath = buildOutputPathFromJob(settings, job, jobId); + const outputPath = ensureUniqueOutputPath(preferredOutputPath); + const outputPathWithTimestamp = outputPath !== preferredOutputPath; + ensureDir(path.dirname(outputPath)); + + await this.setState('ENCODING', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: mode === 'reencode' ? 'Re-Encoding mit HandBrake' : 'Encoding mit HandBrake', + context: { + jobId, + mode, + inputPath, + outputPath, + reviewConfirmed: true, + 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 + } + } + }); + + await historyService.updateJob(jobId, { + status: 'ENCODING', + last_state: 'ENCODING', + output_path: outputPath, + encode_input_path: inputPath + }); + + if (outputPathWithTimestamp) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Output existierte bereits. Neuer Output-Pfad mit Timestamp: ${outputPath}` + ); + } + + if (mode === 'reencode') { + void this.notifyPushover('reencode_started', { + title: 'Ripster - Re-Encode gestartet', + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}` + }); + } else { + void this.notifyPushover('encoding_started', { + title: 'Ripster - Encoding gestartet', + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}` + }); + } + + try { + const trackSelection = extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath); + 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}` + ); + } + const selectedPlaylistId = normalizePlaylistId( + encodePlan?.selectedPlaylistId + || (Array.isArray(encodePlan?.titles) + ? (encodePlan.titles.find((title) => Boolean(title?.selectedForEncode))?.playlistId || null) + : null) + || this.snapshot.context?.selectedPlaylist + || null + ); + if (!handBrakeTitleId && selectedPlaylistId) { + const titleResolveScanLines = []; + const titleResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath); + 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); + if (!handBrakeTitleId) { + const error = new Error(`Kein HandBrake-Titel für Playlist ${selectedPlaylistId}.mpls gefunden.`); + error.statusCode = 400; + throw error; + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Mapping: ${selectedPlaylistId}.mpls -> -t ${handBrakeTitleId}` + ); + } else if (!handBrakeTitleId) { + handBrakeTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId ?? encodePlan?.encodeInputTitleId); + } + } + const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, outputPath, { + trackSelection, + titleId: handBrakeTitleId + }); + 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=${trackSelection.subtitleForcedTrackId ?? (trackSelection.subtitleForcedOnly ? 'forced-only' : 'none')}` + ); + } + if (handBrakeTitleId) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Selektion aktiv: -t ${handBrakeTitleId}` + ); + } + logger.info('encoding:command', { jobId, cmd: handBrakeConfig.cmd, args: handBrakeConfig.args }); + const handbrakeInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'HANDBRAKE', + cmd: handBrakeConfig.cmd, + args: handBrakeConfig.args, + parser: parseHandBrakeProgress + }); + + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify(handbrakeInfo), + status: 'FINISHED', + last_state: 'FINISHED', + end_time: nowIso(), + output_path: outputPath, + error_message: null + }); + + logger.info('encoding:finished', { jobId, mode, outputPath }); + + await this.setState('FINISHED', { + activeJobId: jobId, + progress: 100, + eta: null, + statusText: mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen', + context: { + jobId, + mode, + outputPath + } + }); + + if (mode === 'reencode') { + void this.notifyPushover('reencode_finished', { + title: 'Ripster - Re-Encode abgeschlossen', + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}` + }); + } else { + void this.notifyPushover('job_finished', { + title: 'Ripster - Job abgeschlossen', + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}` + }); + } + + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { + await this.setState('IDLE', { + 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) }); + await this.failJob(jobId, 'ENCODING', error); + throw error; + } + } + + async startRipEncode(jobId) { + this.ensureNotBusy('startRipEncode'); + 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 playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); + const selectedTitleId = playlistDecision.selectedTitleId; + const selectedPlaylist = playlistDecision.selectedPlaylist; + const selectedPlaylistFile = toPlaylistFile(selectedPlaylist); + + const settings = await settingsService.getSettingsMap(); + const rawBaseDir = settings.raw_dir; + const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' + ? 'backup' + : 'mkv'; + 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 }); + + ensureDir(rawBaseDir); + + const metadataBase = sanitizeFileName( + renderTemplate('${title} (${year}) [${imdbId}]', { + title: job.title || job.detected_title || `job-${jobId}`, + year: job.year || new Date().getFullYear(), + imdbId: job.imdb_id || `job-${jobId}` + }) + ); + const rawDirName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`); + const rawJobDir = path.join(rawBaseDir, rawDirName); + ensureDir(rawJobDir); + logger.info('rip:raw-dir-created', { jobId, rawJobDir }); + + const device = this.detectedDisc || this.snapshot.context?.device || { + path: job.disc_device, + index: Number(settings.makemkv_source_index || 0) + }; + 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, + ripMode, + playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), + playlistCandidates: playlistDecision.candidatePlaylists, + selectedPlaylist: effectiveSelectedPlaylist, + selectedTitleId: effectiveSelectedTitleId, + preRipSelectionLocked: hasPreRipConfirmedSelection, + selectedMetadata: { + title: job.title || job.detected_title || null, + year: job.year || null, + imdbId: job.imdb_id || null, + poster: job.poster_url || null + } + } + }); + + 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'})` + }); + + 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 + }); + job = await historyService.getJobById(jobId); + + let makemkvInfo = null; + try { + await this.ensureMakeMKVRegistration(jobId, 'RIPPING'); + + const ripConfig = await settingsService.buildMakeMKVRipConfig(rawJobDir, device, { + selectedTitleId: effectiveSelectedTitleId + }); + logger.info('rip:command', { + jobId, + cmd: ripConfig.cmd, + args: ripConfig.args, + ripMode, + selectedPlaylist: effectiveSelectedPlaylistFile, + selectedTitleId: effectiveSelectedTitleId + }); + 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) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Playlist-Obfuscation erkannt: Rip läuft ohne Vorauswahl. Finale Titelwahl erfolgt in der Mediainfo-Prüfung per Checkbox.' + ); + } + if (devicePath) { + diskDetectionService.lockDevice(devicePath, { + jobId, + stage: 'RIPPING', + source: 'MAKEMKV_RIP' + }); + } + try { + makemkvInfo = await this.runCommand({ + jobId, + stage: 'RIPPING', + source: 'MAKEMKV_RIP', + cmd: ripConfig.cmd, + args: ripConfig.args, + parser: parseMakeMkvProgress + }); + } finally { + if (devicePath) { + diskDetectionService.unlockDevice(devicePath, { + jobId, + stage: 'RIPPING', + source: 'MAKEMKV_RIP' + }); + } + } + const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); + await historyService.updateJob(jobId, { + makemkv_info_json: JSON.stringify({ + ...makemkvInfo, + analyzeContext: mkInfoBeforeRip?.analyzeContext || null + }) + }); + + const review = await this.runReviewForRawJob(jobId, rawJobDir, { mode: 'rip' }); + 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 + }); + 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({ + ...error.runInfo, + analyzeContext: mkInfoBeforeRip?.analyzeContext || null + }) + }); + } + 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) { + this.ensureNotBusy('retry'); + logger.info('retry:start', { jobId }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + if (!job.title && !job.detected_title) { + const error = new Error('Retry nicht möglich: keine Metadaten vorhanden.'); + error.statusCode = 400; + throw error; + } + + await historyService.resetProcessLog(jobId); + + 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 + }); + + this.startRipEncode(jobId).catch((error) => { + logger.error('retry:background-failed', { jobId, error: errorToMeta(error) }); + }); + + return { started: true }; + } + + async resumeReadyToEncodeJob(jobId) { + this.ensureNotBusy('resumeReadyToEncodeJob'); + logger.info('resumeReadyToEncodeJob:requested', { jobId }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + 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 inputPath = isPreRipMode + ? null + : (job.encode_input_path || encodePlan?.encodeInputPath || 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, + inputPath, + hasEncodableTitle, + reviewConfirmed, + mode, + sourceJobId: encodePlan?.sourceJobId || null, + selectedMetadata, + mediaInfoReview: encodePlan + } + }); + + await historyService.appendLog( + jobId, + 'USER_ACTION', + 'READY_TO_ENCODE Job nach Neustart ins Dashboard geladen.' + ); + + return historyService.getJobById(jobId); + } + + async restartEncodeWithLastSettings(jobId) { + this.ensureNotBusy('restartEncodeWithLastSettings'); + logger.info('restartEncodeWithLastSettings:requested', { jobId }); + + const job = await historyService.getJobById(jobId); + if (!job) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + 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 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); + if (!reviewConfirmed) { + 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 previousOutputPath = String(job.output_path || '').trim() || null; + + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + end_time: null, + output_path: null, + handbrake_info_json: null + }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + previousOutputPath + ? `Encode-Neustart angefordert. Letzte bestätigte Auswahl wird verwendet. Vorheriger Output-Pfad: ${previousOutputPath}` + : 'Encode-Neustart angefordert. Letzte bestätigte Auswahl wird verwendet.' + ); + + const result = await this.startPreparedJob(jobId); + return { + restarted: true, + ...result + }; + } + + async cancel() { + if (!this.activeProcess) { + const error = new Error('Kein laufender Prozess zum Abbrechen.'); + error.statusCode = 409; + throw error; + } + + logger.warn('cancel:requested', { + state: this.snapshot.state, + activeJobId: this.snapshot.activeJobId + }); + this.cancelRequested = true; + this.activeProcess.cancel(); + } + + async runCommand({ + jobId, + stage, + source, + cmd, + args, + parser, + collectLines = null, + collectStdoutLines = true, + collectStderrLines = true, + argsForLog = null + }) { + const loggableArgs = Array.isArray(argsForLog) ? argsForLog : args; + 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, + 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) { + 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); + void this.updateProgress(stage, progress.percent, progress.eta, statusText); + } else if (detail) { + const statusText = composeStatusText( + stage, + Number(this.snapshot.progress || 0), + runInfo.lastDetail + ); + void this.updateProgress( + stage, + Number(this.snapshot.progress || 0), + this.snapshot.eta, + statusText + ); + } + } + }; + + this.cancelRequested = false; + const processHandle = spawnTrackedProcess({ + cmd, + args, + context: { jobId, stage, source }, + onStdoutLine: (line) => { + if (collectLines && collectStdoutLines) { + collectLines.push(line); + } + void historyService.appendProcessLog(jobId, source, line); + applyLine(line, false); + }, + onStderrLine: (line) => { + if (collectLines && collectStderrLines) { + collectLines.push(line); + } + void historyService.appendProcessLog(jobId, `${source}_ERR`, line); + applyLine(line, true); + } + }); + + this.activeProcess = processHandle; + + 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.cancelRequested) { + 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 { + await historyService.closeProcessLog(jobId); + this.activeProcess = null; + this.cancelRequested = false; + } + } + + async failJob(jobId, stage, error) { + const message = error?.message || String(error); + const isCancelled = /abgebrochen/i.test(message); + const job = await historyService.getJobById(jobId); + const title = job?.title || job?.detected_title || `Job #${jobId}`; + logger.error('job:failed', { jobId, stage, error: errorToMeta(error) }); + await historyService.updateJob(jobId, { + status: 'ERROR', + last_state: 'ERROR', + end_time: nowIso(), + error_message: message + }); + await historyService.appendLog(jobId, 'SYSTEM', `Fehler in ${stage}: ${message}`); + + await this.setState('ERROR', { + activeJobId: jobId, + progress: this.snapshot.progress, + eta: null, + statusText: message, + context: { + jobId, + stage, + error: message + } + }); + + void this.notifyPushover(isCancelled ? 'job_cancelled' : 'job_error', { + title: isCancelled ? 'Ripster - Job abgebrochen' : 'Ripster - Job Fehler', + message: `${title} (${stage}): ${message}` + }); + } + +} + +module.exports = new PipelineService(); diff --git a/backend/src/services/processRunner.js b/backend/src/services/processRunner.js new file mode 100644 index 0000000..52abcea --- /dev/null +++ b/backend/src/services/processRunner.js @@ -0,0 +1,99 @@ +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'] + }); + + 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); + } + }); + }); + + const cancel = () => { + if (child.killed) { + return; + } + + logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid }); + child.kill('SIGINT'); + + setTimeout(() => { + if (!child.killed) { + logger.warn('spawn:cancel:force-kill', { cmd, args, context, pid: child.pid }); + child.kill('SIGKILL'); + } + }, 3000); + }; + + return { + child, + promise, + cancel + }; +} + +module.exports = { + spawnTrackedProcess +}; diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js new file mode 100644 index 0000000..b9c4bb3 --- /dev/null +++ b/backend/src/services/settingsService.js @@ -0,0 +1,710 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { 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 DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac']; +const SENSITIVE_SETTING_KEYS = new Set([ + 'makemkv_registration_key', + 'omdb_api_key', + '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'; + +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 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' + }; +} + +class SettingsService { + async getSchemaRows() { + const db = await getDb(); + return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC'); + } + + async getSettingsMap() { + const rows = await this.getFlatSettings(); + const map = {}; + + for (const row of rows) { + map[row.key] = row.value; + } + + return map; + } + + async getFlatSettings() { + 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, + 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 + })); + } + + async getCategorizedSettings() { + const flat = await this.getFlatSettings(); + const byCategory = new Map(); + + for (const item of flat) { + if (!byCategory.has(item.category)) { + byCategory.set(item.category, []); + } + byCategory.get(item.category).push(item); + } + + return Array.from(byCategory.entries()).map(([category, settings]) => ({ + category, + settings + })); + } + + 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); + } + + 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); + } + + logger.info('settings:bulk-updated', { count: normalizedEntries.length }); + return normalizedEntries.map((item) => ({ + key: item.key, + value: item.value + })); + } + + async buildMakeMKVAnalyzeConfig(deviceInfo = null) { + const map = await this.getSettingsMap(); + const cmd = map.makemkv_command; + const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo)]; + logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo }); + return { cmd, args }; + } + + async buildMakeMKVAnalyzePathConfig(sourcePath, options = {}) { + const map = await this.getSettingsMap(); + const cmd = map.makemkv_command; + const sourceArg = `file:${sourcePath}`; + const args = ['-r', '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 map = await this.getSettingsMap(); + 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 = Number(options?.selectedTitleId); + 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 + }); + } + baseArgs = [ + 'backup', + '--decrypt', + sourceArg, + rawJobDir + ]; + } else { + extra = parsedExtra; + const minLength = Number(map.makemkv_min_length_minutes || 60); + const hasExplicitTitle = Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0; + const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all'; + if (hasExplicitTitle) { + baseArgs = [ + 'mkv', + sourceArg, + targetTitle, + rawJobDir + ]; + } else { + baseArgs = [ + '--minlength=' + Math.round(minLength * 60), + 'mkv', + sourceArg, + targetTitle, + rawJobDir + ]; + } + } + logger.debug('cli:makemkv:rip', { + cmd, + args: [...baseArgs, ...extra], + ripMode, + rawJobDir, + deviceInfo, + 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) { + const map = await this.getSettingsMap(); + 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 map = await this.getSettingsMap(); + 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)); + } + baseArgs.push('-Z', map.handbrake_preset); + const extra = splitArgs(map.handbrake_extra_args); + const rawSelection = options?.trackSelection || null; + const hasSelection = rawSelection && typeof rawSelection === 'object'; + + if (!hasSelection) { + logger.debug('cli:handbrake', { + cmd, + args: [...baseArgs, ...extra], + inputFile, + outputFile, + selectedTitleId + }); + return { cmd, args: [...baseArgs, ...extra] }; + } + + const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds); + const subtitleTrackIds = normalizeTrackIds(rawSelection.subtitleTrackIds); + const subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null; + const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null; + const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null; + 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 (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, + subtitleForcedTrackId, + subtitleForcedOnly + } + }); + + return { + cmd, + args, + trackSelection: { + audioTrackIds, + subtitleTrackIds, + subtitleBurnTrackId, + subtitleDefaultTrackId, + subtitleForcedTrackId, + subtitleForcedOnly + } + }; + } + + resolveHandBrakeSourceArg(map, deviceInfo = null) { + if (map.drive_mode === 'explicit') { + const device = String(map.drive_device || '').trim(); + if (!device) { + throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.'); + } + return device; + } + + const detectedPath = String(deviceInfo?.path || '').trim(); + if (detectedPath) { + return detectedPath; + } + + const configuredPath = String(map.drive_device || '').trim(); + if (configuredPath) { + return configuredPath; + } + + return '/dev/sr0'; + } + + async buildHandBrakeScanConfig(deviceInfo = null) { + const map = await this.getSettingsMap(); + 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 map = await this.getSettingsMap(); + 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 map = await this.getSettingsMap(); + 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 mode = map.drive_mode; + if (mode === 'explicit') { + const device = map.drive_device; + if (!device) { + throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.'); + } + return `dev:${device}`; + } + + if (deviceInfo && deviceInfo.index !== undefined && deviceInfo.index !== null) { + return `disc:${deviceInfo.index}`; + } + + return `disc:${map.makemkv_source_index ?? 0}`; + } +} + +module.exports = new SettingsService(); diff --git a/backend/src/services/websocketService.js b/backend/src/services/websocketService.js new file mode 100644 index 0000000..78ae2f3 --- /dev/null +++ b/backend/src/services/websocketService.js @@ -0,0 +1,65 @@ +const { WebSocketServer } = require('ws'); +const logger = require('./logger').child('WS'); + +class WebSocketService { + constructor() { + this.wss = null; + this.clients = new Set(); + } + + init(httpServer) { + if (this.wss) { + return; + } + + this.wss = new WebSocketServer({ server: httpServer, path: '/ws' }); + + this.wss.on('connection', (socket) => { + this.clients.add(socket); + logger.info('client:connected', { clients: this.clients.size }); + + socket.send( + JSON.stringify({ + type: 'WS_CONNECTED', + payload: { connectedAt: new Date().toISOString() } + }) + ); + + socket.on('close', () => { + this.clients.delete(socket); + logger.info('client:closed', { clients: this.clients.size }); + }); + + socket.on('error', () => { + this.clients.delete(socket); + logger.warn('client:error', { clients: this.clients.size }); + }); + }); + } + + broadcast(type, payload) { + if (!this.wss) { + return; + } + + logger.debug('broadcast', { + type, + clients: this.clients.size, + payloadKeys: payload && typeof payload === 'object' ? Object.keys(payload) : [] + }); + + const message = JSON.stringify({ + type, + payload, + timestamp: new Date().toISOString() + }); + + for (const client of this.clients) { + if (client.readyState === client.OPEN) { + client.send(message); + } + } + } +} + +module.exports = new WebSocketService(); 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..023958e --- /dev/null +++ b/backend/src/utils/encodePlan.js @@ -0,0 +1,1017 @@ +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 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 subtitleTracks = 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 + })); + + 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 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', + burnBehavior: normalizeBurnBehavior(profile.subtitleBurnBehavior), + 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 : []; + if (available.length === 0) { + return []; + } + + if (selector.mode === 'none') { + return []; + } + + if (selector.mode === 'all') { + if (selector.firstOnly) { + return [available[0].id]; + } + return available.map((track) => track.id); + } + + if (selector.mode === 'explicit') { + const explicit = available + .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 = available.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 [available[0].id]; + } + + if (trackType === 'audio') { + return [available[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; + if (explicitCopyCodec) { + canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec); + } else if (sourceCodec && normalizedMask.length > 0) { + canCopy = normalizedMask.includes(sourceCodec); + } + + if (canCopy) { + return { + type: 'copy', + encoder: normalizedToken, + label: `Copy (${sourceCodec || 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 subtitlePreviewSummary = !selectedByRule + ? 'Nicht übernommen' + : (subtitleFlags.flags.length > 0 + ? `Übernehmen (${subtitleFlags.flags.join(', ')})` + : 'Übernehmen'); + + return { + ...track, + selectedByRule, + subtitlePreviewSummary, + subtitlePreviewFlags: subtitleFlags.flags, + subtitlePreviewBurnIn: subtitleFlags.burned, + subtitlePreviewForced: subtitleFlags.forced, + subtitlePreviewForcedOnly: subtitleFlags.forcedOnly, + subtitlePreviewDefaultTrack: subtitleFlags.default + }; + }); + + 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..f1705f4 --- /dev/null +++ b/backend/src/utils/files.js @@ -0,0 +1,70 @@ +const fs = require('fs'); +const path = require('path'); + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function sanitizeFileName(input) { + return String(input || 'untitled') + .replace(/[\\/:*?"<>|]/g, '_') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + +function renderTemplate(template, values) { + return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => { + const val = values[key.trim()]; + if (val === undefined || val === null || val === '') { + return 'unknown'; + } + return String(val); + }); +} + +function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) { + const files = findMediaFiles(dirPath, extensions); + if (files.length === 0) { + return null; + } + + return files.reduce((largest, file) => (largest === null || file.size > largest.size ? file : largest), null); +} + +function findMediaFiles(dirPath, extensions = ['.mkv', '.mp4']) { + const results = []; + + function walk(current) { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const abs = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(abs); + } else { + const ext = path.extname(entry.name).toLowerCase(); + if (!extensions.includes(ext)) { + continue; + } + + const stat = fs.statSync(abs); + results.push({ + path: abs, + size: stat.size + }); + } + } + } + + walk(dirPath); + results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path)); + return results; +} + +module.exports = { + ensureDir, + sanitizeFileName, + renderTemplate, + findLargestMediaFile, + findMediaFiles +}; diff --git a/backend/src/utils/playlistAnalysis.js b/backend/src/utils/playlistAnalysis.js new file mode 100644 index 0000000..6fa6589 --- /dev/null +++ b/backend/src/utils/playlistAnalysis.js @@ -0,0 +1,576 @@ +const LARGE_JUMP_THRESHOLD = 20; +const DEFAULT_DURATION_SIMILARITY_SECONDS = 90; + +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: [], + 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 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); + // Prefer explicit title<->playlist map lines from MakeMKV (MSG:3016). + const resolvedPlaylistId = playlistIdFromMap || playlistIdFromField16 || playlistId; + const segmentNumbers = Array.isArray(item.segmentNumbers) ? item.segmentNumbers : []; + const segmentFiles = segmentNumbers + .map((number) => toSegmentFile(number)) + .filter(Boolean); + + return { + ...item, + playlistId: resolvedPlaylistId, + playlistIdFromMap, + playlistIdFromField16, + playlistFile: resolvedPlaylistId ? `${resolvedPlaylistId}.mpls` : null, + durationLabel: item.durationLabel || formatDuration(item.durationSeconds), + 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 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 (MSG bevorzugt)` + ); +} + +function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) { + const parsedTitles = parseAnalyzeTitles(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 candidates = parsedTitles + .filter((item) => Number(item.durationSeconds || 0) >= minSeconds) + .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId); + + const similarityGroups = buildSimilarityGroups(candidates, durationSimilaritySeconds); + const obfuscationDetected = similarityGroups.length > 0; + const primaryGroup = similarityGroups[0] || null; + const evaluatedCandidates = primaryGroup ? scoreCandidates(primaryGroup.titles) : []; + const recommendation = evaluatedCandidates[0] || null; + const candidatePlaylists = primaryGroup + ? uniqueOrdered(primaryGroup.titles.map((item) => item.playlistId).filter(Boolean)) + : []; + const playlistSegments = buildPlaylistSegmentMap(primaryGroup ? primaryGroup.titles : []); + const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles); + + return { + generatedAt: new Date().toISOString(), + minLengthMinutes: Number(minLengthMinutes || 0), + minLengthSeconds: minSeconds, + durationSimilaritySeconds, + titles: parsedTitles, + candidates, + duplicateDurationGroups: similarityGroups, + obfuscationDetected, + manualDecisionRequired: obfuscationDetected, + 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), + ...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..6817f89 --- /dev/null +++ b/backend/src/utils/progressParsers.js @@ -0,0 +1,72 @@ +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) { + const a = Number(prgv[1]); + const b = Number(prgv[2]); + const c = Number(prgv[3]); + + if (c > 0) { + return { percent: clampPercent((a / c) * 100), eta: null }; + } + + if (b > 0) { + return { percent: clampPercent((a / b) * 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; +} + +module.exports = { + parseMakeMkvProgress, + parseHandBrakeProgress +}; 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/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..3579b44 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,65 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE settings_schema ( + key TEXT PRIMARY KEY, + category TEXT NOT NULL, + label TEXT NOT NULL, + type TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 0, + description TEXT, + default_value TEXT, + options_json TEXT, + validation_json TEXT, + order_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE settings_values ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (key) REFERENCES settings_schema(key) ON DELETE CASCADE +); + +CREATE TABLE jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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, + makemkv_info_json TEXT, + handbrake_info_json TEXT, + mediainfo_info_json TEXT, + encode_plan_json TEXT, + encode_input_path TEXT, + encode_review_confirmed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_jobs_status ON jobs(status); +CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC); + +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) +); diff --git a/deploy-ripster.sh b/deploy-ripster.sh new file mode 100755 index 0000000..6bcccae --- /dev/null +++ b/deploy-ripster.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_USER="michael" +REMOTE_HOST="10.10.10.24" +REMOTE_PATH="/home/michael/ripster" +SSH_PASSWORD="rabenNest7$" + +LOCAL_PATH="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}" +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10" +DATA_RELATIVE_DIR="backend/data/***" + +if ! command -v sshpass >/dev/null 2>&1; then + echo "sshpass ist nicht installiert. Bitte installieren, z. B.: sudo apt-get install -y sshpass" + exit 1 +fi + +if [[ "$SSH_PASSWORD" == "CHANGE_ME" ]]; then + echo "Bitte in deploy-ripster.sh den Wert von SSH_PASSWORD setzen." + exit 1 +fi + +echo "Pruefe SSH-Verbindung zu ${REMOTE_USER}@${REMOTE_HOST} ..." +sshpass -p "$SSH_PASSWORD" ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "echo connected" >/dev/null + +echo "Stelle sicher, dass Remote-Ordner ${REMOTE_PATH} existiert ..." +sshpass -p "$SSH_PASSWORD" ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "set -euo pipefail; mkdir -p '${REMOTE_PATH}'" + +echo "Uebertrage lokalen Ordner ${LOCAL_PATH} nach ${REMOTE_TARGET} ..." +echo "backend/data wird weder uebertragen noch auf dem Ziel geloescht: ${DATA_RELATIVE_DIR}" +sshpass -p "$SSH_PASSWORD" rsync -az --progress --delete \ + --exclude "${DATA_RELATIVE_DIR}" \ + --filter "protect ${DATA_RELATIVE_DIR}" \ + --filter "protect debug" \ + -e "ssh $SSH_OPTS" \ + "${LOCAL_PATH}/" "${REMOTE_TARGET}/" + +echo "Fertig: ${LOCAL_PATH} wurde nach ${REMOTE_TARGET} uebertragen (backend/data ausgenommen)." diff --git a/dev-script.sh b/dev-script.sh new file mode 100755 index 0000000..2a930f9 --- /dev/null +++ b/dev-script.sh @@ -0,0 +1,1389 @@ +#!/bin/bash +set -e + +# ===================================== +# StackPulse Dev-Skript – Hauptmenü & Aktionen +# ===================================== + +show_menu() { + cat <<'MENU' +Bitte wähle eine Aktion: + + 1) Neues Feature anlegen + 2) Merge & Commit + 3) Dev-Umgebungen + 4) Hotfix + 5) Branch-Verwaltung + 6) Branch wechseln + 0) Beenden +MENU +} + +pause_for_menu() { + echo "" + read -rp "Zurück zum Hauptmenü mit Enter... " _ +} + +merge_commit_menu() { + local selection + while true; do + echo "" + cat <<'MENU' +Merge & Commit – Aktionen: + + 1) Feature-Branch in dev mergen + 2) dev in master mergen + 3) Änderungen committen & pushen + 4) Docker-Release bauen & pushen + 5) Git-Stashes verwalten + 6) README in Feature-Branches aktualisieren + 0) Zurück +MENU + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + merge_feature_into_dev + pause_for_menu + ;; + 2) + merge_dev_into_master + pause_for_menu + ;; + 3) + push_changes + pause_for_menu + ;; + 4) + docker_release + pause_for_menu + ;; + 5) + manage_stash + pause_for_menu + ;; + 6) + sync_readme_to_features + pause_for_menu + ;; + 0) + break + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +dev_environments_menu() { + local selection + while true; do + echo "" + cat <<'MENU' +Dev-Umgebungen: + + 1) Dev-Umgebung starten + 2) Docker Compose (lokal) starten + 0) Zurück +MENU + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + start_dev_environment + pause_for_menu + ;; + 2) + start_docker_compose + pause_for_menu + ;; + 0) + break + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +hotfix_menu() { + local selection + while true; do + echo "" + cat <<'MENU' +Hotfix: + + 1) Neuen Hotfix erstellen + 2) Hotfix auf master anwenden (Cherry-Pick) + 3) Hotfix auf dev anwenden (Cherry-Pick) + 4) Hotfix auf Feature-Branches anwenden (Cherry-Pick) + 0) Zurück +MENU + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + create_hotfix_branch + pause_for_menu + ;; + 2) + apply_hotfix_to_master + pause_for_menu + ;; + 3) + apply_hotfix_to_dev + pause_for_menu + ;; + 4) + apply_hotfix_to_features + pause_for_menu + ;; + 0) + break + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +create_hotfix_branch() { + local -a TAGS + local selection selected_tag hotfix_name + local version_without_prefix patch_part branch_version branch_name + local -a ver_parts + + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Es gibt noch uncommittete Änderungen. Bitte committen oder stashen, bevor ein Hotfix-Branch erstellt wird." + return 1 + fi + + git fetch --tags >/dev/null 2>&1 || true + mapfile -t TAGS < <(git tag --sort=-version:refname) || true + + if [[ ${#TAGS[@]} -eq 0 ]]; then + echo "Keine Tags vorhanden." + return 1 + fi + + while true; do + echo "Verfügbare Tags:" + local i=1 tag_name + for tag_name in "${TAGS[@]}"; do + echo " $i) $tag_name" + ((i++)) + done + echo " 0) Zurück" + + read -rp "Auswahl: " selection + echo "" + + if [[ "$selection" == "0" ]]; then + return 0 + fi + + if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#TAGS[@]} )); then + selected_tag="${TAGS[selection-1]}" + break + else + echo "Ungültige Auswahl. Bitte erneut versuchen." + fi + done + + while true; do + read -rp "Hotfix-Namen eingeben (nur Buchstaben/Zahlen/._- | 0 zum Abbrechen): " hotfix_name + if [[ "$hotfix_name" == "0" ]]; then + echo "Abgebrochen." + return 0 + fi + if [[ -z "$hotfix_name" ]]; then + echo "Eingabe darf nicht leer sein." + continue + fi + if [[ "$hotfix_name" =~ [^a-zA-Z0-9._-] ]]; then + echo "Ungültiger Name. Erlaubt sind Buchstaben, Zahlen, Punkt, Unterstrich oder Bindestrich." + continue + fi + break + done + + if [[ $selected_tag != v* ]]; then + echo "Tag '$selected_tag' entspricht nicht dem erwarteten Format (vX.Y oder vX.Y.Z)." + return 1 + fi + + version_without_prefix="${selected_tag#v}" + IFS='.' read -r -a ver_parts <<< "$version_without_prefix" + if (( ${#ver_parts[@]} < 2 )); then + echo "Tag '$selected_tag' besitzt nicht genügend Versionsbestandteile." + return 1 + fi + if ! [[ ${ver_parts[0]} =~ ^[0-9]+$ && ${ver_parts[1]} =~ ^[0-9]+$ ]]; then + echo "Tag '$selected_tag' enthält keine numerische Haupt-/Nebenversion." + return 1 + fi + + patch_part=${ver_parts[2]:-0} + if ! [[ $patch_part =~ ^[0-9]+$ ]]; then + echo "Tag '$selected_tag' enthält eine ungültige Patch-Version." + return 1 + fi + + patch_part=$((patch_part + 1)) + ver_parts[2]=$patch_part + + local branch_prefix="v${ver_parts[0]}${ver_parts[1]}" + branch_version="${branch_prefix}.$patch_part" + branch_name="hotfix/${branch_version}-hotfix_${hotfix_name}" + + if git show-ref --verify --quiet "refs/heads/$branch_name"; then + echo "❌ Fehler: Der Branch '$branch_name' existiert lokal bereits." + return 1 + fi + if git ls-remote --heads origin "$branch_name" | grep -q "$branch_name"; then + echo "❌ Fehler: Der Branch '$branch_name' existiert bereits auf Remote." + return 1 + fi + + git checkout -b "$branch_name" "$selected_tag" + git push -u origin "$branch_name" + + echo "✅ Hotfix-Branch '$branch_name' wurde von Tag '$selected_tag' erstellt und gepusht." + echo " Basisversion: $branch_version" +} + +select_hotfix_branch() { + local __resultvar=$1 + local selection + local -a HOTFIX_BRANCHES + local branch + local i + + if [[ -z "$__resultvar" ]]; then + echo "Interner Fehler: Kein Ausgabe-Parameter übergeben." + return 1 + fi + + git fetch origin --prune >/dev/null 2>&1 || true + mapfile -t HOTFIX_BRANCHES < <(git branch -r --format='%(refname:lstrip=3)' | grep '^hotfix/') || true + + if [[ ${#HOTFIX_BRANCHES[@]} -eq 0 ]]; then + echo "Keine Hotfix-Branches gefunden." + return 1 + fi + + while true; do + echo "Verfügbare Hotfix-Branches:" + i=1 + for branch in "${HOTFIX_BRANCHES[@]}"; do + echo " $i) $branch" + ((i++)) + done + echo " 0) Zurück" + + read -rp "Auswahl: " selection + echo "" + + if [[ "$selection" == "0" ]]; then + echo "Abgebrochen." + return 1 + fi + + if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#HOTFIX_BRANCHES[@]} )); then + printf -v "$__resultvar" '%s' "${HOTFIX_BRANCHES[selection-1]}" + return 0 + else + echo "Ungültige Auswahl. Bitte erneut versuchen." + fi + done +} + +apply_hotfix_branch_to_target() { + local HOTFIX_BRANCH="$1" + local TARGET_BRANCH="$2" + local CURRENT_BRANCH REMOTE_HOTFIX base_commit + local -a commit_list + local applied_count skipped_count commit commit_desc + local patch_tmp err_tmp status_output + + if [[ -z "$HOTFIX_BRANCH" || -z "$TARGET_BRANCH" ]]; then + echo "Interner Fehler: Hotfix- oder Ziel-Branch fehlt." + return 1 + fi + + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Es gibt uncommittete Änderungen auf $CURRENT_BRANCH. Bitte bereinigen, bevor Hotfixes angewendet werden." + return 1 + fi + + git fetch origin "$HOTFIX_BRANCH" >/dev/null 2>&1 || true + + if git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then + git checkout "$TARGET_BRANCH" + git pull origin "$TARGET_BRANCH" + else + if ! git ls-remote --heads origin "$TARGET_BRANCH" >/dev/null 2>&1; then + echo "❌ Ziel-Branch '$TARGET_BRANCH' existiert nicht auf origin." + return 1 + fi + git checkout -b "$TARGET_BRANCH" "origin/$TARGET_BRANCH" + fi + + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Bitte stelle sicher, dass $TARGET_BRANCH vor dem Anwenden sauber ist." + return 1 + fi + + REMOTE_HOTFIX="origin/$HOTFIX_BRANCH" + base_commit=$(git merge-base "$TARGET_BRANCH" "$REMOTE_HOTFIX") || true + + if [[ -z "$base_commit" ]]; then + echo "❌ Konnte gemeinsamen Stand zwischen $TARGET_BRANCH und $REMOTE_HOTFIX nicht ermitteln." + return 1 + fi + + mapfile -t commit_list < <(git rev-list --reverse "$base_commit".."$REMOTE_HOTFIX") || true + + if [[ ${#commit_list[@]} -eq 0 ]]; then + echo "ℹ️ $TARGET_BRANCH enthält bereits alle Hotfix-Commits." + if [[ "$CURRENT_BRANCH" != "$TARGET_BRANCH" ]]; then + git checkout "$CURRENT_BRANCH" + fi + return 0 + fi + + echo "Übernehme ${#commit_list[@]} Hotfix-Commit(s) nach $TARGET_BRANCH (ohne Commit) ..." + + applied_count=0 + skipped_count=0 + + for commit in "${commit_list[@]}"; do + commit_desc=$(git show -s --format='%h %s' "$commit") + echo "➡️ Übertrage $commit_desc" + + patch_tmp=$(mktemp) + err_tmp=$(mktemp) + + if ! git format-patch -1 --stdout "$commit" > "$patch_tmp"; then + echo "❌ Konnte Patch für $commit_desc nicht erstellen." + rm -f "$patch_tmp" "$err_tmp" + return 1 + fi + + if git apply --check --index --3way "$patch_tmp" >/dev/null 2>"$err_tmp"; then + if git apply --index --3way "$patch_tmp" >/dev/null 2>>"$err_tmp"; then + ((applied_count++)) + else + echo "❌ Fehler beim Anwenden von $commit_desc." + cat "$err_tmp" + rm -f "$patch_tmp" "$err_tmp" + return 1 + fi + else + if grep -qi 'already applied' "$err_tmp"; then + echo "ℹ️ $commit_desc ist bereits in $TARGET_BRANCH enthalten – übersprungen." + ((skipped_count++)) + else + echo "❌ Konflikte beim Anwenden von $commit_desc." + cat "$err_tmp" + rm -f "$patch_tmp" "$err_tmp" + echo " Bitte Konflikte manuell lösen; die Änderungen verbleiben auf $TARGET_BRANCH." + return 1 + fi + fi + + rm -f "$patch_tmp" "$err_tmp" + done + + if (( applied_count > 0 )); then + echo "✅ Hotfix-Änderungen wurden nach $TARGET_BRANCH übertragen. Es wurde kein Commit erstellt." + echo " Bitte Änderungen prüfen, bei Bedarf anpassen und manuell committen/pushen." + else + echo "ℹ️ Keine neuen Hotfix-Änderungen für $TARGET_BRANCH erforderlich. ($skipped_count übersprungen)" + fi + + status_output=$(git status --porcelain) + if [[ "$CURRENT_BRANCH" != "$TARGET_BRANCH" ]]; then + if [[ -z "$status_output" ]]; then + git checkout "$CURRENT_BRANCH" + else + echo "ℹ️ Du befindest dich weiterhin auf $TARGET_BRANCH, um die Änderungen zu prüfen." + echo " Kehre nach Abschluss manuell zu $CURRENT_BRANCH zurück." + fi + fi +} +apply_hotfix_to_master() { + local HOTFIX_BRANCH + if ! select_hotfix_branch HOTFIX_BRANCH; then + return 0 + fi + apply_hotfix_branch_to_target "$HOTFIX_BRANCH" "master" +} + +apply_hotfix_to_dev() { + local HOTFIX_BRANCH + if ! select_hotfix_branch HOTFIX_BRANCH; then + return 0 + fi + apply_hotfix_branch_to_target "$HOTFIX_BRANCH" "dev" +} + +apply_hotfix_to_features() { + local HOTFIX_BRANCH + local -a FEATURE_BRANCHES selection target_branches + local branch choice i + + if ! select_hotfix_branch HOTFIX_BRANCH; then + return 0 + fi + + mapfile -t FEATURE_BRANCHES < <(git branch -r --format='%(refname:lstrip=3)' | grep '^feature/') || true + + if [[ ${#FEATURE_BRANCHES[@]} -eq 0 ]]; then + echo "Keine Feature-Branches gefunden." + return 0 + fi + + echo "Verfügbare Feature-Branches:" + i=1 + for branch in "${FEATURE_BRANCHES[@]}"; do + echo " $i) $branch" + ((i++)) + done + echo " 0) Abbrechen" + + read -rp "Bitte Branch-Auswahl (z.B. 1 3 4 oder 0): " -a selection + echo "" + + if [[ ${selection[0]} == "0" ]]; then + echo "Abgebrochen." + return 0 + fi + + for choice in "${selection[@]}"; do + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#FEATURE_BRANCHES[@]} )); then + target_branches+=("${FEATURE_BRANCHES[choice-1]}") + else + echo "⚠️ Ungültige Auswahl ignoriert: $choice" + fi + done + + if [[ ${#target_branches[@]} -eq 0 ]]; then + echo "Keine gültigen Feature-Branches ausgewählt." + return 0 + fi + + for branch in "${target_branches[@]}"; do + echo "" + echo "➡️ Übernehme Hotfix auf $branch" + if ! apply_hotfix_branch_to_target "$HOTFIX_BRANCH" "$branch"; then + echo "❌ Abbruch nach Fehler in $branch." + return 1 + fi + done + + echo "" + echo "✅ Hotfix wurde auf alle ausgewählten Feature-Branches angewendet." +} + +create_new_feature() { + local dev_branch="dev" + local feature_name feature_branch base_commit + + read -rp "Bitte den Namen des neuen Features eingeben: " feature_name + if [[ -z "$feature_name" ]]; then + echo "❌ Fehler: Kein Feature-Name angegeben." + return 1 + fi + + if [[ "$feature_name" =~ [^a-zA-Z0-9._-] ]]; then + echo "❌ Fehler: Ungültiger Branch-Name. Erlaubt sind Buchstaben, Zahlen, Punkt, Unterstrich oder Bindestrich." + return 1 + fi + + feature_branch="feature/$feature_name" + + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Es gibt noch uncommittete Änderungen. Bitte committen oder stashen, bevor ein neuer Branch erstellt wird." + return 1 + fi + + if git show-ref --verify --quiet "refs/heads/$feature_branch"; then + echo "❌ Fehler: Der Branch '$feature_branch' existiert lokal bereits." + return 1 + fi + + if git ls-remote --heads origin "$feature_branch" | grep -q "$feature_branch"; then + echo "❌ Fehler: Der Branch '$feature_branch' existiert bereits auf Remote." + return 1 + fi + + git checkout "$dev_branch" + git pull origin "$dev_branch" + + git checkout -b "$feature_branch" "$dev_branch" + git push -u origin "$feature_branch" + + base_commit=$(git rev-parse --short HEAD) + + echo "✅ Neuer Feature-Branch '$feature_branch' wurde erstellt und auf Remote gepusht." + echo " Basis: $dev_branch@$base_commit" +} + +merge_feature_into_dev() { + local DEV_BRANCH="dev" + local FEATURE_BRANCH branch_name + local choice i + local -a BRANCH_ARRAY + + mapfile -t BRANCH_ARRAY < <(git branch -r --format='%(refname:lstrip=3)' | grep '^feature/') || true + + if [[ ${#BRANCH_ARRAY[@]} -eq 0 ]]; then + echo "Keine Feature-Branches vorhanden." + return 0 + fi + + while true; do + echo "Verfügbare Feature-Branches:" + i=1 + for branch_name in "${BRANCH_ARRAY[@]}"; do + echo " $i) $branch_name" + ((i++)) + done + echo " 0) Zurück" + + read -rp "Auswahl: " choice + echo "" + + if [[ "$choice" == "0" ]]; then + return 0 + fi + + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#BRANCH_ARRAY[@]} )); then + FEATURE_BRANCH="${BRANCH_ARRAY[choice-1]}" + break + else + echo "Ungültige Auswahl. Bitte erneut versuchen." + fi + done + + echo "Ausgewählter Feature-Branch: $FEATURE_BRANCH" + + git checkout "$DEV_BRANCH" + git pull origin "$DEV_BRANCH" + + if ! git show-ref --verify --quiet "refs/heads/$FEATURE_BRANCH"; then + git checkout -b "$FEATURE_BRANCH" "origin/$FEATURE_BRANCH" + else + git checkout "$FEATURE_BRANCH" + git pull origin "$FEATURE_BRANCH" + fi + + git checkout "$DEV_BRANCH" + git merge --no-ff "$FEATURE_BRANCH" -m "Merge $FEATURE_BRANCH into $DEV_BRANCH" + + git push origin "$DEV_BRANCH" + + echo "Feature $FEATURE_BRANCH wurde erfolgreich in $DEV_BRANCH gemerged." +} + + +sync_readme_to_features() { + local BASE_BRANCH="dev" + local MASTER_BRANCH="master" + local -a FEATURE_BRANCHES TARGET_BRANCHES selection + local -a ALL_BRANCHES + local choice branch i + local readme_path="README.md" + + if [[ ! -f "$readme_path" ]]; then + echo "❌ README.md nicht gefunden." + return 1 + fi + + mapfile -t FEATURE_BRANCHES < <(git branch -r --format='%(refname:lstrip=3)' | grep '^feature/') || true + ALL_BRANCHES=("${FEATURE_BRANCHES[@]}" "master") + + if [[ ${#ALL_BRANCHES[@]} -eq 0 ]]; then + echo "Keine geeigneten Branches gefunden." + return 0 + fi + + echo "Verfügbare Branches:" + i=1 + for branch in "${ALL_BRANCHES[@]}"; do + echo " $i) $branch" + ((i++)) + done + echo " 0) Abbrechen" + + read -rp "Bitte Branch-Auswahl (z.B. 1 3 4 oder 0): " -a selection + echo "" + + if [[ ${selection[0]} == "0" ]]; then + echo "Abgebrochen." + return 0 + fi + + for choice in "${selection[@]}"; do + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#ALL_BRANCHES[@]} )); then + TARGET_BRANCHES+=("${ALL_BRANCHES[choice-1]}") + else + echo "⚠️ Ungültige Auswahl ignoriert: $choice" + fi + done + + if [[ ${#TARGET_BRANCHES[@]} -eq 0 ]]; then + echo "Keine gültigen Branches ausgewählt." + return 0 + fi + + local CURRENT_BRANCH + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + + git checkout "$BASE_BRANCH" + git pull origin "$BASE_BRANCH" + + for branch in "${TARGET_BRANCHES[@]}"; do + echo "\n➡️ Synchronisiere README in $branch" + + if ! git show-ref --verify --quiet "refs/heads/$branch"; then + git checkout -b "$branch" "origin/$branch" + else + git checkout "$branch" + git pull origin "$branch" + fi + + git checkout "$BASE_BRANCH" -- "$readme_path" + git add "$readme_path" + git commit -m "Sync README from $BASE_BRANCH" || echo "Keine README-Änderungen in $branch" + git push origin "$branch" + + git checkout "$BASE_BRANCH" + done + + git checkout "$CURRENT_BRANCH" + echo "\n✅ README wurde in den ausgewählten Feature-Branches aktualisiert." +} + + +merge_dev_into_master() { + local DEV_BRANCH="dev" + local MASTER_BRANCH="master" + + # Auf master wechseln und aktuell holen + git checkout "$MASTER_BRANCH" + git pull origin "$MASTER_BRANCH" + + # Dev aktuell holen + if ! git show-ref --verify --quiet "refs/heads/$DEV_BRANCH"; then + git checkout -b "$DEV_BRANCH" "origin/$DEV_BRANCH" + else + git checkout "$DEV_BRANCH" + git pull origin "$DEV_BRANCH" + fi + + # Merge Dev in Master + git checkout "$MASTER_BRANCH" + git merge --no-ff "$DEV_BRANCH" -m "Merge $DEV_BRANCH into $MASTER_BRANCH" + + # Push Master auf Remote + git push origin "$MASTER_BRANCH" + + echo "Branch $DEV_BRANCH wurde in $MASTER_BRANCH gemerged." +} + + +push_changes() { + set -e + + # Aktuellen Branch herausfinden + local BRANCH + BRANCH=$(git rev-parse --abbrev-ref HEAD) + + # Branch bestätigen + read -rp "Aktueller Branch: $BRANCH. Ist das korrekt? (y/n): " CONFIRM + if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then + echo "Abgebrochen." + return 1 + fi + + # Commit-Nachricht + local COMMIT_MSG + read -rp "Bitte Commit-Nachricht eingeben (default: 'Update'): " COMMIT_MSG + COMMIT_MSG=${COMMIT_MSG:-Update} + + # Änderungen stagen und committen + echo "Änderungen werden gestaged..." + git add . + echo "Commit wird erstellt..." + git commit -m "$COMMIT_MSG" || echo "Nichts zu committen" + + local VERSION_TAG="" + # Prüfen, ob Branch master ist + if [[ "$BRANCH" == "master" ]]; then + # Versionsnummer abfragen (0 bedeutet: kein Tag) + while true; do + read -rp "Bitte Versionsnummer für Master-Release Tag eingeben (oder 0 für keinen Tag): " VERSION_TAG + if [[ -n "$VERSION_TAG" ]]; then + break + else + echo "Eingabe darf nicht leer sein. Bitte eingeben." + fi + done + fi + + # Push Branch + echo "Push nach origin/$BRANCH..." + git push -f origin "$BRANCH" + + # Tag setzen und pushen, nur bei master und wenn nicht 0 + if [[ "$BRANCH" == "master" && "$VERSION_TAG" != "0" ]]; then + git tag -a "$VERSION_TAG" -m "Release $VERSION_TAG" + git push origin -f "$VERSION_TAG" + echo "Tag $VERSION_TAG gesetzt und gepusht." + else + if [[ "$BRANCH" == "master" && "$VERSION_TAG" == "0" ]]; then + echo "Kein Tag gesetzt (manuelle Auswahl: 0)." + fi + fi + + echo "Push abgeschlossen." +} + + + +docker_release() { + local ghcr_username="mboehmlaender" + local repo_name="stackpulse" + local branch version_tag + + + + + while true; do + read -rp "Bitte Versionsnummer für das Docker-Image eingeben (z.B. v0.1): " version_tag + if [[ -n "$version_tag" ]]; then + break + fi + echo "Versionsnummer darf nicht leer sein." + done + + branch=$(git rev-parse --abbrev-ref HEAD) + + if [[ "$branch" != "master" ]]; then + echo "Fehler: Du musst auf 'master' sein, um ein Release zu machen." + return 1 + fi + + if [[ -z "$CR_PAT" ]]; then + echo "CR_PAT (GitHub Token) nicht gesetzt! Bitte export CR_PAT=" + return 1 + fi + + echo "$CR_PAT" | docker login ghcr.io -u "$ghcr_username" --password-stdin + docker build -t "ghcr.io/$ghcr_username/$repo_name:$version_tag" . + docker tag "ghcr.io/$ghcr_username/$repo_name:$version_tag" "ghcr.io/$ghcr_username/$repo_name:latest" + + docker push "ghcr.io/$ghcr_username/$repo_name:$version_tag" + docker push "ghcr.io/$ghcr_username/$repo_name:latest" + + echo "Docker-Release $version_tag erfolgreich gebaut und zu GHCR gepusht!" +} + + + +sync_frontend_build() { + local source_dir="$1" + local target_dir="$2" + + if [[ -z "$source_dir" || -z "$target_dir" ]]; then + echo "sync_frontend_build benötigt Quell- und Zielverzeichnis." + return 1 + fi + + if [[ ! -d "$source_dir" ]]; then + echo "⚠️ Build-Verzeichnis '$source_dir' wurde nicht gefunden." + return 1 + fi + + mkdir -p "$target_dir" + + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "$source_dir"/ "$target_dir"/ + else + find "$target_dir" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -R "$source_dir"/. "$target_dir"/ + fi + + echo "✅ Frontend-Build nach '$target_dir' synchronisiert." +} + +normalize_semver() { + local version="$1" + echo "${version#v}" | sed -E 's/[^0-9.].*$//' +} + +version_gte() { + local current required + local c_major c_minor c_patch r_major r_minor r_patch + + current=$(normalize_semver "$1") + required=$(normalize_semver "$2") + + IFS='.' read -r c_major c_minor c_patch <<< "$current" + IFS='.' read -r r_major r_minor r_patch <<< "$required" + + c_major=${c_major:-0} + c_minor=${c_minor:-0} + c_patch=${c_patch:-0} + r_major=${r_major:-0} + r_minor=${r_minor:-0} + r_patch=${r_patch:-0} + + if (( c_major > r_major )); then return 0; fi + if (( c_major < r_major )); then return 1; fi + if (( c_minor > r_minor )); then return 0; fi + if (( c_minor < r_minor )); then return 1; fi + if (( c_patch >= r_patch )); then return 0; fi + return 1 +} + +load_nvm_if_available() { + if command -v nvm >/dev/null 2>&1; then + return 0 + fi + + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [[ -s "$NVM_DIR/nvm.sh" ]]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 + fi + + command -v nvm >/dev/null 2>&1 +} + +try_use_project_node() { + local project_root="$1" + local required_version="$2" + local current_version + + current_version=$(node -v 2>/dev/null || true) + if [[ -n "$current_version" ]] && version_gte "$current_version" "$required_version"; then + return 0 + fi + + if ! load_nvm_if_available; then + return 1 + fi + + pushd "$project_root" >/dev/null + if [[ -f ".nvmrc" ]]; then + nvm use --silent >/dev/null 2>&1 || true + fi + popd >/dev/null + + current_version=$(node -v 2>/dev/null || true) + if [[ -n "$current_version" ]] && version_gte "$current_version" "$required_version"; then + echo "ℹ️ Projekt-Node aktiviert: ${current_version}" + return 0 + fi + + return 1 +} + +ensure_minimum_node_version() { + local project_root="$1" + local min_version="20.19.0" + local current_version + + if ! command -v node >/dev/null 2>&1; then + echo "❌ Node.js wurde nicht gefunden. Bitte Node.js >= ${min_version} installieren." + return 1 + fi + + try_use_project_node "$project_root" "$min_version" || true + + current_version=$(node -v 2>/dev/null || true) + if [[ -z "$current_version" ]]; then + echo "❌ Konnte die Node.js-Version nicht ermitteln." + return 1 + fi + + if ! version_gte "$current_version" "$min_version"; then + echo "❌ Node.js ${current_version} erkannt. Für StackPulse wird Node.js >= ${min_version} benötigt." + echo " Projektlokal (ohne andere Projekte zu ändern):" + echo " nvm install ${min_version} && nvm use" + return 1 + fi +} + +ensure_backend_sqlite_binding() { + if [[ ! -d node_modules/better-sqlite3 ]]; then + return 0 + fi + + if node -e "require('better-sqlite3')" >/dev/null 2>&1; then + return 0 + fi + + echo "⚠️ better-sqlite3 ist nicht mit der aktuellen Node-Version kompatibel. Versuche Rebuild ..." + npm rebuild better-sqlite3 + + if ! node -e "require('better-sqlite3')" >/dev/null 2>&1; then + echo "❌ better-sqlite3 konnte weiterhin nicht geladen werden." + echo " Bitte im Ordner 'backend' ausführen: rm -rf node_modules package-lock.json && npm install" + return 1 + fi + + echo "✅ better-sqlite3 wurde erfolgreich für $(node -v) vorbereitet." +} + +start_dev_environment() { + local back_pid front_pid + local script_dir project_root + + echo "🚀 Starte StackPulse Dev-Umgebung..." + script_dir="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + project_root="$(cd -- "$script_dir/.." && pwd)" + ensure_minimum_node_version "$project_root" || return 1 + + pushd "$project_root/backend" >/dev/null + read -rp "➡️ Backend: npm install ausführen? (y/N) " backend_install_choice + if [[ "$backend_install_choice" =~ ^([YyJj]|[Yy]es|[Jj]a)$ ]]; then + npm install + else + echo "ℹ️ Backend npm install übersprungen." + fi + ensure_backend_sqlite_binding || return 1 + npm run migrate + npm start & + back_pid=$! + popd >/dev/null + + pushd "$project_root/frontend" >/dev/null + read -rp "➡️ Frontend: npm install ausführen? (y/N) " frontend_install_choice + if [[ "$frontend_install_choice" =~ ^([YyJj]|[Yy]es|[Jj]a)$ ]]; then + npm install + else + echo "ℹ️ Frontend npm install übersprungen." + fi + npm run build + sync_frontend_build "dist" "../backend/public" + npm run dev -- --host & + front_pid=$! + popd >/dev/null + + echo "" + echo "✅ StackPulse läuft lokal:" + echo "Frontend (Vite Dev): http://localhost:5173" + echo "Backend API: http://localhost:4001" + echo "Die gebaute Oberfläche liegt unter backend/public" + echo "Beenden mit STRG+C" + + wait "$back_pid" "$front_pid" +} + +start_docker_compose() { + echo "🔄 Starte lokale Docker-Umgebung..." + docker compose -f docker-compose.dev.yml up --build --force-recreate +} + +manage_stash() { + local current_branch action user_input stash_name choice selected_stash + local -A stash_map + local -a stash_list + local i line stash_ref stash_msg + + current_branch=$(git rev-parse --abbrev-ref HEAD) + + echo "Aktueller Branch: $current_branch" + echo "Was möchtest du tun?" + echo "1) Neuen Stash anlegen" + echo "2) Vorhandenen Stash laden (apply)" + echo "3) Vorhandenen Stash löschen" + echo "4) Stash anwenden und löschen (pop)" + echo "0) Zurück" + read -rp "Auswahl: " action + + case $action in + 0) + return 0 + ;; + 1) + read -rp "Gib einen Namen für den Stash ein: " user_input + stash_name="$current_branch - $user_input" + + if git stash list | grep -q "$stash_name"; then + while IFS= read -r line; do + stash_ref=$(echo "$line" | awk -F: '{print $1}') + echo "Lösche vorhandenen Stash: $stash_ref" + git stash drop "$stash_ref" + done < <(git stash list | grep "$stash_name") + fi + + git stash push -u -m "$stash_name" + echo "Stash '$stash_name' wurde angelegt." + ;; + + 2|3|4) + local action_text + case $action in + 2) action_text="laden" ;; + 3) action_text="löschen" ;; + 4) action_text="anwenden & löschen" ;; + esac + + echo "Liste aller Stashes für Branch '$current_branch':" + mapfile -t stash_list < <(git stash list | grep "$current_branch") || true + + if [[ ${#stash_list[@]} -eq 0 ]]; then + echo "Keine Stashes für diesen Branch vorhanden." + return 0 + fi + + i=1 + for line in "${stash_list[@]}"; do + stash_ref=$(echo "$line" | awk -F: '{print $1}') + stash_msg=$(echo "$line" | cut -d':' -f3- | sed 's/^ //') + echo " $i) $stash_ref -> $stash_msg" + stash_map[$i]=$stash_ref + ((i++)) + done + echo " 0) Zurück" + + read -rp "Wähle einen Stash zum ${action_text} (Nummer): " choice + if [[ "$choice" == "0" ]]; then + return 0 + fi + if [[ -z "${stash_map[$choice]}" ]]; then + echo "Ungültige Auswahl!" + return 1 + fi + + selected_stash=${stash_map[$choice]} + + case $action in + 2) + echo "Wende Stash an: $selected_stash" + git stash apply "$selected_stash" + ;; + 3) + echo "Lösche Stash: $selected_stash" + git stash drop "$selected_stash" + ;; + 4) + echo "Wende Stash an und lösche ihn: $selected_stash" + git stash pop "$selected_stash" + ;; + esac + ;; + + *) + echo "Ungültige Auswahl!" + return 1 + ;; + esac +} + + +branch_management_menu() { + local selection + while true; do + echo "" + cat <<'MENU' +Branch-Verwaltung: + + 1) Branch löschen (lokal/remote) + 2) Branch-Listen aktualisieren + 0) Zurück +MENU + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + delete_branch + pause_for_menu + ;; + 2) + update_branch_lists + pause_for_menu + ;; + 0) + break + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +delete_branch() { + local -a local_branches remote_branches all_branches + local -A has_local has_remote branch_map + local current_branch branch label selection selected_branch delete_choice + local i=1 + + current_branch=$(git rev-parse --abbrev-ref HEAD) + + mapfile -t local_branches < <(git branch --format='%(refname:short)') + mapfile -t remote_branches < <(git branch -r | grep -v 'HEAD' | sed 's|^[[:space:]]*origin/||') + mapfile -t all_branches < <(printf "%s\n" "${local_branches[@]}" "${remote_branches[@]}" | sed '/^$/d' | sort -u) + + for branch in "${local_branches[@]}"; do + [[ -n "$branch" ]] && has_local["$branch"]=1 + done + for branch in "${remote_branches[@]}"; do + [[ -n "$branch" ]] && has_remote["$branch"]=1 + done + + if [[ ${#all_branches[@]} -eq 0 ]]; then + echo "Keine Branches zum Löschen gefunden." + return 1 + fi + + echo "Verfügbare Branches zum Löschen:" + for branch in "${all_branches[@]}"; do + label="" + if [[ -n "${has_local[$branch]}" && -n "${has_remote[$branch]}" ]]; then + label="(lokal & remote)" + elif [[ -n "${has_local[$branch]}" ]]; then + label="(nur lokal)" + else + label="(nur remote)" + fi + + printf " %d) %s %s\n" "$i" "$branch" "$label" + branch_map[$i]=$branch + ((i++)) + done + echo " 0) Zurück" + + read -rp "Wähle einen Branch (Nummer): " selection + if [[ "$selection" == "0" ]]; then + echo "Abgebrochen." + return 0 + fi + if [[ -z "${branch_map[$selection]}" ]]; then + echo "Ungültige Auswahl." + return 1 + fi + + selected_branch=${branch_map[$selection]} + + if [[ "$selected_branch" == "$current_branch" && -n "${has_local[$selected_branch]}" ]]; then + echo "Der aktuell ausgecheckte Branch '$selected_branch' kann nicht gelöscht werden." + return 1 + fi + + if [[ -n "${has_local[$selected_branch]}" && -n "${has_remote[$selected_branch]}" ]]; then + echo "" + echo "Branch '$selected_branch' existiert lokal und remote." + echo " 1) Nur lokal löschen" + echo " 2) Nur remote löschen" + echo " 3) Lokal und remote löschen" + read -rp "Auswahl: " delete_choice + echo "" + case $delete_choice in + 1) + delete_choice="local" + ;; + 2) + delete_choice="remote" + ;; + 3) + delete_choice="both" + ;; + *) + echo "Ungültige Auswahl." + return 1 + ;; + esac + elif [[ -n "${has_local[$selected_branch]}" ]]; then + delete_choice="local" + else + delete_choice="remote" + fi + + if [[ "$delete_choice" == "local" || "$delete_choice" == "both" ]]; then + read -rp "Lokalen Branch '$selected_branch' wirklich löschen? (j/N): " confirmation + if [[ "$confirmation" =~ ^[JjYy]$ ]]; then + if git branch -D "$selected_branch"; then + echo "Lokaler Branch '$selected_branch' wurde gelöscht." + else + echo "Fehler beim Löschen des lokalen Branches '$selected_branch'." + return 1 + fi + else + echo "Löschen des lokalen Branches abgebrochen." + [[ "$delete_choice" == "local" ]] && return 0 + fi + fi + + if [[ "$delete_choice" == "remote" || "$delete_choice" == "both" ]]; then + read -rp "Remote-Branch '$selected_branch' auf origin wirklich löschen? (j/N): " confirmation + if [[ "$confirmation" =~ ^[JjYy]$ ]]; then + if git push origin --delete "$selected_branch"; then + echo "Remote-Branch 'origin/$selected_branch' wurde gelöscht." + else + echo "Fehler beim Löschen des Remote-Branches '$selected_branch'." + return 1 + fi + else + echo "Löschen des Remote-Branches abgebrochen." + return 0 + fi + fi + + return 0 +} + +update_branch_lists() { + echo "Aktualisiere Branch-Listen (git fetch origin --prune)..." + if git fetch origin --prune; then + echo "Branch-Listen wurden aktualisiert." + else + echo "Fehler beim Aktualisieren der Branch-Listen." + return 1 + fi +} + +switch_branch() { + local -a unversioned_files=("devscripts/*") + local file + local local_branches remote_branches all_branches master_branch dev_branch feature_branches hotfix_branches other_branches + local -a sorted_branches + local i=1 choice selected_branch + declare -A branch_map + + for file in "${unversioned_files[@]}"; do + if [[ -f "$file" ]]; then + mkdir -p /tmp/git_safe_backup + cp "$file" "/tmp/git_safe_backup/$(basename "$file")" + fi + done + local_branches=$(git branch | sed 's/* //' | sed 's/^[[:space:]]*//') + remote_branches=$(git branch -r | grep -v 'HEAD' | sed 's|^[[:space:]]*origin/||' | sed 's/^[[:space:]]*//') + all_branches=$(printf "%s\n%s\n" "$local_branches" "$remote_branches" | sort -u) + + master_branch=$(echo "$all_branches" | grep -x 'master' || true) + dev_branch=$(echo "$all_branches" | grep -x 'dev' || true) + feature_branches=$(echo "$all_branches" | grep '^feature/' | sort || true) + hotfix_branches=$(echo "$all_branches" | grep '^hotfix/' | sort || true) + other_branches=$(echo "$all_branches" | grep -Ev '^(master|dev|feature/|hotfix/)' | sort || true) + + [[ -n "$master_branch" ]] && sorted_branches+=("$master_branch") + [[ -n "$dev_branch" ]] && sorted_branches+=("$dev_branch") + if [[ -n "$feature_branches" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] && sorted_branches+=("$line") + done <<< "$feature_branches" + fi + if [[ -n "$hotfix_branches" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] && sorted_branches+=("$line") + done <<< "$hotfix_branches" + fi + if [[ -n "$other_branches" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] && sorted_branches+=("$line") + done <<< "$other_branches" + fi + + if [[ ${#sorted_branches[@]} -eq 0 ]]; then + echo "Keine Branches gefunden." + return 1 + fi + + echo "Verfügbare Branches:" + for line in "${sorted_branches[@]}"; do + echo " $i) $line" + branch_map[$i]=$line + ((i++)) + done + echo " 0) Zurück" + + read -rp "Wähle einen Branch (Nummer): " choice + if [[ "$choice" == "0" ]]; then + rm -rf /tmp/git_safe_backup + echo "Abgebrochen." + return 0 + fi + if [[ -z "${branch_map[$choice]}" ]]; then + echo "Ungültige Auswahl!" + rm -rf /tmp/git_safe_backup + return 1 + fi + + selected_branch=${branch_map[$choice]} + echo "Wechsle zu Branch: $selected_branch" + + git fetch origin + + if git show-ref --verify --quiet "refs/heads/$selected_branch"; then + git checkout "$selected_branch" + else + git checkout -b "$selected_branch" "origin/$selected_branch" + fi + + git reset --hard "origin/$selected_branch" + git clean -fd + + for file in "${unversioned_files[@]}"; do + if [[ -f "/tmp/git_safe_backup/$(basename "$file")" ]]; then + mkdir -p "$(dirname "$file")" + mv "/tmp/git_safe_backup/$(basename "$file")" "$file" + fi + done + rm -rf /tmp/git_safe_backup + + echo "Branch '$selected_branch' ist nun aktiv. Arbeitsverzeichnis entspricht exakt dem Remote-Stand." + echo "Gesicherte Dateien wurden wiederhergestellt." +} + +main() { + local selection + while true; do + echo "" + show_menu + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + create_new_feature + pause_for_menu + ;; + 2) + merge_commit_menu + ;; + 3) + dev_environments_menu + ;; + 4) + hotfix_menu + ;; + 5) + branch_management_menu + ;; + 6) + switch_branch + pause_for_menu + ;; + 0) + echo "Auf Wiedersehen!" + exit 0 + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +main "$@" diff --git a/docs/api/history.md b/docs/api/history.md new file mode 100644 index 0000000..df6457b --- /dev/null +++ b/docs/api/history.md @@ -0,0 +1,222 @@ +# History API + +Endpunkte für die Job-Histoire, Dateimanagement und Orphan-Import. + +--- + +## GET /api/history + +Gibt eine Liste aller Jobs zurück, optional gefiltert. + +**Query-Parameter:** + +| Parameter | Typ | Beschreibung | +|----------|-----|-------------| +| `status` | string | Filtert nach Status (z.B. `FINISHED`, `ERROR`) | +| `search` | string | Sucht in Filmtiteln | + +**Beispiel:** + +``` +GET /api/history?status=FINISHED&search=Inception +``` + +**Response:** + +```json +{ + "jobs": [ + { + "id": 42, + "status": "FINISHED", + "title": "Inception", + "imdb_id": "tt1375666", + "omdb_year": "2010", + "omdb_type": "movie", + "omdb_poster": "https://...", + "raw_path": "/mnt/nas/raw/Inception_t00.mkv", + "output_path": "/mnt/nas/movies/Inception (2010).mkv", + "created_at": "2024-01-15T10:00:00.000Z", + "updated_at": "2024-01-15T12:30:00.000Z" + } + ], + "total": 1 +} +``` + +--- + +## GET /api/history/:id + +Gibt Detail-Informationen für einen einzelnen Job zurück. + +**URL-Parameter:** `id` – Job-ID + +**Query-Parameter:** + +| Parameter | Typ | Standard | Beschreibung | +|----------|-----|---------|-------------| +| `includeLogs` | boolean | `false` | Log-Inhalte einschließen | +| `includeLiveLog` | boolean | `false` | Aktuellen Live-Log einschließen | + +**Response:** + +```json +{ + "id": 42, + "status": "FINISHED", + "title": "Inception", + "imdb_id": "tt1375666", + "encode_plan": { ... }, + "makemkv_output": { ... }, + "mediainfo_output": { ... }, + "handbrake_log": "/path/to/log", + "logs": { + "handbrake": "Encoding: task 1 of 1, 100.0%\n..." + }, + "created_at": "2024-01-15T10:00:00.000Z", + "updated_at": "2024-01-15T12:30:00.000Z" +} +``` + +--- + +## GET /api/history/database + +Gibt alle rohen Datenbankzeilen zurück (Debug-Ansicht). + +**Response:** + +```json +{ + "jobs": [ { "id": 1, "status": "FINISHED", ... } ], + "total": 15 +} +``` + +--- + +## GET /api/history/orphan-raw + +Findet Raw-Ordner, die nicht als Jobs in der Datenbank registriert sind. + +**Response:** + +```json +{ + "orphans": [ + { + "path": "/mnt/nas/raw/UnknownMovie_2023-12-01", + "size": "45.2 GB", + "modifiedAt": "2023-12-01T15:00:00.000Z", + "files": ["t00.mkv", "t01.mkv"] + } + ] +} +``` + +--- + +## POST /api/history/orphan-raw/import + +Importiert einen Orphan-Raw-Ordner als Job in die Datenbank. + +**Request:** + +```json +{ + "path": "/mnt/nas/raw/UnknownMovie_2023-12-01" +} +``` + +**Response:** + +```json +{ + "ok": true, + "jobId": 99, + "message": "Orphan-Ordner als Job importiert" +} +``` + +Nach dem Import kann dem Job über `/api/history/:id/omdb/assign` Metadaten zugewiesen werden. + +--- + +## POST /api/history/:id/omdb/assign + +Weist einem bestehenden Job OMDb-Metadaten nachträglich zu. + +**URL-Parameter:** `id` – Job-ID + +**Request:** + +```json +{ + "imdbId": "tt1375666", + "title": "Inception", + "year": "2010", + "type": "movie", + "poster": "https://..." +} +``` + +**Response:** + +```json +{ "ok": true } +``` + +--- + +## POST /api/history/:id/delete-files + +Löscht die Dateien eines Jobs (Raw und/oder Output), behält den Job-Eintrag. + +**URL-Parameter:** `id` – Job-ID + +**Request:** + +```json +{ + "deleteRaw": true, + "deleteOutput": false +} +``` + +**Response:** + +```json +{ + "ok": true, + "deleted": { + "raw": "/mnt/nas/raw/Inception_t00.mkv", + "output": null + } +} +``` + +--- + +## POST /api/history/:id/delete + +Löscht den Job-Eintrag aus der Datenbank, optional auch die Dateien. + +**URL-Parameter:** `id` – Job-ID + +**Request:** + +```json +{ + "deleteFiles": true +} +``` + +**Response:** + +```json +{ "ok": true, "message": "Job gelöscht" } +``` + +!!! warning "Unwiderruflich" + Das Löschen von Jobs und Dateien ist nicht rückgängig zu machen. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..9027d85 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,85 @@ +# API-Referenz + +Ripster bietet eine **REST-API** für alle Operationen sowie einen **WebSocket-Endpunkt** für Echtzeit-Updates. + +--- + +## Basis-URL + +``` +http://localhost:3001 +``` + +Konfigurierbar über die Umgebungsvariable `PORT`. + +--- + +## API-Gruppen + +
+ +- :material-pipe: **Pipeline API** + + --- + + Pipeline-Steuerung: Analyse starten, Metadaten setzen, Ripping und Encoding steuern. + + [:octicons-arrow-right-24: Pipeline API](pipeline.md) + +- :material-cog: **Settings API** + + --- + + Einstellungen lesen und schreiben. + + [:octicons-arrow-right-24: Settings API](settings.md) + +- :material-history: **History API** + + --- + + Job-Geschichte abfragen, Jobs löschen, Orphan-Ordner importieren. + + [:octicons-arrow-right-24: History API](history.md) + +- :material-lightning-bolt: **WebSocket Events** + + --- + + Echtzeit-Events für Pipeline-Status, Fortschritt und Disc-Erkennung. + + [:octicons-arrow-right-24: WebSocket](websocket.md) + +
+ +--- + +## Authentifizierung + +Die API hat **keine Authentifizierung**. Sie ist für den Einsatz im lokalen Netzwerk konzipiert. + +!!! warning "Produktionsbetrieb" + Falls Ripster öffentlich erreichbar sein soll, schütze die API mit einem Reverse-Proxy (z. B. nginx mit Basic Auth oder OAuth). + +--- + +## Fehlerformat + +Alle API-Fehler werden im folgenden Format zurückgegeben: + +```json +{ + "error": "Job nicht gefunden", + "details": "Kein Job mit ID 999 vorhanden" +} +``` + +HTTP-Statuscodes: + +| Code | Bedeutung | +|-----|-----------| +| `200` | Erfolg | +| `400` | Ungültige Anfrage | +| `404` | Ressource nicht gefunden | +| `409` | Konflikt (z.B. Pipeline bereits aktiv) | +| `500` | Interner Serverfehler | diff --git a/docs/api/pipeline.md b/docs/api/pipeline.md new file mode 100644 index 0000000..069a155 --- /dev/null +++ b/docs/api/pipeline.md @@ -0,0 +1,249 @@ +# Pipeline API + +Alle Endpunkte zur Steuerung des Ripping-Workflows. + +--- + +## GET /api/pipeline/state + +Gibt den aktuellen Pipeline-Zustand zurück. + +**Response:** + +```json +{ + "state": "ENCODING", + "jobId": 42, + "job": { + "id": 42, + "title": "Inception", + "status": "ENCODING", + "imdb_id": "tt1375666", + "omdb_year": "2010" + }, + "progress": 73.5, + "eta": "00:12:34", + "updatedAt": "2024-01-15T14:30:00.000Z" +} +``` + +**States:** + +| Wert | Beschreibung | +|------|-------------| +| `IDLE` | Wartet auf Disc | +| `ANALYZING` | MakeMKV analysiert | +| `METADATA_SELECTION` | Wartet auf Benutzer | +| `READY_TO_START` | Bereit zum Starten | +| `RIPPING` | Rippen läuft | +| `MEDIAINFO_CHECK` | Track-Analyse | +| `READY_TO_ENCODE` | Wartet auf Bestätigung | +| `ENCODING` | Encoding läuft | +| `FINISHED` | Abgeschlossen | +| `ERROR` | Fehler | + +--- + +## POST /api/pipeline/analyze + +Startet eine manuelle Disc-Analyse (ohne Disc-Detection-Trigger). + +**Request:** Kein Body erforderlich + +**Response:** + +```json +{ "ok": true, "message": "Analyse gestartet" } +``` + +**Fehlerfälle:** +- `409` – Pipeline bereits aktiv + +--- + +## POST /api/pipeline/rescan-disc + +Erzwingt eine erneute Disc-Erkennung. + +**Response:** + +```json +{ "ok": true } +``` + +--- + +## GET /api/pipeline/omdb/search + +Sucht in der OMDb-API nach einem Filmtitel. + +**Query-Parameter:** + +| Parameter | Typ | Beschreibung | +|----------|-----|-------------| +| `q` | string | Suchbegriff (Filmtitel) | +| `type` | string | `movie` oder `series` (optional) | + +**Beispiel:** + +``` +GET /api/pipeline/omdb/search?q=Inception&type=movie +``` + +**Response:** + +```json +{ + "results": [ + { + "imdbId": "tt1375666", + "title": "Inception", + "year": "2010", + "type": "movie", + "poster": "https://m.media-amazon.com/images/..." + } + ] +} +``` + +--- + +## POST /api/pipeline/select-metadata + +Bestätigt Metadaten und Playlist-Auswahl für den aktuellen Job. + +**Request:** + +```json +{ + "jobId": 42, + "omdb": { + "imdbId": "tt1375666", + "title": "Inception", + "year": "2010", + "type": "movie", + "poster": "https://..." + }, + "playlist": "00800.mpls" +} +``` + +`playlist` ist optional und nur bei Blu-rays relevant. + +**Response:** + +```json +{ "ok": true } +``` + +--- + +## POST /api/pipeline/start/:jobId + +Startet den Ripping-Prozess für einen vorbereiteten Job. + +**URL-Parameter:** `jobId` – ID des Jobs + +**Response:** + +```json +{ "ok": true, "message": "Ripping gestartet" } +``` + +**Fehlerfälle:** +- `404` – Job nicht gefunden +- `409` – Job nicht im Status `READY_TO_START` + +--- + +## POST /api/pipeline/confirm-encode/:jobId + +Bestätigt die Encode-Konfiguration mit Track-Auswahl. + +**URL-Parameter:** `jobId` – ID des Jobs + +**Request:** + +```json +{ + "audioTracks": [1, 2], + "subtitleTracks": [1] +} +``` + +Track-Indizes entsprechen den 1-basierten Track-Nummern aus dem Encode-Plan. + +**Response:** + +```json +{ "ok": true, "message": "Encoding gestartet" } +``` + +--- + +## POST /api/pipeline/cancel + +Bricht den aktuellen Pipeline-Prozess ab. + +**Response:** + +```json +{ "ok": true, "message": "Pipeline abgebrochen" } +``` + +Der laufende Prozess wird mit SIGINT beendet (Fallback: SIGKILL nach Timeout). + +--- + +## POST /api/pipeline/retry/:jobId + +Wiederholt einen fehlgeschlagenen Job. + +**URL-Parameter:** `jobId` – ID des Jobs + +**Response:** + +```json +{ "ok": true, "message": "Job wird wiederholt" } +``` + +**Fehlerfälle:** +- `404` – Job nicht gefunden +- `409` – Job nicht im Status `ERROR` + +--- + +## POST /api/pipeline/resume-ready/:jobId + +Setzt einen Job im Status `READY_TO_ENCODE` zurück in die aktive Pipeline. + +**URL-Parameter:** `jobId` – ID des Jobs + +**Response:** + +```json +{ "ok": true } +``` + +--- + +## POST /api/pipeline/reencode/:jobId + +Startet ein erneutes Encoding für einen abgeschlossenen Job (ohne erneutes Ripping). + +**URL-Parameter:** `jobId` – ID des Jobs + +**Request:** + +```json +{ + "audioTracks": [1, 2], + "subtitleTracks": [1] +} +``` + +**Response:** + +```json +{ "ok": true, "message": "Re-Encoding gestartet" } +``` diff --git a/docs/api/settings.md b/docs/api/settings.md new file mode 100644 index 0000000..f01d1ce --- /dev/null +++ b/docs/api/settings.md @@ -0,0 +1,140 @@ +# Settings API + +Endpunkte zum Lesen und Schreiben der Anwendungseinstellungen. + +--- + +## GET /api/settings + +Gibt alle Einstellungen kategorisiert zurück. + +**Response:** + +```json +{ + "paths": { + "raw_dir": { + "value": "/mnt/nas/raw", + "schema": { + "type": "string", + "label": "Raw-Verzeichnis", + "description": "Speicherort für rohe MKV-Dateien", + "required": true + } + }, + "movie_dir": { + "value": "/mnt/nas/movies", + "schema": { ... } + } + }, + "tools": { ... }, + "encoding": { ... }, + "drive": { ... }, + "makemkv": { ... }, + "omdb": { ... }, + "notifications": { ... } +} +``` + +--- + +## PUT /api/settings/:key + +Aktualisiert eine einzelne Einstellung. + +**URL-Parameter:** `key` – Einstellungs-Schlüssel + +**Request:** + +```json +{ + "value": "/mnt/storage/raw" +} +``` + +**Response:** + +```json +{ "ok": true, "key": "raw_dir", "value": "/mnt/storage/raw" } +``` + +**Fehlerfälle:** +- `400` – Ungültiger Wert (Validierungsfehler) +- `404` – Einstellung nicht gefunden + +!!! note "Encode-Review-Refresh" + Wenn eine encoding-relevante Einstellung geändert wird (z.B. `handbrake_preset`), wird der Encode-Plan für den aktuell wartenden Job automatisch neu berechnet. + +--- + +## PUT /api/settings + +Aktualisiert mehrere Einstellungen auf einmal. + +**Request:** + +```json +{ + "raw_dir": "/mnt/storage/raw", + "movie_dir": "/mnt/storage/movies", + "handbrake_preset": "H.265 MKV 720p30" +} +``` + +**Response:** + +```json +{ + "ok": true, + "updated": ["raw_dir", "movie_dir", "handbrake_preset"], + "errors": [] +} +``` + +--- + +## POST /api/settings/pushover/test + +Sendet eine Test-Benachrichtigung über PushOver. + +**Request:** Kein Body erforderlich (verwendet gespeicherte Zugangsdaten) + +**Response (Erfolg):** + +```json +{ "ok": true, "message": "Test-Benachrichtigung gesendet" } +``` + +**Response (Fehler):** + +```json +{ "ok": false, "error": "Ungültiger API-Token" } +``` + +--- + +## Einstellungs-Schlüssel Referenz + +Eine vollständige Liste aller Einstellungs-Schlüssel: + +| Schlüssel | Kategorie | Typ | Beschreibung | +|---------|----------|-----|-------------| +| `raw_dir` | paths | string | Raw-MKV Verzeichnis | +| `movie_dir` | paths | string | Ausgabe-Verzeichnis | +| `log_dir` | paths | string | Log-Verzeichnis | +| `makemkv_command` | tools | string | MakeMKV-Befehl | +| `handbrake_command` | tools | string | HandBrake-Befehl | +| `mediainfo_command` | tools | string | MediaInfo-Befehl | +| `handbrake_preset` | encoding | string | HandBrake-Preset-Name | +| `handbrake_extra_args` | encoding | string | Zusatz-Argumente | +| `output_extension` | encoding | string | Dateiendung (z.B. `mkv`) | +| `filename_template` | encoding | string | Dateiname-Template | +| `drive_mode` | drive | select | `auto` oder `explicit` | +| `drive_device` | drive | string | Geräte-Pfad | +| `disc_poll_interval_ms` | drive | number | Polling-Intervall (ms) | +| `makemkv_min_length_minutes` | makemkv | number | Min. Titellänge (Minuten) | +| `makemkv_backup_mode` | makemkv | boolean | Backup-Modus aktivieren | +| `omdb_api_key` | omdb | string | OMDb API-Key | +| `omdb_default_type` | omdb | select | Standard-Suchtyp | +| `pushover_user_key` | notifications | string | PushOver User-Key | +| `pushover_api_token` | notifications | string | PushOver API-Token | diff --git a/docs/api/websocket.md b/docs/api/websocket.md new file mode 100644 index 0000000..e24e0e9 --- /dev/null +++ b/docs/api/websocket.md @@ -0,0 +1,225 @@ +# WebSocket Events + +Ripster verwendet WebSockets für Echtzeit-Updates. Der Endpunkt ist `/ws`. + +--- + +## Verbindung + +```js +const ws = new WebSocket('ws://localhost:3001/ws'); + +ws.onmessage = (event) => { + const message = JSON.parse(event.data); + console.log(message.type, message.data); +}; +``` + +--- + +## Nachrichten-Format + +Alle Nachrichten folgen diesem Schema: + +```json +{ + "type": "EVENT_TYPE", + "data": { ... } +} +``` + +--- + +## Event-Typen + +### PIPELINE_STATE_CHANGE + +Wird gesendet, wenn der Pipeline-Zustand wechselt. + +```json +{ + "type": "PIPELINE_STATE_CHANGE", + "data": { + "state": "ENCODING", + "jobId": 42, + "job": { + "id": 42, + "title": "Inception", + "status": "ENCODING" + } + } +} +``` + +--- + +### PROGRESS_UPDATE + +Wird während aktiver Prozesse (Ripping/Encoding) regelmäßig gesendet. + +```json +{ + "type": "PROGRESS_UPDATE", + "data": { + "progress": 73.5, + "eta": "00:12:34", + "speed": "45.2 fps", + "phase": "ENCODING" + } +} +``` + +**Felder:** + +| Feld | Typ | Beschreibung | +|-----|-----|-------------| +| `progress` | number | Fortschritt 0–100 | +| `eta` | string | Geschätzte Restzeit (`HH:MM:SS`) | +| `speed` | string | Encoding-Geschwindigkeit (nur beim Encoding) | +| `phase` | string | Aktuelle Phase (`RIPPING` oder `ENCODING`) | + +--- + +### DISC_DETECTED + +Wird gesendet, wenn eine Disc erkannt wird. + +```json +{ + "type": "DISC_DETECTED", + "data": { + "device": "/dev/sr0" + } +} +``` + +--- + +### DISC_REMOVED + +Wird gesendet, wenn eine Disc ausgeworfen wird. + +```json +{ + "type": "DISC_REMOVED", + "data": { + "device": "/dev/sr0" + } +} +``` + +--- + +### JOB_COMPLETE + +Wird gesendet, wenn ein Job erfolgreich abgeschlossen wurde. + +```json +{ + "type": "JOB_COMPLETE", + "data": { + "jobId": 42, + "title": "Inception", + "outputPath": "/mnt/nas/movies/Inception (2010).mkv" + } +} +``` + +--- + +### ERROR + +Wird gesendet, wenn ein Fehler aufgetreten ist. + +```json +{ + "type": "ERROR", + "data": { + "jobId": 42, + "message": "HandBrake ist abgestürzt", + "details": "Exit code: 1\nStderr: ..." + } +} +``` + +--- + +### METADATA_REQUIRED + +Wird gesendet, wenn Benutzer-Eingabe für Metadaten benötigt wird. + +```json +{ + "type": "METADATA_REQUIRED", + "data": { + "jobId": 42, + "makemkvData": { ... }, + "playlistAnalysis": { ... } + } +} +``` + +--- + +### ENCODE_REVIEW_REQUIRED + +Wird gesendet, wenn der Benutzer den Encode-Plan bestätigen soll. + +```json +{ + "type": "ENCODE_REVIEW_REQUIRED", + "data": { + "jobId": 42, + "encodePlan": { + "audioTracks": [ ... ], + "subtitleTracks": [ ... ] + } + } +} +``` + +--- + +## Reconnect-Verhalten + +Der Frontend-Hook `useWebSocket.js` implementiert automatisches Reconnect: + +``` +Verbindung verloren + ↓ +Warte 1s → Reconnect-Versuch + ↓ (Fehlschlag) +Warte 2s → Reconnect-Versuch + ↓ (Fehlschlag) +Warte 4s → ... + ↓ +Max. 30s Wartezeit +``` + +--- + +## Beispiel: React-Hook + +```js +import { useEffect, useState } from 'react'; + +function usePipelineState() { + const [state, setState] = useState({ state: 'IDLE' }); + + useEffect(() => { + const ws = new WebSocket(import.meta.env.VITE_WS_URL + '/ws'); + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + + if (msg.type === 'PIPELINE_STATE_CHANGE') { + setState(msg.data); + } + }; + + return () => ws.close(); + }, []); + + return state; +} +``` diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md new file mode 100644 index 0000000..146d4df --- /dev/null +++ b/docs/architecture/backend.md @@ -0,0 +1,221 @@ +# Backend-Services + +Das Backend ist in Node.js/Express geschrieben und in **Services** aufgeteilt, die jeweils eine klar abgegrenzte Verantwortlichkeit haben. + +--- + +## pipelineService.js + +**Der Kern von Ripster** – orchestriert den gesamten Ripping-Workflow. + +### Zuständigkeiten + +- Verwaltung des Pipeline-Zustands als State Machine +- Koordination zwischen allen externen Tools +- Generierung von Encode-Plänen +- Fehlerbehandlung und Recovery + +### Haupt-Methoden + +| Methode | Beschreibung | +|---------|-------------| +| `analyzeDisc()` | Startet MakeMKV-Analyse der eingelegten Disc | +| `selectMetadata(jobId, omdbData, playlist)` | Setzt Metadaten und Playlist für einen Job | +| `startJob(jobId)` | Startet den Ripping-Prozess | +| `confirmEncode(jobId, trackSelection)` | Bestätigt Encode mit Track-Auswahl | +| `cancelPipeline()` | Bricht aktiven Prozess ab | +| `retryJob(jobId)` | Wiederholt fehlgeschlagenen Job | +| `reencodeJob(jobId)` | Encodiert bestehende Raw-MKV neu | + +### Zustandsübergänge + +```mermaid +stateDiagram-v2 + [*] --> IDLE + IDLE --> ANALYZING: analyzeDisc() + ANALYZING --> METADATA_SELECTION: MakeMKV fertig + METADATA_SELECTION --> READY_TO_START: selectMetadata() + READY_TO_START --> RIPPING: startJob() + RIPPING --> MEDIAINFO_CHECK: MKV erstellt + MEDIAINFO_CHECK --> READY_TO_ENCODE: Tracks analysiert + READY_TO_ENCODE --> ENCODING: confirmEncode() + ENCODING --> FINISHED: HandBrake fertig + ENCODING --> ERROR: Fehler + RIPPING --> ERROR: Fehler + ERROR --> IDLE: retryJob() / cancel + FINISHED --> IDLE: cancel / neue Disc +``` + +--- + +## diskDetectionService.js + +Überwacht das Disc-Laufwerk auf Disc-Einleger- und Auswurf-Ereignisse. + +### Modi + +| Modus | Beschreibung | +|------|-------------| +| `auto` | Erkennt verfügbare Laufwerke automatisch | +| `explicit` | Überwacht ein bestimmtes Gerät (z.B. `/dev/sr0`) | + +### Polling + +Der Service pollt das Laufwerk im konfigurierten Intervall (`disc_poll_interval_ms`, Standard: 5000ms) und emittiert Events: + +```js +// Ereignisse +emit('disc-detected', { device: '/dev/sr0' }) +emit('disc-removed', { device: '/dev/sr0' }) +``` + +--- + +## processRunner.js + +Verwaltet externe CLI-Prozesse. + +### Features + +- **Streaming**: stdout/stderr werden zeilenweise gelesen +- **Progress-Callbacks**: Ermöglicht Echtzeit-Fortschrittsanzeige +- **Graceful Shutdown**: SIGINT → Warte-Timeout → SIGKILL +- **Prozess-Registry**: Verfolgt aktive Prozesse für sauberes Beenden + +### Nutzung + +```js +const result = await runProcess( + 'HandBrakeCLI', + ['--input', rawFile, '--output', outputFile, '--preset', preset], + { + onStderr: (line) => parseHandBrakeProgress(line), + onStdout: (line) => logger.debug(line) + } +); +``` + +--- + +## websocketService.js + +WebSocket-Server für Echtzeit-Client-Kommunikation. + +### Betrieb + +- Läuft auf Pfad `/ws` des Express-Servers +- Hält eine Registry aller verbundenen Clients +- Ermöglicht Broadcast an alle Clients oder gezieltes Senden + +### API + +```js +broadcast({ type: 'PIPELINE_STATE_CHANGE', data: { state, jobId } }); +broadcast({ type: 'PROGRESS_UPDATE', data: { progress, eta } }); +``` + +--- + +## omdbService.js + +Integration mit der [OMDb API](https://www.omdbapi.com/). + +### Methoden + +| Methode | Beschreibung | +|---------|-------------| +| `searchByTitle(title, type)` | Suche nach Titel (movie/series) | +| `fetchById(imdbId)` | Vollständige Metadaten per IMDb-ID | + +### Zurückgegebene Daten + +```json +{ + "imdbId": "tt1375666", + "title": "Inception", + "year": "2010", + "type": "movie", + "poster": "https://...", + "plot": "...", + "director": "Christopher Nolan" +} +``` + +--- + +## settingsService.js + +Verwaltet alle Anwendungseinstellungen. + +### Features + +- **Schema-getriebene Validierung**: Jede Einstellung hat Typ, Grenzen und Pflichtfeld-Flag +- **Kategorisierung**: Einstellungen sind in Kategorien gruppiert (Paths, Tools, Encoding, ...) +- **Persistenz**: Werte in SQLite, Schema ebenfalls in SQLite +- **Defaults**: `defaultSettings.js` definiert Standardwerte + +### Einstellungs-Kategorien + +| Kategorie | Einstellungen | +|-----------|--------------| +| `paths` | `raw_dir`, `movie_dir`, `log_dir` | +| `tools` | `makemkv_command`, `handbrake_command`, `mediainfo_command` | +| `encoding` | `handbrake_preset`, `handbrake_extra_args`, `output_extension`, `filename_template` | +| `drive` | `drive_mode`, `drive_device`, `disc_poll_interval_ms` | +| `makemkv` | `makemkv_min_length_minutes`, `makemkv_backup_mode` | +| `omdb` | `omdb_api_key`, `omdb_default_type` | +| `notifications` | `pushover_user_key`, `pushover_api_token` | + +--- + +## historyService.js + +Datenbankoperationen für Job-Historie. + +### Hauptoperationen + +| Operation | Beschreibung | +|-----------|-------------| +| `listJobs(filters)` | Jobs nach Status/Titel filtern | +| `getJob(id)` | Job-Details mit Logs abrufen | +| `findOrphanRawFolders()` | Nicht-getrackte Raw-Ordner finden | +| `importOrphanRaw(path)` | Orphan-Ordner als Job importieren | +| `assignOmdb(id, omdbData)` | OMDb-Metadaten nachträglich zuweisen | +| `deleteJob(id, deleteFiles)` | Job und optional Dateien löschen | + +--- + +## notificationService.js + +PushOver-Push-Benachrichtigungen. + +```js +await notify({ + title: 'Ripster: Job abgeschlossen', + message: 'Inception (2010) wurde erfolgreich encodiert' +}); +``` + +--- + +## logger.js + +Strukturiertes Logging mit täglicher Log-Rotation. + +### Log-Level + +| Level | Verwendung | +|-------|-----------| +| `debug` | Detaillierte Entwicklungs-Informationen | +| `info` | Normale Betriebsereignisse | +| `warn` | Warnungen, die Aufmerksamkeit benötigen | +| `error` | Fehler, die den Betrieb beeinträchtigen | + +### Log-Dateien + +``` +logs/ +├── ripster-2024-01-15.log ← Tages-Log +└── jobs/ + └── job-42-handbrake.log ← Prozess-spezifische Logs +``` diff --git a/docs/architecture/database.md b/docs/architecture/database.md new file mode 100644 index 0000000..523391b --- /dev/null +++ b/docs/architecture/database.md @@ -0,0 +1,161 @@ +# Datenbank + +Ripster verwendet **SQLite3** als Datenbank. Die Datenbankdatei liegt unter `backend/data/ripster.db`. + +--- + +## Schema-Übersicht + +```sql +-- Vier Haupt-Tabellen +settings_schema -- Einstellungs-Definitionen +settings_values -- Benutzer-Werte +jobs -- Rip-Job-Datensätze +pipeline_state -- Aktueller Pipeline-Zustand (Singleton) +``` + +--- + +## Tabelle: jobs + +Die wichtigste Tabelle – speichert alle Ripping-Jobs. + +```sql +CREATE TABLE jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + status TEXT NOT NULL, -- Aktueller Status + title TEXT, -- Filmtitel (von OMDb) + imdb_id TEXT, -- IMDb-ID + omdb_year TEXT, -- Erscheinungsjahr + omdb_type TEXT, -- movie/series + omdb_poster TEXT, -- Poster-URL + raw_path TEXT, -- Pfad zur Raw-MKV + output_path TEXT, -- Pfad zur Ausgabedatei + playlist TEXT, -- Gewählte Blu-ray Playlist + makemkv_output TEXT, -- MakeMKV-Ausgabe (JSON) + mediainfo_output TEXT, -- MediaInfo-Ausgabe (JSON) + encode_plan TEXT, -- Encode-Plan (JSON) + handbrake_log TEXT, -- HandBrake Log-Pfad + error_message TEXT, -- Fehlermeldung bei ERROR + error_details TEXT -- Detaillierte Fehler-Infos +); +``` + +### Job-Status-Werte + +| Status | Beschreibung | +|--------|-------------| +| `ANALYZING` | MakeMKV analysiert die Disc | +| `METADATA_SELECTION` | Wartet auf Benutzer-Metadaten-Auswahl | +| `READY_TO_START` | Bereit zum Starten | +| `RIPPING` | MakeMKV rippt die Disc | +| `MEDIAINFO_CHECK` | MediaInfo analysiert die Raw-Datei | +| `READY_TO_ENCODE` | Wartet auf Encode-Bestätigung | +| `ENCODING` | HandBrake encodiert | +| `FINISHED` | Erfolgreich abgeschlossen | +| `ERROR` | Fehler aufgetreten | + +--- + +## Tabelle: pipeline_state + +Singleton-Tabelle für den aktuellen Pipeline-Zustand (immer genau 1 Zeile). + +```sql +CREATE TABLE pipeline_state ( + id INTEGER PRIMARY KEY CHECK(id = 1), + state TEXT NOT NULL DEFAULT 'IDLE', + job_id INTEGER, -- Aktiver Job (NULL wenn IDLE) + progress REAL, -- Fortschritt 0-100 + eta TEXT, -- Geschätzte Restzeit + updated_at TEXT NOT NULL +); +``` + +--- + +## Tabelle: settings_schema + +Definiert alle verfügbaren Einstellungen mit Metadaten. + +```sql +CREATE TABLE settings_schema ( + key TEXT PRIMARY KEY, + category TEXT NOT NULL, -- paths, tools, encoding, ... + type TEXT NOT NULL, -- string, number, boolean, select + label TEXT NOT NULL, -- Anzeigename + description TEXT, -- Hilfetext + default_val TEXT, -- Standardwert + required INTEGER, -- 1 = Pflichtfeld + min_val REAL, -- Minimalwert (für number) + max_val REAL, -- Maximalwert (für number) + options TEXT -- JSON-Array für select-Typ +); +``` + +--- + +## Tabelle: settings_values + +Speichert benutzer-konfigurierte Werte. + +```sql +CREATE TABLE settings_values ( + key TEXT PRIMARY KEY REFERENCES settings_schema(key), + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +--- + +## Schema-Migrationen + +`database.js` implementiert **automatische Migrationen**: + +1. Beim Start wird das aktuelle Schema geprüft +2. Fehlende Tabellen werden erstellt +3. Fehlende Spalten werden hinzugefügt +4. Neue Default-Einstellungen werden eingefügt + +### Korruptions-Recovery + +Falls die Datenbankdatei korrupt ist: + +``` +1. Korrupte Datei wird erkannt (Verbindungsfehler / Integritätsprüfung) +2. Datei wird in /backend/data/quarantine/ verschoben +3. Neue, leere Datenbank wird erstellt +4. Schema wird neu initialisiert +5. Log-Eintrag mit Warnung +``` + +--- + +## Datenbankpfad konfigurieren + +Standard: `./data/ripster.db` (relativ zum Backend-Verzeichnis) + +Über Umgebungsvariable anpassen: + +```env +DB_PATH=/var/lib/ripster/ripster.db +``` + +--- + +## Direkte Datenbankinspektion + +```bash +# SQLite3-CLI +sqlite3 backend/data/ripster.db + +# Alle Jobs anzeigen +.mode table +SELECT id, status, title, created_at FROM jobs ORDER BY created_at DESC; + +# Einstellungen anzeigen +SELECT key, value FROM settings_values; +``` diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md new file mode 100644 index 0000000..f26b96c --- /dev/null +++ b/docs/architecture/frontend.md @@ -0,0 +1,190 @@ +# Frontend-Komponenten + +Das Frontend ist mit **React 18** und **PrimeReact** gebaut und kommuniziert über REST-API und WebSocket mit dem Backend. + +--- + +## Seiten (Pages) + +### DashboardPage.jsx + +Die Hauptseite von Ripster – zeigt den aktuellen Pipeline-Status und ermöglicht alle Workflow-Aktionen. + +**Funktionen:** +- Anzeige des aktuellen Pipeline-Zustands (IDLE, ANALYZING, RIPPING, ENCODING, ...) +- Live-Fortschrittsbalken mit ETA +- Trigger für Metadaten-Dialog +- Playlist-Entscheidungs-UI (bei Blu-ray Obfuskierung) +- Encode-Review mit Track-Auswahl +- Job-Steuerung (Start, Abbruch, Retry) + +**Zugehörige Komponenten:** +- `PipelineStatusCard` – Status-Widget +- `MetadataSelectionDialog` – OMDb-Suche und Playlist-Auswahl +- `MediaInfoReviewPanel` – Track-Auswahl vor dem Encoding +- `DiscDetectedDialog` – Benachrichtigung bei Disc-Erkennung + +### SettingsPage.jsx + +Konfigurationsoberfläche für alle Ripster-Einstellungen. + +**Funktionen:** +- Dynamisch generiertes Formular aus dem Settings-Schema +- Echtzeit-Validierungsfeedback +- PushOver-Verbindungstest +- Automatische Aktualisierung des Encode-Reviews bei relevanten Änderungen + +### HistoryPage.jsx + +Job-Historie mit vollständigem Audit-Trail. + +**Funktionen:** +- Sortier- und filterbares Job-Verzeichnis +- Statusfilter (FINISHED, ERROR, WAITING_FOR_USER_DECISION, ...) +- Job-Detail-Dialog mit vollständigen Logs +- Re-Encode, Löschen und Metadaten-Zuweisung +- Import von Orphan-Raw-Ordnern + +--- + +## Komponenten (Components) + +### MetadataSelectionDialog.jsx + +Dialog für die Metadaten-Auswahl nach der Disc-Analyse. + +``` +┌─────────────────────────────────────┐ +│ Metadaten auswählen │ +├─────────────────────────────────────┤ +│ Suche: [Inception ] 🔍 │ +├─────────────────────────────────────┤ +│ Ergebnisse: │ +│ ▶ Inception (2010) – Movie │ +│ Inception: ... (2011) – Series │ +├─────────────────────────────────────┤ +│ Playlist (nur Blu-ray): │ +│ ▶ 00800.mpls (2:30:15) ✓ Empfohlen │ +│ 00801.mpls (0:01:23) │ +├─────────────────────────────────────┤ +│ [Bestätigen] │ +└─────────────────────────────────────┘ +``` + +### MediaInfoReviewPanel.jsx + +Track-Auswahl-Panel vor dem Encoding. + +``` +┌─────────────────────────────────────┐ +│ Encode-Review │ +├─────────────────────────────────────┤ +│ Audio-Tracks: │ +│ ☑ Track 1: Deutsch (AC-3, 5.1) │ +│ ☑ Track 2: English (TrueHD, 7.1) │ +│ ☐ Track 3: Français (AC-3, 2.0) │ +├─────────────────────────────────────┤ +│ Untertitel: │ +│ ☑ Track 1: Deutsch │ +│ ☐ Track 2: English │ +├─────────────────────────────────────┤ +│ [Encodierung starten] │ +└─────────────────────────────────────┘ +``` + +### DynamicSettingsForm.jsx + +Wiederverwendbares Formular, das aus dem Settings-Schema generiert wird. + +**Unterstützte Feldtypen:** + +| Typ | UI-Element | +|----|-----------| +| `string` | Text-Input | +| `number` | Zahlen-Input mit Min/Max | +| `boolean` | Toggle/Checkbox | +| `select` | Dropdown | +| `password` | Passwort-Input | + +### PipelineStatusCard.jsx + +Status-Anzeige-Widget für die Dashboard-Seite. + +### JobDetailDialog.jsx + +Vollständiger Job-Detail-Dialog mit Logs-Viewer. + +--- + +## Hooks + +### useWebSocket.js + +Zentraler Custom-Hook für die WebSocket-Verbindung. + +```js +const { status, lastMessage } = useWebSocket({ + onMessage: (msg) => { + if (msg.type === 'PIPELINE_STATE_CHANGE') { + setPipelineState(msg.data); + } + } +}); +``` + +**Features:** +- Automatische Verbindung zu `/ws` +- Reconnect mit exponential backoff +- Message-Parsing (JSON) +- Status-Tracking (connecting, connected, disconnected) + +--- + +## API-Client (client.js) + +Zentraler HTTP-Client für alle Backend-Anfragen. + +```js +// Beispiel-Aufrufe +const state = await api.getPipelineState(); +const results = await api.searchOmdb('Inception'); +await api.selectMetadata(jobId, omdbData, playlist); +await api.confirmEncode(jobId, { audioTracks: [0, 1], subtitleTracks: [0] }); +``` + +**Features:** +- Zentralisierte Fehlerbehandlung +- Automatische JSON-Serialisierung +- Basis-URL aus Umgebungsvariable (`VITE_API_BASE`) + +--- + +## Build & Entwicklung + +### Entwicklungsserver + +```bash +cd frontend +npm run dev +# → http://localhost:5173 +``` + +### Vite-Proxy-Konfiguration + +In der Entwicklungsumgebung proxied Vite API-Anfragen zum Backend: + +```js +// vite.config.js +proxy: { + '/api': 'http://localhost:3001', + '/ws': { target: 'ws://localhost:3001', ws: true } +} +``` + +### Production-Build + +```bash +cd frontend +npm run build +# → frontend/dist/ +``` diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 0000000..739f459 --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,112 @@ +# Architektur + +Ripster ist als klassische **Client-Server-Anwendung** mit Echtzeit-Kommunikation über WebSockets aufgebaut. + +--- + +## Systemüberblick + +```mermaid +graph TB + subgraph Browser["Browser (React)"] + Dashboard["Dashboard"] + Settings["Einstellungen"] + History["History"] + end + + subgraph Backend["Node.js Backend"] + API["REST API\n(Express)"] + WS["WebSocket\nServer"] + Pipeline["Pipeline\nService"] + DB["SQLite\nDatenbank"] + end + + subgraph ExternalTools["Externe Tools"] + MakeMKV["makemkvcon"] + HandBrake["HandBrakeCLI"] + MediaInfo["mediainfo"] + end + + subgraph ExternalAPIs["Externe APIs"] + OMDb["OMDb API"] + PushOver["PushOver"] + end + + Browser <-->|HTTP REST| API + Browser <-->|WebSocket| WS + Pipeline --> MakeMKV + Pipeline --> HandBrake + Pipeline --> MediaInfo + Pipeline <-->|Metadaten| OMDb + Pipeline -->|Benachrichtigungen| PushOver + API --> DB + Pipeline --> DB +``` + +--- + +## Schichten-Architektur + +### Backend + +``` +index.js (Express Server) +├── Routes (API-Endpunkte) +│ ├── pipelineRoutes.js +│ ├── settingsRoutes.js +│ └── historyRoutes.js +├── Services (Business Logic) +│ ├── pipelineService.js ← Kern-Orchestrierung +│ ├── diskDetectionService.js +│ ├── processRunner.js +│ ├── websocketService.js +│ ├── omdbService.js +│ ├── settingsService.js +│ ├── notificationService.js +│ ├── historyService.js +│ └── logger.js +├── Database +│ ├── database.js +│ └── defaultSettings.js +└── Utils + ├── encodePlan.js + ├── playlistAnalysis.js + ├── progressParsers.js + └── files.js +``` + +### Frontend + +``` +App.jsx (React Router) +├── Pages +│ ├── DashboardPage.jsx ← Haupt-Interface +│ ├── SettingsPage.jsx +│ └── HistoryPage.jsx +├── Components +│ ├── PipelineStatusCard.jsx +│ ├── MetadataSelectionDialog.jsx +│ ├── MediaInfoReviewPanel.jsx +│ ├── DynamicSettingsForm.jsx +│ └── JobDetailDialog.jsx +├── Hooks +│ └── useWebSocket.js +└── API + └── client.js +``` + +--- + +## Weiterführende Dokumentation + +
+ +- [: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..d8829bb --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,144 @@ +# Architektur-Übersicht + +--- + +## Kern-Designprinzipien + +### Event-Driven Pipeline + +Der gesamte Ripping-Workflow ist als **State Machine** implementiert. Der `pipelineService` verwaltet den aktuellen Zustand und emittiert Ereignisse bei jedem Zustandswechsel. Der WebSocket-Service überträgt diese Ereignisse sofort an alle verbundenen Clients. + +``` +Zustandswechsel → Event → WebSocket → Frontend-Update +``` + +### Service-Layer-Muster + +``` +HTTP-Route → Service → Datenbank +``` + +Routes delegieren die gesamte Business-Logik an Services. Services sind voneinander unabhängig und können einzeln getestet werden. + +### Schema-getriebene Einstellungen + +Die Settings-Konfiguration definiert **sowohl** die Validierungsregeln als auch die UI-Struktur in einer einzigen Quelle (`settings_schema`-Tabelle). Die `DynamicSettingsForm`-Komponente rendert das Formular dynamisch aus dem Schema. + +--- + +## Echtzeit-Kommunikation + +### WebSocket-Protokoll + +Der WebSocket-Server läuft unter dem Pfad `/ws`. Nachrichten werden als JSON übertragen: + +```json +{ + "type": "PIPELINE_STATE_CHANGE", + "data": { + "state": "ENCODING", + "jobId": 42, + "progress": 73.5, + "eta": "00:12:34" + } +} +``` + +**Nachrichtentypen:** + +| Typ | Beschreibung | +|----|-------------| +| `PIPELINE_STATE_CHANGE` | Pipeline-Zustand hat gewechselt | +| `PROGRESS_UPDATE` | Fortschritt (% und ETA) | +| `DISC_DETECTED` | Disc wurde erkannt | +| `DISC_REMOVED` | Disc wurde entfernt | +| `ERROR` | Fehler aufgetreten | +| `JOB_COMPLETE` | Job abgeschlossen | + +### Reconnect-Logik + +Der Frontend-Hook `useWebSocket.js` implementiert automatisches Reconnect mit exponential backoff bei Verbindungsabbrüchen. + +--- + +## Prozess-Management + +### processRunner.js + +Externe Tools (MakeMKV, HandBrake, MediaInfo) werden als **Child Processes** gestartet: + +```js +spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) +``` + +- **stdout/stderr** werden zeilenweise gelesen und in Echtzeit verarbeitet +- **Progress-Parsing** erfolgt über reguläre Ausdrücke in `progressParsers.js` +- **Graceful Shutdown**: SIGINT → Timeout → SIGKILL +- **Prozess-Tracking**: Aktive Prozesse werden registriert für sauberes Beenden + +--- + +## Datenpersistenz + +### SQLite-Datenbank + +Ripster verwendet eine **einzige SQLite-Datei** für alle persistenten Daten: + +``` +backend/data/ripster.db +``` + +**Tabellen:** + +| Tabelle | Inhalt | +|---------|--------| +| `jobs` | Alle Rip-Jobs mit Status, Logs, Metadaten | +| `pipeline_state` | Aktueller Pipeline-Zustand (Singleton) | +| `settings_schema` | Schema aller verfügbaren Einstellungen | +| `settings_values` | Benutzer-konfigurierte Werte | + +### Migrations-Strategie + +Beim Start prüft `database.js` automatisch, ob das Schema aktuell ist, und führt fehlende Migrationen aus. Korrupte Datenbankdateien werden in ein Quarantäne-Verzeichnis verschoben und eine neue Datenbank erstellt. + +--- + +## Fehlerbehandlung + +### Strukturierte Fehler + +Alle Fehler werden mit Kontext-Metadaten protokolliert: + +```js +logger.error('Encoding fehlgeschlagen', { + jobId: job.id, + command: cmd, + exitCode: code, + stderr: lastLines +}); +``` + +### Job-Fehler-Recovery + +- Fehlgeschlagene Jobs bleiben in der Datenbank (Status `ERROR`) +- Vollständige Fehler-Logs werden im Job-Datensatz gespeichert +- **Retry-Funktion** ermöglicht Neustart von einem Fehler-Zustand +- **Re-Encode** erlaubt erneutes Encodieren ohne neu zu rippen + +--- + +## Sicherheit + +### Eingabe-Validierung + +- Alle Benutzer-Eingaben werden in `validators.js` validiert +- CLI-Argumente werden sicher über `commandLine.js` konstruiert (kein Shell-Injection-Risiko) +- Pfade werden sanitisiert bevor sie an externe Prozesse übergeben werden + +### CORS-Konfiguration + +```env +CORS_ORIGIN=http://localhost:5173 +``` + +In Produktion sollte dieser Wert auf die tatsächliche Frontend-URL gesetzt werden. diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md new file mode 100644 index 0000000..fe9a0bf --- /dev/null +++ b/docs/configuration/environment.md @@ -0,0 +1,96 @@ +# Umgebungsvariablen + +Umgebungsvariablen überschreiben die Standardwerte und eignen sich für Server-Deployments. + +--- + +## Backend-Umgebungsvariablen + +Konfigurationsdatei: `backend/.env` + +| Variable | Standard | Beschreibung | +|---------|---------|-------------| +| `PORT` | `3001` | Port des Express-Servers | +| `DB_PATH` | `./data/ripster.db` | Pfad zur SQLite-Datenbankdatei | +| `CORS_ORIGIN` | `http://localhost:5173` | Erlaubter CORS-Origin | +| `LOG_DIR` | `./logs` | Verzeichnis für Log-Dateien | +| `LOG_LEVEL` | `info` | Log-Level (`debug`, `info`, `warn`, `error`) | + +### Beispiel: backend/.env + +```env +PORT=3001 +DB_PATH=/var/lib/ripster/ripster.db +CORS_ORIGIN=http://192.168.1.100:5173 +LOG_DIR=/var/log/ripster +LOG_LEVEL=info +``` + +--- + +## Frontend-Umgebungsvariablen + +Konfigurationsdatei: `frontend/.env` + +| Variable | Standard | Beschreibung | +|---------|---------|-------------| +| `VITE_API_BASE` | `http://localhost:3001` | Backend-API-URL | +| `VITE_WS_URL` | `ws://localhost:3001` | WebSocket-URL | +| `VITE_PUBLIC_ORIGIN` | — | Öffentliche Origin-URL (für CORS) | +| `VITE_HMR_HOST` | — | Vite HMR-Host (für Remote-Entwicklung) | +| `VITE_HMR_PORT` | — | Vite HMR-Port | + +### Beispiel: frontend/.env (Entwicklung) + +```env +VITE_API_BASE=http://localhost:3001 +VITE_WS_URL=ws://localhost:3001 +``` + +### Beispiel: frontend/.env (Netzwerk-Zugriff) + +```env +VITE_API_BASE=http://192.168.1.100:3001 +VITE_WS_URL=ws://192.168.1.100:3001 +VITE_PUBLIC_ORIGIN=http://192.168.1.100:5173 +``` + +--- + +## .env.example Dateien + +Das Repository enthält Vorlagen für beide Konfigurationsdateien: + +```bash +# Backend +cp backend/.env.example backend/.env + +# Frontend +cp frontend/.env.example frontend/.env +``` + +--- + +## Priorität der Konfiguration + +Einstellungen werden in folgender Reihenfolge geladen (höhere Priorität überschreibt niedrigere): + +``` +1. Systemumgebungsvariablen (export VAR=value) +2. .env-Datei +3. Hardcodierte Standardwerte in config.js +``` + +--- + +## LOG_LEVEL + +| Level | Ausgabe | +|-------|---------| +| `debug` | Alle Meldungen inkl. Debugging | +| `info` | Normale Betriebsinformationen | +| `warn` | Warnungen + Fehler | +| `error` | Nur Fehler | + +!!! tip "Produktionsempfehlung" + Für Produktionsumgebungen `LOG_LEVEL=info` oder `LOG_LEVEL=warn` verwenden. `debug` erzeugt sehr viele Log-Einträge. diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 0000000..7d6a6a9 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,21 @@ +# Konfiguration + +
+ +- :material-tune: **Einstellungsreferenz** + + --- + + Alle verfügbaren Einstellungen mit Typen, Standardwerten und Beschreibungen. + + [:octicons-arrow-right-24: Einstellungsreferenz](settings-reference.md) + +- :material-variable: **Umgebungsvariablen** + + --- + + Umgebungsvariablen für Backend und Frontend. + + [:octicons-arrow-right-24: Umgebungsvariablen](environment.md) + +
diff --git a/docs/configuration/settings-reference.md b/docs/configuration/settings-reference.md new file mode 100644 index 0000000..54b446d --- /dev/null +++ b/docs/configuration/settings-reference.md @@ -0,0 +1,138 @@ +# Einstellungsreferenz + +Vollständige Übersicht aller Ripster-Einstellungen. Alle Einstellungen werden über die Web-Oberfläche unter **Einstellungen** verwaltet. + +--- + +## Kategorie: Pfade (paths) + +| Schlüssel | Typ | Standard | Pflicht | Beschreibung | +|---------|-----|---------|---------|-------------| +| `raw_dir` | string | — | ✅ | Verzeichnis für rohe MKV-Dateien nach dem Ripping | +| `movie_dir` | string | — | ✅ | Ausgabeverzeichnis für encodierte Filme | +| `log_dir` | string | `./logs` | — | Verzeichnis für Log-Dateien | + +!!! example "Beispielkonfiguration" + ``` + raw_dir = /mnt/nas/raw + movie_dir = /mnt/nas/movies + log_dir = /var/log/ripster + ``` + +--- + +## Kategorie: Tools (tools) + +| Schlüssel | Typ | Standard | Beschreibung | +|---------|-----|---------|-------------| +| `makemkv_command` | string | `makemkvcon` | Befehl oder absoluter Pfad zu MakeMKV | +| `handbrake_command` | string | `HandBrakeCLI` | Befehl oder absoluter Pfad zu HandBrake | +| `mediainfo_command` | string | `mediainfo` | Befehl oder absoluter Pfad zu MediaInfo | + +!!! tip "Absolute Pfade verwenden" + Falls die Tools nicht im `PATH` des Systems sind: + ``` + makemkv_command = /usr/local/bin/makemkvcon + handbrake_command = /usr/local/bin/HandBrakeCLI + mediainfo_command = /usr/bin/mediainfo + ``` + +--- + +## Kategorie: Encoding (encoding) + +| Schlüssel | Typ | Standard | Beschreibung | +|---------|-----|---------|-------------| +| `handbrake_preset` | string | `H.265 MKV 1080p30` | Name des HandBrake-Presets | +| `handbrake_extra_args` | string | _(leer)_ | Zusätzliche HandBrake CLI-Argumente | +| `output_extension` | string | `mkv` | Dateiendung der Ausgabedatei | +| `filename_template` | string | `{title} ({year})` | Template für den Dateinamen | + +### Verfügbare HandBrake-Presets + +Eine vollständige Liste der verfügbaren Presets: + +```bash +HandBrakeCLI --preset-list +``` + +Häufig verwendete Presets: + +| Preset | Beschreibung | +|--------|-------------| +| `H.265 MKV 1080p30` | H.265/HEVC, Full-HD, 30fps | +| `H.265 MKV 720p30` | H.265/HEVC, HD, 30fps | +| `H.264 MKV 1080p30` | H.264/AVC, Full-HD, 30fps | +| `HQ 1080p30 Surround` | Hohe Qualität, Full-HD mit Surround | + +### Dateiname-Template-Platzhalter + +| Platzhalter | Beispiel | +|------------|---------| +| `{title}` | `Inception` | +| `{year}` | `2010` | +| `{imdb_id}` | `tt1375666` | +| `{type}` | `movie` | + +--- + +## Kategorie: Laufwerk (drive) + +| Schlüssel | Typ | Standard | Optionen | Beschreibung | +|---------|-----|---------|---------|-------------| +| `drive_mode` | select | `auto` | `auto`, `explicit` | Laufwerk-Erkennungsmodus | +| `drive_device` | string | `/dev/sr0` | — | Geräte-Pfad (nur bei `explicit`) | +| `disc_poll_interval_ms` | number | `5000` | 1000–60000 | Polling-Intervall in Millisekunden | + +**`drive_mode` Optionen:** + +| Modus | Beschreibung | +|------|-------------| +| `auto` | Ripster erkennt das Laufwerk automatisch | +| `explicit` | Verwendet das in `drive_device` konfigurierte Gerät | + +--- + +## Kategorie: MakeMKV (makemkv) + +| Schlüssel | Typ | Standard | Min | Max | Beschreibung | +|---------|-----|---------|-----|-----|-------------| +| `makemkv_min_length_minutes` | number | `15` | `0` | `999` | Mindest-Titellänge in Minuten | +| `makemkv_backup_mode` | boolean | `false` | — | — | Backup-Modus statt MKV-Modus | + +**`makemkv_min_length_minutes`:** Titel kürzer als dieser Wert werden von MakeMKV ignoriert. Verhindert das Rippen von Menü-Schleifen und kurzen Extra-Clips. + +**`makemkv_backup_mode`:** Im Backup-Modus erstellt MakeMKV eine vollständige Disc-Kopie mit Menüs. Im Standard-Modus werden direkt MKV-Dateien erstellt. + +--- + +## Kategorie: OMDb (omdb) + +| Schlüssel | Typ | Standard | Pflicht | Beschreibung | +|---------|-----|---------|---------|-------------| +| `omdb_api_key` | string | — | ✅ | API-Key von [omdbapi.com](https://www.omdbapi.com/) | +| `omdb_default_type` | select | `movie` | — | Standard-Suchtyp: `movie` oder `series` | + +--- + +## Kategorie: Benachrichtigungen (notifications) + +| Schlüssel | Typ | Standard | Beschreibung | +|---------|-----|---------|-------------| +| `pushover_user_key` | string | — | PushOver User-Key | +| `pushover_api_token` | string | — | PushOver API-Token | + +Beide Felder müssen konfiguriert sein, um PushOver-Benachrichtigungen zu aktivieren. Die Verbindung kann mit dem **Test-Button** in den Einstellungen geprüft werden. + +--- + +## Standard-Einstellungen zurücksetzen + +Über die Datenbank können Einstellungen auf Standardwerte zurückgesetzt werden: + +```bash +sqlite3 backend/data/ripster.db \ + "DELETE FROM settings_values WHERE key = 'handbrake_preset';" +``` + +Beim nächsten Laden der Einstellungen wird der Standardwert verwendet. diff --git a/docs/deployment/development.md b/docs/deployment/development.md new file mode 100644 index 0000000..7fed80b --- /dev/null +++ b/docs/deployment/development.md @@ -0,0 +1,137 @@ +# Entwicklungsumgebung + +--- + +## Voraussetzungen + +- Node.js >= 20.19.0 +- Alle [externen Tools](../getting-started/prerequisites.md) installiert + +--- + +## Schnellstart + +```bash +./start.sh +``` + +Das Skript startet automatisch: +- **Backend** auf Port 3001 (mit Nodemon für Hot-Reload) +- **Frontend** auf Port 5173 (mit Vite HMR) + +--- + +## Manuelle Entwicklungsumgebung + +### Terminal 1 – Backend + +```bash +cd backend +npm install +npm run dev +``` + +Backend läuft auf `http://localhost:3001` mit **Nodemon** – Neustart bei Dateiänderungen. + +### Terminal 2 – Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Frontend läuft auf `http://localhost:5173` mit **Vite HMR** – sofortige Browser-Updates. + +--- + +## Vite-Proxy + +Im Entwicklungsmodus proxied Vite alle API- und WebSocket-Anfragen zum Backend: + +```js +// frontend/vite.config.js +server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true + }, + '/ws': { + target: 'ws://localhost:3001', + ws: true + } + } +} +``` + +Das bedeutet: Im Browser macht das Frontend Anfragen an `localhost:5173/api/...` – Vite leitet diese an `localhost:3001/api/...` weiter. + +--- + +## Remote-Entwicklung + +Falls Ripster auf einem entfernten Server entwickelt wird (z.B. Homeserver), muss die Vite-Konfiguration angepasst werden: + +```env +# frontend/.env.local +VITE_API_BASE=http://192.168.1.100:3001 +VITE_WS_URL=ws://192.168.1.100:3001 +VITE_HMR_HOST=192.168.1.100 +VITE_HMR_PORT=5173 +``` + +--- + +## Log-Level für Entwicklung + +```env +# backend/.env +LOG_LEVEL=debug +``` + +Im Debug-Modus werden alle Ausgaben der externen Tools (MakeMKV, HandBrake) vollständig geloggt. + +--- + +## Stoppen + +```bash +./kill.sh +``` + +--- + +## Linting & Type-Checking + +```bash +# Frontend (ESLint) +cd frontend && npm run lint + +# Backend hat keine separaten Lint-Scripts, +# nutze direkt eslint falls konfiguriert +``` + +--- + +## Deployment-Script + +Das `deploy-ripster.sh`-Script überträgt Code auf einen Remote-Server per SSH: + +```bash +./deploy-ripster.sh +``` + +**Was das Script tut:** +1. `rsync` synchronisiert den Code (Backend-Quellcode ohne `data/`) +2. Die Datenbank (`backend/data/`) wird **nicht** überschrieben +3. Verbindung via SSH (konfigurierbar im Script) + +**Anpassung des Scripts:** + +```bash +# deploy-ripster.sh +REMOTE_HOST="192.168.1.100" +REMOTE_USER="michael" +REMOTE_PATH="/home/michael/ripster" +``` diff --git a/docs/deployment/index.md b/docs/deployment/index.md new file mode 100644 index 0000000..01332cf --- /dev/null +++ b/docs/deployment/index.md @@ -0,0 +1,21 @@ +# Deployment + +
+ +- :material-laptop: **Entwicklungsumgebung** + + --- + + Lokale Entwicklungsumgebung einrichten. + + [:octicons-arrow-right-24: Entwicklung](development.md) + +- :material-server: **Produktion** + + --- + + Ripster auf einem Server dauerhaft betreiben. + + [: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..e66fae4 --- /dev/null +++ b/docs/deployment/production.md @@ -0,0 +1,193 @@ +# Produktions-Deployment + +--- + +## Empfohlene Architektur + +``` +Internet / Heimnetz + ↓ + nginx (Reverse Proxy) + ↓ + ┌────┴────┐ + │ │ +Backend Frontend + :3001 (statische Dateien) +``` + +--- + +## systemd-Service + +Für ein dauerhaftes Betreiben als systemd-Service: + +```bash +sudo nano /etc/systemd/system/ripster.service +``` + +```ini +[Unit] +Description=Ripster - Disc Ripping Service +After=network.target + +[Service] +Type=simple +User=michael +WorkingDirectory=/home/michael/ripster +ExecStart=/bin/bash /home/michael/ripster/start.sh +ExecStop=/bin/bash /home/michael/ripster/kill.sh +Restart=on-failure +RestartSec=10s + +# Umgebungsvariablen +Environment=NODE_ENV=production +Environment=PORT=3001 +Environment=LOG_LEVEL=info + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Service aktivieren und starten +sudo systemctl daemon-reload +sudo systemctl enable ripster +sudo systemctl start ripster + +# Status prüfen +sudo systemctl status ripster + +# Logs anzeigen +journalctl -u ripster -f +``` + +--- + +## Frontend-Build + +Für Produktion das Frontend bauen: + +```bash +cd frontend +npm run build +``` + +Die statischen Dateien landen in `frontend/dist/`. + +--- + +## nginx-Konfiguration + +```nginx +# /etc/nginx/sites-available/ripster +server { + listen 80; + server_name ripster.local; + + # Statisches Frontend + root /home/michael/ripster/frontend/dist; + index index.html; + + # SPA Fallback (React Router) + location / { + try_files $uri $uri/ /index.html; + } + + # API-Proxy zum Backend + location /api/ { + proxy_pass http://localhost:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # WebSocket-Proxy + location /ws { + proxy_pass http://localhost:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +--- + +## Nur-Backend-Produktion (ohne nginx) + +Falls kein Reverse Proxy gewünscht ist, kann das Backend die Frontend-Dateien direkt ausliefern: + +```bash +# Frontend bauen +cd frontend && npm run build + +# Backend startet und serviert frontend/dist/ +cd backend && NODE_ENV=production npm start +``` + +Das Backend ist so konfiguriert, dass es im Produktionsmodus die `frontend/dist/`-Dateien als statische Assets ausliefert. + +--- + +## Datenbank-Backup + +```bash +# Datenbank sichern +cp backend/data/ripster.db backend/data/ripster.db.backup.$(date +%Y%m%d) + +# Oder mit SQLite-eigenem Backup-Befehl +sqlite3 backend/data/ripster.db ".backup '/mnt/backup/ripster.db'" +``` + +!!! tip "Automatisches Backup" + Cron-Job für tägliches Backup: + ```cron + 0 3 * * * sqlite3 /home/michael/ripster/backend/data/ripster.db ".backup '/mnt/backup/ripster-$(date +\%Y\%m\%d).db'" + ``` + +--- + +## Log-Rotation + +Ripster rotiert Logs automatisch täglich. Falls zusätzlich systemd-Journal-Rotation gewünscht ist: + +```bash +# /etc/logrotate.d/ripster +/home/michael/ripster/backend/logs/*.log { + daily + rotate 14 + compress + missingok + notifempty +} +``` + +--- + +## Sicherheitshinweise + +!!! warning "Heimnetz-Einsatz" + Ripster ist für den Einsatz im **lokalen Heimnetz** konzipiert und enthält **keine Authentifizierung**. Stelle sicher, dass der Dienst nicht öffentlich erreichbar ist. + +Falls öffentlicher Zugang benötigt wird: + +1. **Basic Auth** via nginx: + ```bash + sudo htpasswd -c /etc/nginx/.htpasswd michael + ``` + ```nginx + location / { + auth_basic "Ripster"; + auth_basic_user_file /etc/nginx/.htpasswd; + # ... + } + ``` + +2. **VPN-Zugang** (empfohlen): Zugriff nur über WireGuard/OpenVPN + +3. **SSL/TLS**: Let's Encrypt mit certbot für HTTPS diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..c57da04 --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,118 @@ +# Konfiguration + +Alle Einstellungen werden über die Web-Oberfläche unter **Einstellungen** verwaltet und in der SQLite-Datenbank gespeichert. + +--- + +## Pflichteinstellungen + +Diese Einstellungen müssen vor dem ersten Rip konfiguriert werden: + +### Pfade + +| Einstellung | Beschreibung | Beispiel | +|------------|-------------|---------| +| `raw_dir` | Verzeichnis für rohe MKV-Dateien | `/mnt/nas/raw` | +| `movie_dir` | Ausgabeverzeichnis für kodierte Filme | `/mnt/nas/movies` | +| `log_dir` | Verzeichnis für Log-Dateien | `/var/log/ripster` | + +!!! warning "Berechtigungen" + Der Ripster-Prozess benötigt **Schreibrechte** auf alle konfigurierten Verzeichnisse. + + ```bash + # Verzeichnisse erstellen und Berechtigungen setzen + sudo mkdir -p /mnt/nas/{raw,movies} + sudo chown $USER:$USER /mnt/nas/{raw,movies} + ``` + +### OMDb API + +| Einstellung | Beschreibung | +|------------|-------------| +| `omdb_api_key` | API-Key von omdbapi.com | +| `omdb_default_type` | Standard-Suchtyp: `movie` oder `series` | + +--- + +## Tool-Konfiguration + +| Einstellung | Standard | Beschreibung | +|------------|---------|-------------| +| `makemkv_command` | `makemkvcon` | Pfad oder Befehl für MakeMKV | +| `handbrake_command` | `HandBrakeCLI` | Pfad oder Befehl für HandBrake | +| `mediainfo_command` | `mediainfo` | Pfad oder Befehl für MediaInfo | + +!!! tip "Absolute Pfade" + Falls die Tools nicht im `PATH` sind, verwende absolute Pfade: + ``` + /usr/local/bin/HandBrakeCLI + ``` + +--- + +## Encoding-Konfiguration + +| Einstellung | Standard | Beschreibung | +|------------|---------|-------------| +| `handbrake_preset` | `H.265 MKV 1080p30` | HandBrake-Preset-Name | +| `handbrake_extra_args` | _(leer)_ | Zusätzliche HandBrake-Argumente | +| `output_extension` | `mkv` | Dateiendung der Ausgabedatei | +| `filename_template` | `{title} ({year})` | Template für Dateinamen | + +### Dateiname-Template + +Das Template unterstützt folgende Platzhalter: + +| Platzhalter | Beschreibung | Beispiel | +|------------|-------------|---------| +| `{title}` | Filmtitel | `Inception` | +| `{year}` | Erscheinungsjahr | `2010` | +| `{imdb_id}` | IMDb-ID | `tt1375666` | +| `{type}` | `movie` oder `series` | `movie` | + +**Beispiel-Template:** +``` +{title} ({year}) +→ Inception (2010).mkv +``` + +--- + +## Laufwerk-Konfiguration + +| Einstellung | Standard | Beschreibung | +|------------|---------|-------------| +| `drive_mode` | `auto` | `auto` (automatisch erkennen) oder `explicit` (festes Gerät) | +| `drive_device` | `/dev/sr0` | Geräte-Pfad (nur bei `explicit`) | +| `disc_poll_interval_ms` | `5000` | Polling-Intervall in Millisekunden | + +--- + +## MakeMKV-Konfiguration + +| Einstellung | Standard | Beschreibung | +|------------|---------|-------------| +| `makemkv_min_length_minutes` | `15` | Mindestlänge für Titel in Minuten | +| `makemkv_backup_mode` | `false` | Backup-Modus statt MKV-Modus | + +!!! info "Backup-Modus" + Im Backup-Modus erstellt MakeMKV eine vollständige Kopie der Disc (inkl. Menüs). Der Standardmodus erstellt direkt MKV-Dateien. + +--- + +## Benachrichtigungen (PushOver) + +| Einstellung | Beschreibung | +|------------|-------------| +| `pushover_user_key` | Dein PushOver User-Key | +| `pushover_api_token` | API-Token deiner PushOver-App | + +Nach der Eingabe kann die Verbindung mit dem **Test-Button** geprüft werden. + +--- + +## Vollständige Einstellungsreferenz + +Eine vollständige Liste aller Einstellungen mit Typen, Validierung und Standardwerten findest du unter: + +[:octicons-arrow-right-24: Einstellungsreferenz](../configuration/settings-reference.md) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000..da7ec91 --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,41 @@ +# Erste Schritte + +Dieser Abschnitt führt dich durch die Installation und Einrichtung von Ripster. + +## Überblick + +
+ +- :material-list-check: **Voraussetzungen** + + --- + + Systemanforderungen und externe Tools, die vor der Installation benötigt werden. + + [:octicons-arrow-right-24: Voraussetzungen prüfen](prerequisites.md) + +- :material-download: **Installation** + + --- + + Schritt-für-Schritt-Anleitung zur Installation von Ripster. + + [:octicons-arrow-right-24: Installation starten](installation.md) + +- :material-tune: **Konfiguration** + + --- + + Einrichten von Pfaden, API-Keys und Encoding-Presets. + + [:octicons-arrow-right-24: Konfigurieren](configuration.md) + +- :material-rocket-launch: **Schnellstart** + + --- + + Rippe deinen ersten Film in wenigen Minuten. + + [:octicons-arrow-right-24: Loslegen](quickstart.md) + +
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..63d2423 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,140 @@ +# Installation + +--- + +## Repository klonen + +```bash +git clone https://github.com/YOUR_GITHUB_USERNAME/ripster.git +cd ripster +``` + +--- + +## Automatischer Start + +Ripster enthält ein `start.sh`-Skript, das alle Abhängigkeiten installiert und Backend + Frontend gleichzeitig startet: + +```bash +./start.sh +``` + +Das Skript führt automatisch folgende Schritte durch: + +1. **Node.js-Versionscheck** – prüft ob >= 20.19.0 verfügbar ist (mit nvm/npx-Fallback) +2. **Abhängigkeiten installieren** – `npm install` für Root, Backend und Frontend +3. **Dienste starten** – Backend und Frontend werden parallel gestartet + +!!! success "Erfolgreich gestartet" + - Backend läuft auf `http://localhost:3001` + - Frontend läuft auf `http://localhost:5173` + +--- + +## Manuelle Installation + +Falls du mehr Kontrolle benötigst: + +```bash +# Root-Abhängigkeiten +npm install + +# Backend-Abhängigkeiten +cd backend && npm install && cd .. + +# Frontend-Abhängigkeiten +cd frontend && npm install && cd .. + +# Backend starten (Terminal 1) +cd backend && npm run dev + +# Frontend starten (Terminal 2) +cd frontend && npm run dev +``` + +--- + +## Umgebungsvariablen konfigurieren + +### Backend + +```bash +cp backend/.env.example backend/.env +``` + +Bearbeite `backend/.env`: + +```env +PORT=3001 +DB_PATH=./data/ripster.db +CORS_ORIGIN=http://localhost:5173 +LOG_DIR=./logs +LOG_LEVEL=info +``` + +### Frontend + +```bash +cp frontend/.env.example frontend/.env +``` + +Bearbeite `frontend/.env`: + +```env +VITE_API_BASE=http://localhost:3001 +VITE_WS_URL=ws://localhost:3001 +``` + +!!! tip "Alle Umgebungsvariablen" + Eine vollständige Übersicht aller Umgebungsvariablen findest du unter [Umgebungsvariablen](../configuration/environment.md). + +--- + +## Datenbank initialisieren + +Die SQLite-Datenbank wird **automatisch** beim ersten Start erstellt und mit dem Schema aus `db/schema.sql` initialisiert. Es sind keine manuellen Datenbankschritte erforderlich. + +``` +backend/data/ +└── ripster.db ← Wird automatisch angelegt +``` + +--- + +## Stoppen + +```bash +./kill.sh +``` + +Das Skript beendet Backend- und Frontend-Prozesse graceful. + +--- + +## Verzeichnisstruktur nach Installation + +``` +ripster/ +├── backend/ +│ ├── data/ ← SQLite-Datenbank (nach erstem Start) +│ ├── logs/ ← Log-Dateien +│ ├── node_modules/ ← Backend-Abhängigkeiten +│ └── .env ← Backend-Konfiguration +├── frontend/ +│ ├── node_modules/ ← Frontend-Abhängigkeiten +│ ├── dist/ ← Production-Build (nach npm run build) +│ └── .env ← Frontend-Konfiguration +└── node_modules/ ← Root-Abhängigkeiten (concurrently etc.) +``` + +--- + +## Nächste Schritte + +Nach erfolgreicher Installation: + +1. Öffne [http://localhost:5173](http://localhost:5173) +2. Navigiere zu **Einstellungen** +3. Konfiguriere Pfade, API-Keys und Encoding-Presets + +[:octicons-arrow-right-24: Zur Konfiguration](configuration.md) diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md new file mode 100644 index 0000000..b9c4af2 --- /dev/null +++ b/docs/getting-started/prerequisites.md @@ -0,0 +1,158 @@ +# Voraussetzungen + +Bevor du Ripster installierst, stelle sicher, dass folgende Software auf deinem System verfügbar ist. + +--- + +## System-Anforderungen + +| Anforderung | Mindestversion | Empfohlen | +|------------|----------------|-----------| +| **Betriebssystem** | Linux / macOS | Ubuntu 22.04+ | +| **Node.js** | 20.19.0 | 20.x LTS | +| **RAM** | 4 GB | 8 GB+ | +| **Festplatte** | 50 GB frei | 500 GB+ (für Roh-MKVs) | + +--- + +## Node.js + +Ripster benötigt **Node.js >= 20.19.0**. + +=== "nvm (empfohlen)" + + ```bash + # nvm installieren + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + + # Node.js 20 installieren + nvm install 20 + nvm use 20 + + # Version prüfen + node --version # v20.x.x + ``` + +=== "Ubuntu/Debian" + + ```bash + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + + node --version # v20.x.x + ``` + +=== "macOS (Homebrew)" + + ```bash + brew install node@20 + node --version # v20.x.x + ``` + +--- + +## Externe Tools + +### MakeMKV + +!!! warning "Lizenz erforderlich" + MakeMKV ist für den persönlichen Gebrauch kostenlos (Beta-Lizenz), benötigt aber eine gültige Lizenz. + +```bash +# Ubuntu/Debian - PPA verwenden +sudo add-apt-repository ppa:heyarje/makemkv-beta +sudo apt-get update +sudo apt-get install makemkv-bin makemkv-oss + +# Installierte Version prüfen +makemkvcon --version +``` + +[:octicons-link-external-24: MakeMKV Download](https://www.makemkv.com/download/){ .md-button } + +### HandBrake CLI + +```bash +# Ubuntu/Debian +sudo add-apt-repository ppa:stebbins/handbrake-releases +sudo apt-get update +sudo apt-get install handbrake-cli + +# Version prüfen +HandBrakeCLI --version + +# macOS +brew install handbrake +``` + +[:octicons-link-external-24: HandBrake Download](https://handbrake.fr/downloads2.php){ .md-button } + +### MediaInfo + +```bash +# Ubuntu/Debian +sudo apt-get install mediainfo + +# macOS +brew install mediainfo + +# Version prüfen +mediainfo --Version +``` + +--- + +## Disc-Laufwerk + +Ripster benötigt ein physisches **DVD- oder Blu-ray-Laufwerk**. + +!!! info "Blu-ray unter Linux" + Für Blu-ray-Ripping unter Linux wird zusätzlich `libaacs` benötigt. MakeMKV bringt jedoch eine eigene Entschlüsselung mit, daher ist dies in den meisten Fällen nicht erforderlich. + +```bash +# Laufwerk prüfen +ls /dev/sr* +# oder +lsblk | grep rom +``` + +--- + +## OMDb API-Key + +Ripster verwendet die [OMDb API](https://www.omdbapi.com/) für Filmmetadaten. + +1. Registriere dich kostenlos auf [omdbapi.com](https://www.omdbapi.com/apikey.aspx) +2. Bestätige deine E-Mail-Adresse +3. Notiere deinen API-Key – du gibst ihn später in den Einstellungen ein + +--- + +## Optionale Voraussetzungen + +### PushOver (Benachrichtigungen) + +Für mobile Push-Benachrichtigungen bei Fertigstellung oder Fehlern: + +- App kaufen auf [pushover.net](https://pushover.net) (~5 USD einmalig) +- **User Key** und **API Token** notieren + +### SSH-Zugang (Deployment) + +Für Remote-Deployment via `deploy-ripster.sh`: + +```bash +# sshpass installieren +sudo apt-get install sshpass +``` + +--- + +## Checkliste + +- [ ] Node.js >= 20.19.0 installiert (`node --version`) +- [ ] `makemkvcon` installiert (`makemkvcon --version`) +- [ ] `HandBrakeCLI` installiert (`HandBrakeCLI --version`) +- [ ] `mediainfo` installiert (`mediainfo --Version`) +- [ ] DVD/Blu-ray Laufwerk vorhanden (`ls /dev/sr*`) +- [ ] OMDb API-Key beschafft diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..00d74c7 --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,144 @@ +# Schnellstart + +Nach der [Installation](installation.md) und [Konfiguration](configuration.md) kannst du sofort mit dem ersten Rip beginnen. + +--- + +## 1. Ripster starten + +```bash +cd ripster +./start.sh +``` + +Öffne [http://localhost:5173](http://localhost:5173) im Browser. + +--- + +## 2. Dashboard + +Das Dashboard zeigt den aktuellen Pipeline-Status: + +``` +Status: IDLE – Bereit +Warte auf Disc... +``` + +--- + +## 3. Disc einlegen + +Lege eine DVD oder Blu-ray in das Laufwerk ein. Ripster erkennt die Disc automatisch (Polling-Intervall konfigurierbar, Standard: 5 Sekunden) und wechselt in den Status **ANALYZING**. + +!!! tip "Manuelle Analyse" + Falls die Disc nicht automatisch erkannt wird, kann über die API eine manuelle Analyse ausgelöst werden: + ```bash + curl -X POST http://localhost:3001/api/pipeline/analyze + ``` + +--- + +## 4. Analyse abwarten + +MakeMKV analysiert die Disc-Struktur. Dieser Vorgang dauert je nach Disc **30 Sekunden bis 5 Minuten**. + +Der Fortschritt wird live im Dashboard angezeigt. + +--- + +## 5. Metadaten auswählen + +Nach der Analyse öffnet sich der **Metadaten-Dialog**: + +1. **Titel suchen**: Gib den Filmtitel in die Suchleiste ein +2. **Ergebnis auswählen**: Wähle den passenden Film aus der OMDb-Liste +3. **Playlist wählen** *(nur Blu-ray)*: Bei Blu-rays mit mehreren Playlists zeigt Ripster eine Analyse der wahrscheinlich korrekten Playlist an +4. **Bestätigen**: Klicke auf "Bestätigen" + +!!! info "Playlist-Obfuskierung" + Einige Blu-rays enthalten absichtlich viele Fake-Playlists. Ripster analysiert diese automatisch und schlägt die wahrscheinlich korrekte Playlist vor. + +--- + +## 6. Ripping starten + +Nach der Metadaten-Auswahl wechselt der Status zu **READY_TO_START**. + +Klicke auf **"Starten"** – MakeMKV beginnt mit dem Ripping. + +**Typische Dauer:** +- DVD: 20–40 Minuten +- Blu-ray: 45–120 Minuten + +--- + +## 7. Encode-Review + +Nach dem Ripping analysiert MediaInfo die Track-Struktur. Im **Encode-Review** kannst du: + +- **Audio-Tracks** auswählen (z. B. Deutsch + Englisch) +- **Untertitel-Tracks** auswählen +- Überflüssige Tracks deaktivieren + +Klicke auf **"Encodierung bestätigen"**. + +--- + +## 8. Encoding + +HandBrake encodiert die Datei mit dem konfigurierten Preset. + +**Fortschrittsanzeige:** +- Aktueller Prozentsatz +- Geschätzte Restzeit (ETA) +- Encoding-Geschwindigkeit (FPS) + +--- + +## 9. Fertig! + +Status wechselt zu **FINISHED**. Die encodierte Datei liegt im konfigurierten `movie_dir`. + +``` +/mnt/nas/movies/ +└── Inception (2010).mkv ← Fertige Datei +``` + +!!! success "PushOver-Benachrichtigung" + Falls PushOver konfiguriert ist, erhältst du eine Push-Benachrichtigung auf dein Mobilgerät. + +--- + +## Workflow-Zusammenfassung + +``` +Disc einlegen + ↓ +ANALYZING (MakeMKV analysiert) + ↓ +METADATA_SELECTION (Titel & Playlist wählen) + ↓ +READY_TO_START → [Starten] + ↓ +RIPPING (MakeMKV rippt) + ↓ +MEDIAINFO_CHECK (Track-Analyse) + ↓ +READY_TO_ENCODE → [Bestätigen] + ↓ +ENCODING (HandBrake encodiert) + ↓ +FINISHED ✓ +``` + +--- + +## Was tun bei Fehlern? + +Falls ein Job in den Status **ERROR** wechselt: + +1. Klicke auf **"Details"** im Dashboard +2. Prüfe die Log-Ausgabe +3. Klicke auf **"Retry"** um den Job erneut zu versuchen + +Logs findest du auch in der [History-Seite](http://localhost:5173/history). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..3947fa9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,130 @@ +# Ripster + +**Halbautomatische Disc-Ripping-Plattform für DVDs und Blu-rays** + +--- + +
+ +- :material-disc: **Automatisiertes Ripping** + + --- + + Disc einlegen – Ripster erkennt sie automatisch und startet den Analyse-Workflow mit MakeMKV. + + [:octicons-arrow-right-24: Workflow verstehen](pipeline/workflow.md) + +- :material-movie-open: **Metadata-Integration** + + --- + + Automatische Suche in der OMDb-Datenbank für Filmtitel, Poster und IMDb-IDs. + + [:octicons-arrow-right-24: Konfiguration](getting-started/configuration.md) + +- :material-cog: **Flexibles Encoding** + + --- + + HandBrake-Encoding mit individueller Track-Auswahl für Audio- und Untertitelspuren. + + [:octicons-arrow-right-24: Encode-Planung](pipeline/encoding.md) + +- :material-history: **Job-Historie** + + --- + + Vollständiges Audit-Trail aller Ripping-Jobs mit Logs und Re-Encode-Funktion. + + [:octicons-arrow-right-24: History API](api/history.md) + +
+ +--- + +## Was ist Ripster? + +Ripster ist eine webbasierte Anwendung zur **halbautomatischen Digitalisierung** von DVDs und Blu-rays. Die Anwendung kombiniert bewährte Open-Source-Tools zu einem durchgängigen, komfortablen Workflow: + +``` +Disc einlegen → Erkennung → Analyse → Metadaten wählen → Rippen → Encodieren → Fertig +``` + +### Kernfunktionen + +| Feature | Beschreibung | +|---------|-------------| +| **Echtzeit-Updates** | WebSocket-basierte Live-Statusanzeige ohne Reload | +| **Intelligente Playlist-Analyse** | Erkennt Blu-ray Playlist-Verschleierung (Fake-Playlists) | +| **Track-Auswahl** | Individuelle Auswahl von Audio- und Untertitelspuren | +| **Orphan-Recovery** | Import von bereits gerippten Dateien als Jobs | +| **PushOver-Benachrichtigungen** | Mobile Alerts bei Fertigstellung oder Fehlern | +| **DB-Korruptions-Recovery** | Automatische Quarantäne bei korrupten SQLite-Dateien | +| **Re-Encoding** | Erneutes Encodieren ohne neu rippen | + +--- + +## Technologie-Stack + +=== "Backend" + + - **Node.js** >= 20.19.0 mit Express.js + - **SQLite3** mit automatischen Schema-Migrationen + - **WebSocket** (`ws`) für Echtzeit-Kommunikation + - Externe CLI-Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo` + +=== "Frontend" + + - **React** 18.3.1 mit React Router + - **Vite** 5.4.12 als Build-Tool + - **PrimeReact** 10.9.2 als UI-Bibliothek + - WebSocket-Client für Live-Updates + +=== "Externe Tools" + + | Tool | Zweck | + |------|-------| + | `makemkvcon` | Disc-Analyse & MKV/Backup-Ripping | + | `HandBrakeCLI` | Video-Encoding | + | `mediainfo` | Track-Informationen aus gerippten Dateien | + | OMDb API | Filmmetadaten (Titel, Poster, IMDb-ID) | + +--- + +## Schnellstart + +```bash +# 1. Repository klonen +git clone https://github.com/YOUR_GITHUB_USERNAME/ripster.git +cd ripster + +# 2. Starten (Node.js >= 20 erforderlich) +./start.sh + +# 3. Browser öffnen +open http://localhost:5173 +``` + +!!! tip "Erste Schritte" + Die vollständige Installationsanleitung mit allen Voraussetzungen findest du unter [Erste Schritte](getting-started/index.md). + +--- + +## Pipeline-Überblick + +```mermaid +stateDiagram-v2 + [*] --> IDLE + IDLE --> ANALYZING: Disc erkannt + ANALYZING --> METADATA_SELECTION: Analyse abgeschlossen + METADATA_SELECTION --> READY_TO_START: Metadaten bestätigt + READY_TO_START --> RIPPING: Start gedrückt + RIPPING --> MEDIAINFO_CHECK: MKV erstellt + MEDIAINFO_CHECK --> READY_TO_ENCODE: Tracks analysiert + READY_TO_ENCODE --> ENCODING: Encode bestätigt + ENCODING --> FINISHED: Encoding fertig + ENCODING --> ERROR: Fehler + RIPPING --> ERROR: Fehler + ERROR --> [*] + FINISHED --> [*] +``` diff --git a/docs/pipeline/encoding.md b/docs/pipeline/encoding.md new file mode 100644 index 0000000..179e18d --- /dev/null +++ b/docs/pipeline/encoding.md @@ -0,0 +1,159 @@ +# Encode-Planung + +`encodePlan.js` analysiert die MediaInfo-Ausgabe und erstellt einen strukturierten Encode-Plan mit Track-Auswahl. + +--- + +## Ablauf + +``` +MediaInfo-JSON + ↓ +Track-Parsing (Video, Audio, Untertitel) + ↓ +Sprach-Normalisierung (ISO 639-1 → 639-3) + ↓ +Codec-Klassifizierung (copy-kompatibel / transcode) + ↓ +Encode-Plan generieren + ↓ +Benutzer-Review im Frontend + ↓ +HandBrake-CLI-Argumente aufbauen +``` + +--- + +## Encode-Plan-Format + +Der generierte Plan wird als JSON im Job-Datensatz gespeichert: + +```json +{ + "inputFile": "/mnt/raw/Inception_t00.mkv", + "outputFile": "/mnt/movies/Inception (2010).mkv", + "preset": "H.265 MKV 1080p30", + "audioTracks": [ + { + "index": 1, + "codec": "dts", + "language": "deu", + "channels": 6, + "label": "Deutsch (DTS, 5.1)", + "copyCompatible": false, + "selected": true + }, + { + "index": 2, + "codec": "truehd", + "language": "eng", + "channels": 8, + "label": "English (TrueHD, 7.1)", + "copyCompatible": true, + "selected": true + } + ], + "subtitleTracks": [ + { + "index": 1, + "language": "deu", + "label": "Deutsch", + "selected": true + } + ] +} +``` + +--- + +## Sprach-Normalisierung + +MediaInfo liefert Sprachcodes in verschiedenen Formaten. `encodePlan.js` normalisiert diese auf **ISO 639-3**: + +| MediaInfo-Output | Normalisiert | +|----------------|-------------| +| `de` | `deu` | +| `German` | `deu` | +| `en` | `eng` | +| `English` | `eng` | +| `fr` | `fra` | +| `ja` | `jpn` | + +--- + +## Codec-Klassifizierung + +HandBrake kann einige Codecs direkt kopieren (ohne Transcoding): + +| Codec | Copy-kompatibel | HandBrake-Encoder | +|-------|----------------|------------------| +| `ac3` | ✅ Ja | `copy:ac3` | +| `aac` | ✅ Ja | `copy:aac` | +| `mp3` | ✅ Ja | `copy:mp3` | +| `truehd` | ✅ Ja | `copy:truehd` | +| `eac3` | ✅ Ja | `copy:eac3` | +| `dts` | ❌ Nein | `ffaac` (transcode) | +| `dtshd` | ❌ Nein | `ffaac` (transcode) | + +!!! info "DTS-Transcoding" + HandBrake unterstützt kein DTS-Passthrough in den Standard-Builds. DTS-Tracks werden zu AAC transcodiert, es sei denn, du verwendest einen speziellen HandBrake-Build mit DTS-Unterstützung. + +--- + +## HandBrake-CLI-Argumente + +Aus dem Encode-Plan generiert `commandLine.js` die HandBrake-Argumente: + +```bash +HandBrakeCLI \ + --input "/mnt/raw/Inception_t00.mkv" \ + --output "/mnt/movies/Inception (2010).mkv" \ + --preset "H.265 MKV 1080p30" \ + --audio 1,2 \ + --aencoder copy:truehd,ffaac \ + --subtitle 1 \ + --subtitle-default 1 +``` + +### Zusätzliche Argumente + +Über die Einstellung `handbrake_extra_args` können beliebige HandBrake-Argumente ergänzt werden: + +``` +--crop 0:0:0:0 --loose-anamorphic +``` + +--- + +## Dateiname-Template + +Die Ausgabedatei wird über das konfigurierte Template benannt: + +``` +Template: {title} ({year}) +Ergebnis: Inception (2010).mkv +``` + +Verfügbare Platzhalter: + +| Platzhalter | Wert | +|------------|------| +| `{title}` | Filmtitel von OMDb | +| `{year}` | Erscheinungsjahr | +| `{imdb_id}` | IMDb-ID (z.B. `tt1375666`) | +| `{type}` | `movie` oder `series` | + +Sonderzeichen im Dateinamen werden automatisch sanitisiert (`:`, `/`, `?` etc. werden entfernt oder ersetzt). + +--- + +## Re-Encoding + +Abgeschlossene Jobs können mit geänderten Einstellungen neu encodiert werden: + +1. Job in der History auswählen +2. "Re-Encode" klicken +3. Neue Track-Auswahl treffen (oder bestehende übernehmen) +4. Encoding startet mit aktuellen Einstellungen + +Dies ist nützlich, wenn sich das HandBrake-Preset oder die Track-Auswahl geändert hat, ohne die zeitintensive Ripping-Phase zu wiederholen. diff --git a/docs/pipeline/index.md b/docs/pipeline/index.md new file mode 100644 index 0000000..e8b2db5 --- /dev/null +++ b/docs/pipeline/index.md @@ -0,0 +1,31 @@ +# Pipeline + +Der Pipeline-Abschnitt beschreibt den Kern-Workflow von Ripster. + +
+ +- :material-state-machine: **Workflow & Zustände** + + --- + + Der vollständige Ripping-Workflow mit allen Zustandsübergängen. + + [:octicons-arrow-right-24: Workflow](workflow.md) + +- :material-film: **Encode-Planung** + + --- + + Wie Ripster Audio- und Untertitel-Tracks analysiert und Encode-Pläne erstellt. + + [:octicons-arrow-right-24: Encoding](encoding.md) + +- :material-playlist-check: **Playlist-Analyse** + + --- + + Erkennung von Blu-ray Playlist-Obfuskierung und Auswahl der korrekten Playlist. + + [:octicons-arrow-right-24: Playlist-Analyse](playlist-analysis.md) + +
diff --git a/docs/pipeline/playlist-analysis.md b/docs/pipeline/playlist-analysis.md new file mode 100644 index 0000000..2768081 --- /dev/null +++ b/docs/pipeline/playlist-analysis.md @@ -0,0 +1,119 @@ +# Playlist-Analyse + +Einige Blu-rays verwenden **Playlist-Obfuskierung** als Kopierschutz-Mechanismus. Ripster erkennt dieses Muster und hilft bei der Auswahl der korrekten Playlist. + +--- + +## Das Problem: Playlist-Obfuskierung + +Moderne Blu-rays können Hunderte von Playlists enthalten, von denen nur eine den eigentlichen Film enthält. Die anderen sind: + +- **Kurze Dummy-Playlists** (wenige Sekunden bis Minuten) +- **Umgeordnete Segmente** (falsche Reihenfolge der Film-Segmente) +- **Duplizierte Inhalte** (mehrere Playlists mit gleichem Inhalt, verschiedenen Timestamps) + +Dies macht es schwierig, die korrekte Playlist manuell zu identifizieren. + +--- + +## Ripsters Analyse-Algorithmus + +`playlistAnalysis.js` analysiert alle von MakeMKV erkannten Playlists nach mehreren Kriterien: + +### 1. Laufzeit-Matching + +Die erwartete Laufzeit (aus OMDb-Metadaten) wird mit der Playlist-Laufzeit verglichen: + +``` +Filmtitel: Inception (2010) +OMDb-Laufzeit: 148 Minuten + +Playlist 00800.mpls: 148:22 → ✅ Match +Playlist 00801.mpls: 1:23 → ❌ Zu kurz +Playlist 00900.mpls: 148:25 → ✅ Match (Duplikat?) +``` + +### 2. Titel-Ähnlichkeit + +Playlists mit Namen, die dem Filmtitel ähneln, werden bevorzugt. + +### 3. Segment-Validierung + +Die Playlist-Segmente werden auf logische Reihenfolge geprüft. + +### 4. Häufigkeits-Analyse + +Bei mehreren Kandidaten: Welche Segment-Kombination kommt am häufigsten vor? + +--- + +## Benutzer-Interface + +Wenn Playlist-Obfuskierung erkannt wird, zeigt Ripster im `MetadataSelectionDialog` eine Playlist-Auswahl: + +``` +┌─────────────────────────────────────────────────────┐ +│ Playlist auswählen │ +├─────────────────────────────────────────────────────┤ +│ ★ 00800.mpls 2:28:05 ✓ Empfohlen (Laufzeit passt) │ +│ 00801.mpls 0:01:23 Kurz (wahrscheinlich Menü) │ +│ 00900.mpls 2:28:12 Mögliche Alternative │ +│ 00901.mpls 0:00:45 Kurz │ +│ ... ... ... │ +├─────────────────────────────────────────────────────┤ +│ Hinweis: 847 Playlists gefunden – Analyse empfiehlt │ +│ Playlist 00800.mpls als Hauptfilm. │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## Analyse-Ergebnis-Format + +```json +{ + "candidates": [ + { + "playlist": "00800.mpls", + "duration": "2:28:05", + "durationSeconds": 8885, + "score": 0.95, + "recommended": true, + "reasons": ["Laufzeit stimmt mit OMDb überein", "Häufigste Segment-Kombination"] + }, + { + "playlist": "00900.mpls", + "duration": "2:28:12", + "durationSeconds": 8892, + "score": 0.72, + "recommended": false, + "reasons": ["Ähnliche Laufzeit", "Seltene Segment-Kombination"] + } + ], + "totalPlaylists": 847, + "recommendation": "00800.mpls" +} +``` + +--- + +## Manuelle Auswahl + +Falls die automatische Empfehlung nicht korrekt ist: + +1. Wähle eine andere Playlist aus der Liste +2. Beachte die Laufzeit-Angabe +3. Vergleiche mit der erwarteten Filmlänge (aus OMDb oder Disc-Hülle) + +!!! tip "Tipp" + Bei Blu-rays von bekannten Filmen kannst du die korrekte Playlist oft über Foren wie [MakeMKV-Forum](https://www.makemkv.com/forum/) verifizieren. + +--- + +## Konfiguration + +Die Playlist-Analyse ist automatisch aktiv. Einstellbar ist: + +| Parameter | Beschreibung | +|----------|-------------| +| `makemkv_min_length_minutes` | Mindestlänge, um als Hauptfilm-Kandidat zu gelten (Standard: 15 Min) | diff --git a/docs/pipeline/workflow.md b/docs/pipeline/workflow.md new file mode 100644 index 0000000..01c05fc --- /dev/null +++ b/docs/pipeline/workflow.md @@ -0,0 +1,222 @@ +# Workflow & Zustände + +Der Ripping-Workflow von Ripster ist als **State Machine** implementiert. Jeder Zustand hat klar definierte Übergangsbedingungen und Aktionen. + +--- + +## Zustandsdiagramm + +```mermaid +stateDiagram-v2 + direction LR + [*] --> IDLE + + IDLE --> ANALYZING: Disc erkannt\n(automatisch) + ANALYZING --> METADATA_SELECTION: MakeMKV-Analyse\nabgeschlossen + METADATA_SELECTION --> READY_TO_START: Benutzer wählt\nMetadaten + Playlist + + READY_TO_START --> RIPPING: Benutzer startet\nden Job + + RIPPING --> MEDIAINFO_CHECK: MKV/Backup\nerstellt + MEDIAINFO_CHECK --> READY_TO_ENCODE: Track-Analyse\nabgeschlossen + + READY_TO_ENCODE --> ENCODING: Benutzer bestätigt\nEncode + Tracks + + ENCODING --> FINISHED: HandBrake\nfertig + FINISHED --> IDLE: Disc auswerfen /\nneue Disc + + RIPPING --> ERROR: Fehler + ENCODING --> ERROR: Fehler + ANALYZING --> ERROR: Fehler + ERROR --> IDLE: cancelPipeline()\noder retryJob() +``` + +--- + +## Zustandsbeschreibungen + +### IDLE + +**Ausgangszustand.** Ripster wartet auf eine Disc. + +- `diskDetectionService` pollt das Laufwerk (Intervall konfigurierbar) +- Bei Disc-Erkennung: Automatischer Übergang zu `ANALYZING` +- WebSocket-Event: `DISC_DETECTED` + +--- + +### ANALYZING + +**MakeMKV analysiert die Disc-Struktur.** + +```bash +makemkvcon -r info disc:0 +``` + +- Liest Titel-Informationen, Playlist-Liste, Track-Details +- Fortschritt wird über WebSocket übertragen +- Bei Blu-ray: Playlist-Liste für spätere Analyse gesammelt +- Dauer: 30 Sekunden bis 5 Minuten + +**Ausgabe:** JSON-Struktur mit allen Titeln und Playlists + +--- + +### METADATA_SELECTION + +**Wartet auf Benutzer-Eingabe.** + +- `MetadataSelectionDialog` wird im Frontend angezeigt +- Benutzer sucht in OMDb nach dem Filmtitel +- Benutzer wählt einen Eintrag aus den Suchergebnissen +- Bei Blu-ray: Playlist-Auswahl (mit Empfehlung durch `playlistAnalysis.js`) + +**Übergang:** `selectMetadata(jobId, omdbData, playlist)` aufrufen + +--- + +### READY_TO_START + +**Metadaten bestätigt, bereit zum Starten.** + +- Job-Datensatz in Datenbank mit Metadaten aktualisiert +- Start-Button im Dashboard aktiv +- Benutzer kann Metadaten noch mal ändern + +**Übergang:** `startJob(jobId)` aufrufen + +--- + +### RIPPING + +**MakeMKV rippt die Disc.** + +=== "MKV-Modus (Standard)" + + ```bash + makemkvcon mkv disc:0 all /path/to/raw/ \ + --minlength=900 + ``` + + Erstellt MKV-Datei(en) direkt aus den gewählten Titeln. + +=== "Backup-Modus" + + ```bash + makemkvcon backup disc:0 /path/to/raw/backup/ \ + --decrypt + ``` + + Erstellt vollständiges Disc-Backup inkl. Menüs. + +**Live-Updates:** Fortschritt wird zeilenweise aus MakeMKV-Ausgabe geparst: + +``` +PRGC:5012,0,2048 → Fortschritt: X% +PRGT:5011,0,"..." → Aktueller Task +``` + +--- + +### MEDIAINFO_CHECK + +**MediaInfo analysiert die gerippte Datei.** + +```bash +mediainfo --Output=JSON /path/to/raw/file.mkv +``` + +- Liest alle Audio-, Untertitel- und Video-Tracks +- Extrahiert Codec-Informationen (DTS, TrueHD, AC-3, ...) +- Bestimmt Sprachcodes (ISO 639) +- Erstellt Encode-Plan via `encodePlan.js` + +--- + +### READY_TO_ENCODE + +**Encode-Plan erstellt, wartet auf Bestätigung.** + +- `MediaInfoReviewPanel` wird im Frontend angezeigt +- Benutzer kann Audio- und Untertitel-Tracks de/aktivieren +- Vorgeschlagene Tracks basierend auf Sprach-Einstellungen + +**Übergang:** `confirmEncode(jobId, trackSelection)` aufrufen + +--- + +### ENCODING + +**HandBrake encodiert die Datei.** + +```bash +HandBrakeCLI \ + --input /path/to/raw.mkv \ + --output /path/to/movies/Inception\ \(2010\).mkv \ + --preset "H.265 MKV 1080p30" \ + --audio 1,2 \ + --subtitle 1 +``` + +**Live-Updates:** Fortschritt wird aus HandBrake-stderr geparst: + +``` +Encoding: task 1 of 1, 73.50 % +``` + +--- + +### FINISHED + +**Job erfolgreich abgeschlossen.** + +- Ausgabedatei liegt im konfigurierten `movie_dir` +- Job-Status in Datenbank auf `FINISHED` gesetzt +- PushOver-Benachrichtigung (falls konfiguriert) +- WebSocket-Event: `JOB_COMPLETE` + +--- + +### ERROR + +**Fehler aufgetreten.** + +- Fehlerdetails im Job-Datensatz gespeichert +- Fehler-Logs verfügbar in der History +- **Retry**: Job kann vom Fehlerzustand neu gestartet werden +- **Abbrechen**: Pipeline zurück zu IDLE + +--- + +## Abbrechen & Retry + +### Pipeline abbrechen + +```http +POST /api/pipeline/cancel +``` + +- Sendet SIGINT an aktiven Prozess +- Wartet auf graceful exit (Timeout: 10 Sekunden) +- Falls kein graceful exit: SIGKILL +- Pipeline-Zustand zurück zu IDLE + +### Job wiederholen + +```http +POST /api/pipeline/retry/:jobId +``` + +- Setzt Job-Status zurück auf `READY_TO_START` +- Behält Metadaten und Playlist-Auswahl +- Pipeline neu starten mit vorhandenen Daten + +### Re-Encode + +```http +POST /api/pipeline/reencode/:jobId +``` + +- Encodiert bestehende Raw-MKV neu +- Nützlich für geänderte Encoding-Einstellungen +- Kein neues Ripping erforderlich diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..cdfd134 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,64 @@ +/* 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 */ +.md-typeset .mermaid { + display: flex; + justify-content: center; + margin: 1.5rem 0; +} + +/* 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..257d583 --- /dev/null +++ b/docs/tools/handbrake.md @@ -0,0 +1,137 @@ +# HandBrake + +HandBrake encodiert die rohen MKV-Dateien in das gewünschte Format. Ripster nutzt `HandBrakeCLI`. + +--- + +## Verwendeter Befehl + +```bash +HandBrakeCLI \ + --input "/mnt/raw/Film_t00.mkv" \ + --output "/mnt/movies/Film (2010).mkv" \ + --preset "H.265 MKV 1080p30" \ + --audio 1,2 \ + --aencoder copy:ac3,ffaac \ + --subtitle 1 \ + --subtitle-default 1 +``` + +--- + +## Presets + +HandBrake verwendet **Presets** für vorkonfigurierte Encoding-Einstellungen. + +### Empfohlene Presets + +| Preset | Codec | Auflösung | Für | +|--------|-------|----------|-----| +| `H.265 MKV 1080p30` | HEVC/H.265 | 1080p | Beste Qualität/Größe | +| `H.265 MKV 720p30` | HEVC/H.265 | 720p | Kleinere Dateien | +| `H.264 MKV 1080p30` | AVC/H.264 | 1080p | Breiteste Kompatibilität | +| `HQ 1080p30 Surround` | HEVC/H.265 | 1080p | Hohe Qualität mit Surround | + +### Alle Presets anzeigen + +```bash +HandBrakeCLI --preset-list +``` + +--- + +## Audio-Encoding + +### Copy-kompatible Codecs + +HandBrake kann folgende Codecs direkt kopieren (kein Qualitätsverlust): + +| Codec | `--aencoder` Wert | +|-------|-----------------| +| AC-3 | `copy:ac3` | +| AAC | `copy:aac` | +| MP3 | `copy:mp3` | +| TrueHD | `copy:truehd` | +| E-AC-3 | `copy:eac3` | + +### Transcoding + +Codecs die nicht kopiert werden können, werden zu AAC transcodiert: + +| Original | Transcodiert zu | +|---------|----------------| +| DTS | AAC (`ffaac`) | +| DTS-HD | AAC (`ffaac`) | + +--- + +## Extra-Argumente + +Über die Einstellung `handbrake_extra_args` können beliebige HandBrake-Argumente hinzugefügt werden: + +``` +# Cropping deaktivieren +--crop 0:0:0:0 + +# Loose Anamorphic +--loose-anamorphic + +# Bestimmte Qualität setzen +--quality 20 +``` + +--- + +## Fortschritts-Parsing + +Ripster parst die HandBrake-Ausgabe auf stderr für die Fortschrittsanzeige: + +``` +Encoding: task 1 of 1, 73.50 % (45.23 fps, avg 44.12 fps, ETA 00h12m34s) +``` + +`progressParsers.js` extrahiert: +- Prozentzahl +- Aktuelle FPS +- ETA + +--- + +## Konfiguration in Ripster + +| Einstellung | Beschreibung | +|------------|-------------| +| `handbrake_command` | Pfad/Befehl für `HandBrakeCLI` | +| `handbrake_preset` | Preset-Name | +| `handbrake_extra_args` | Zusätzliche CLI-Argumente | +| `output_extension` | Dateiendung der Ausgabe | + +--- + +## Troubleshooting + +### HandBrake findet Preset nicht + +```bash +# Preset-Liste anzeigen +HandBrakeCLI --preset-list 2>&1 | grep -i "h.265" +``` + +Preset-Namen sind case-sensitive! + +### Encoding sehr langsam + +```bash +# CPU-Encoding-Preset anpassen (schneller = schlechtere Qualität) +handbrake_extra_args = --encoder-preset fast +``` + +Verfügbare Presets: `ultrafast`, `superfast`, `veryfast`, `faster`, `fast`, `medium`, `slow`, `slower`, `veryslow` + +### GPU-Encoding nutzen (NVIDIA) + +``` +handbrake_preset = H.265 NVENC 1080p +``` + +Erfordert HandBrake-Build mit NVENC-Unterstützung und NVIDIA-GPU. diff --git a/docs/tools/index.md b/docs/tools/index.md new file mode 100644 index 0000000..da1b5b2 --- /dev/null +++ b/docs/tools/index.md @@ -0,0 +1,31 @@ +# Externe Tools + +Ripster ist ein **Orchestrator** – die eigentliche Arbeit erledigen diese bewährten Open-Source-Tools: + +
+ +- :material-disc: **MakeMKV** + + --- + + Disc-Analyse und Ripping. Erstellt MKV-Dateien oder vollständige Backups. + + [:octicons-arrow-right-24: MakeMKV](makemkv.md) + +- :material-film: **HandBrake** + + --- + + Video-Encoding mit umfangreichen Preset-Optionen. + + [:octicons-arrow-right-24: HandBrake](handbrake.md) + +- :material-information: **MediaInfo** + + --- + + Analyse von Track-Informationen in Mediendateien. + + [: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..f4ee6ca --- /dev/null +++ b/docs/tools/makemkv.md @@ -0,0 +1,118 @@ +# MakeMKV + +MakeMKV analysiert und rippt DVDs und Blu-rays. Ripster nutzt `makemkvcon` (die CLI-Version). + +--- + +## Verwendete Befehle + +### Disc-Analyse + +```bash +makemkvcon -r --cache=1 info disc:0 +``` + +Gibt alle Titel und Playlists der eingelegten Disc aus. Ripster parst diese Ausgabe um die verfügbaren Tracks und Playlists zu bestimmen. + +**Parameter:** +- `-r` – Maschinen-lesbares Ausgabeformat +- `--cache=1` – Minimaler Disc-Cache +- `info disc:0` – Informationsabfrage für erstes Laufwerk + +### MKV-Modus (Standard) + +```bash +makemkvcon mkv disc:0 all /path/to/raw/ \ + --minlength=900 \ + -r +``` + +Erstellt MKV-Dateien aus allen Titeln, die länger als 15 Minuten sind. + +**Parameter:** +- `mkv` – MKV-Ausgabemodus +- `disc:0` – Erstes Disc-Laufwerk +- `all` – Alle passenden Titel (nicht nur einen bestimmten) +- `--minlength=900` – Mindestlänge in Sekunden (entspricht 15 Minuten) + +### Backup-Modus + +```bash +makemkvcon backup disc:0 /path/to/raw/backup/ \ + --decrypt \ + -r +``` + +Erstellt ein vollständiges Disc-Backup mit Menüs. + +**Parameter:** +- `backup` – Backup-Modus +- `--decrypt` – Verschlüsselung entfernen + +--- + +## Ausgabeformat + +MakeMKV gibt Fortschritt und Status in einem strukturierten Format aus: + +``` +PRGV:current,total,max → Fortschrittsbalken-Werte +PRGT:code,id,"Beschreibung" → Aktueller Task +PRGC:code,id,"Beschreibung" → Aktueller Sub-Task +MSG:code,flags,count,"Text" → Nachricht +``` + +Ripster's `progressParsers.js` parst diese Ausgabe für die Live-Fortschrittsanzeige. + +--- + +## MakeMKV-Lizenz + +MakeMKV ist **Beta-Software** und kostenlos für den persönlichen Gebrauch während der Beta-Phase. Eine Beta-Lizenz ist regelmäßig im [MakeMKV-Forum](https://www.makemkv.com/forum/viewtopic.php?t=1053) verfügbar. + +Ohne gültige Lizenz können Blu-rays nicht entschlüsselt werden. + +### Lizenz eintragen + +Die Lizenz wird in den MakeMKV-Einstellungen eingetragen (GUI) oder direkt in: + +``` +~/.MakeMKV/settings.conf +``` + +``` +app_Key = "XXXX-XXXX-XXXX-XXXX-XXXX" +``` + +--- + +## Konfiguration in Ripster + +| Einstellung | Beschreibung | +|------------|-------------| +| `makemkv_command` | Pfad/Befehl für `makemkvcon` | +| `makemkv_min_length_minutes` | Mindest-Titellänge (Standard: 15 Min) | +| `makemkv_backup_mode` | Backup-Modus statt MKV | + +--- + +## Troubleshooting + +### MakeMKV erkennt Disc nicht + +```bash +# Laufwerk-Berechtigungen prüfen +ls -la /dev/sr0 +sudo chmod a+rw /dev/sr0 + +# Oder Benutzer zur Gruppe cdrom hinzufügen +sudo usermod -a -G cdrom $USER +``` + +### Langer Analyseprozess + +Blu-ray-Analyse kann bei Discs mit vielen Playlists 5+ Minuten dauern. Dies ist normal. + +### Fehlermeldung: "LibMMBD" + +LibMMBD ist MakeMKVs interne Verschlüsselungsbibliothek. Bei Fehlern die MakeMKV-Version aktualisieren. diff --git a/docs/tools/mediainfo.md b/docs/tools/mediainfo.md new file mode 100644 index 0000000..89ca3ab --- /dev/null +++ b/docs/tools/mediainfo.md @@ -0,0 +1,108 @@ +# MediaInfo + +MediaInfo analysiert die Track-Struktur von Mediendateien. Ripster nutzt es nach dem Ripping um Audio- und Untertitelspuren zu identifizieren. + +--- + +## Verwendeter Befehl + +```bash +mediainfo --Output=JSON /path/to/raw/film.mkv +``` + +Gibt vollständige Track-Informationen als JSON zurück. + +--- + +## Ausgabe-Struktur + +```json +{ + "media": { + "track": [ + { + "@type": "General", + "Duration": "8885.042", + "Format": "Matroska" + }, + { + "@type": "Video", + "Format": "HEVC", + "Width": "1920", + "Height": "1080", + "FrameRate": "23.976" + }, + { + "@type": "Audio", + "StreamOrder": "1", + "Format": "TrueHD", + "Channels": "8", + "Language": "en" + }, + { + "@type": "Audio", + "StreamOrder": "2", + "Format": "AC-3", + "Channels": "6", + "Language": "de" + }, + { + "@type": "Text", + "StreamOrder": "1", + "Format": "UTF-8", + "Language": "de" + } + ] + } +} +``` + +--- + +## Verarbeitung in Ripster + +`encodePlan.js` verarbeitet die MediaInfo-Ausgabe: + +1. **Track-Extraktion**: Alle Audio- und Untertitel-Tracks werden extrahiert +2. **Sprach-Normalisierung**: Sprachcodes werden auf ISO 639-3 normalisiert +3. **Codec-Klassifizierung**: Bestimmt ob Codec kopiert oder transcodiert werden kann +4. **Track-Labels**: Benutzerfreundliche Bezeichnungen (z.B. "Deutsch (AC-3, 5.1)") + +### Track-Label-Format + +``` +{Sprache} ({Format}, {Kanäle}) +``` + +Beispiele: +- `Deutsch (AC-3, 5.1)` +- `English (TrueHD, 7.1)` +- `Français (AC-3, 2.0)` + +--- + +## Konfiguration in Ripster + +| Einstellung | Beschreibung | +|------------|-------------| +| `mediainfo_command` | Pfad/Befehl für `mediainfo` | + +--- + +## Troubleshooting + +### MediaInfo gibt kein JSON aus + +```bash +# Version prüfen +mediainfo --Version + +# JSON-Ausgabe testen +mediainfo --Output=JSON /path/to/test.mkv +``` + +MediaInfo >= 17.10 wird empfohlen. + +### Sprache als "und" angezeigt + +`und` steht für "undetermined" – die Sprache ist in der MKV-Datei nicht getaggt. Dies ist bei manchen Rips normal. Der Track wird trotzdem angezeigt und kann manuell ausgewählt werden. 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..55a434d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1713 @@ +{ + "name": "ripster-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ripster-frontend", + "version": "1.0.0", + "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..4e673c1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "ripster-frontend", + "version": "1.0.0", + "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 0000000000000000000000000000000000000000..f520fdf5922548fd860df4f9eb0c238601616694 GIT binary patch literal 286251 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
6=<5- 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 z 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..6da6700 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'; +import { Button } from 'primereact/button'; +import { api } from './api/client'; +import { useWebSocket } from './hooks/useWebSocket'; +import DashboardPage from './pages/DashboardPage'; +import SettingsPage from './pages/SettingsPage'; +import DatabasePage from './pages/DatabasePage'; + +function App() { + const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} }); + const [lastDiscEvent, setLastDiscEvent] = useState(null); + const location = useLocation(); + const navigate = useNavigate(); + + const refreshPipeline = async () => { + const response = await api.getPipelineState(); + setPipeline(response.pipeline); + }; + + useEffect(() => { + refreshPipeline().catch(() => null); + }, []); + + useWebSocket({ + onMessage: (message) => { + if (message.type === 'PIPELINE_STATE_CHANGED') { + setPipeline(message.payload); + } + + if (message.type === 'PIPELINE_PROGRESS') { + setPipeline((prev) => ({ + ...prev, + ...message.payload + })); + } + + if (message.type === 'DISC_DETECTED') { + setLastDiscEvent(message.payload?.device || null); + } + + if (message.type === 'DISC_REMOVED') { + setLastDiscEvent(null); + } + } + }); + + const nav = [ + { label: 'Dashboard', path: '/' }, + { label: 'Settings', path: '/settings' }, + { label: 'Historie', path: '/history' } + ]; + + return ( +

+ ); +} + +export default App; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..24a74db --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,175 @@ +const API_BASE = import.meta.env.VITE_API_BASE || '/api'; + +async function request(path, options = {}) { + const response = await fetch(`${API_BASE}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}) + }, + ...options + }); + + if (!response.ok) { + let errorPayload = null; + let message = `HTTP ${response.status}`; + try { + errorPayload = await response.json(); + message = errorPayload?.error?.message || message; + } catch (_error) { + // ignore parse errors + } + const error = new Error(message); + error.status = response.status; + error.details = errorPayload?.error?.details || null; + throw error; + } + + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return response.json(); + } + + return response.text(); +} + +export const api = { + getSettings() { + return request('/settings'); + }, + updateSetting(key, value) { + return request(`/settings/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ value }) + }); + }, + updateSettingsBulk(settings) { + return request('/settings', { + method: 'PUT', + body: JSON.stringify({ settings }) + }); + }, + testPushover(payload = {}) { + return request('/settings/pushover/test', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, + getPipelineState() { + return request('/pipeline/state'); + }, + analyzeDisc() { + return request('/pipeline/analyze', { + method: 'POST' + }); + }, + rescanDisc() { + return request('/pipeline/rescan-disc', { + method: 'POST' + }); + }, + searchOmdb(q) { + return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); + }, + selectMetadata(payload) { + return request('/pipeline/select-metadata', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, + startJob(jobId) { + return request(`/pipeline/start/${jobId}`, { + method: 'POST' + }); + }, + confirmEncodeReview(jobId, payload = {}) { + return request(`/pipeline/confirm-encode/${jobId}`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + }, + cancelPipeline() { + return request('/pipeline/cancel', { + method: 'POST' + }); + }, + retryJob(jobId) { + return request(`/pipeline/retry/${jobId}`, { + method: 'POST' + }); + }, + resumeReadyJob(jobId) { + return request(`/pipeline/resume-ready/${jobId}`, { + method: 'POST' + }); + }, + reencodeJob(jobId) { + return request(`/pipeline/reencode/${jobId}`, { + method: 'POST' + }); + }, + restartEncodeWithLastSettings(jobId) { + return request(`/pipeline/restart-encode/${jobId}`, { + method: 'POST' + }); + }, + getJobs(params = {}) { + const query = new URLSearchParams(); + if (params.status) query.set('status', params.status); + if (params.search) query.set('search', params.search); + const suffix = query.toString() ? `?${query.toString()}` : ''; + return request(`/history${suffix}`); + }, + getDatabaseRows(params = {}) { + const query = new URLSearchParams(); + if (params.status) query.set('status', params.status); + if (params.search) query.set('search', params.search); + const suffix = query.toString() ? `?${query.toString()}` : ''; + return request(`/history/database${suffix}`); + }, + getOrphanRawFolders() { + return request('/history/orphan-raw'); + }, + importOrphanRawFolder(rawPath) { + return request('/history/orphan-raw/import', { + method: 'POST', + body: JSON.stringify({ rawPath }) + }); + }, + assignJobOmdb(jobId, payload = {}) { + return request(`/history/${jobId}/omdb/assign`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + }, + deleteJobFiles(jobId, target = 'both') { + return request(`/history/${jobId}/delete-files`, { + method: 'POST', + body: JSON.stringify({ target }) + }); + }, + deleteJobEntry(jobId, target = 'none') { + return request(`/history/${jobId}/delete`, { + method: 'POST', + body: JSON.stringify({ target }) + }); + }, + getJob(jobId, options = {}) { + const query = new URLSearchParams(); + 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)))); + } + const suffix = query.toString() ? `?${query.toString()}` : ''; + return request(`/history/${jobId}${suffix}`); + } +}; + +export { API_BASE }; 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..5a9b384 --- /dev/null +++ b/frontend/src/assets/media-disc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + CD + diff --git a/frontend/src/components/DiscDetectedDialog.jsx b/frontend/src/components/DiscDetectedDialog.jsx new file mode 100644 index 0000000..3047a69 --- /dev/null +++ b/frontend/src/components/DiscDetectedDialog.jsx @@ -0,0 +1,39 @@ +import { Dialog } from 'primereact/dialog'; +import { Button } from 'primereact/button'; + +export default function DiscDetectedDialog({ visible, device, onHide, onAnalyze, busy }) { + return ( + +

+ Laufwerk: {device?.path || 'unbekannt'} +

+

+ Disk-Label: {device?.discLabel || 'n/a'} +

+

+ Laufwerks-Label: {device?.label || 'n/a'} +

+

+ Modell: {device?.model || 'n/a'} +

+ +
+
+
+ ); +} diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx new file mode 100644 index 0000000..fbfb0eb --- /dev/null +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from 'react'; +import { TabView, TabPanel } from 'primereact/tabview'; +import { InputText } from 'primereact/inputtext'; +import { InputNumber } from 'primereact/inputnumber'; +import { InputSwitch } from 'primereact/inputswitch'; +import { Dropdown } from 'primereact/dropdown'; +import { Tag } from 'primereact/tag'; + +function normalizeText(value) { + return String(value || '').trim().toLowerCase(); +} + +function normalizeSettingKey(value) { + return String(value || '').trim().toLowerCase(); +} + +function buildToolSections(settings) { + const list = Array.isArray(settings) ? settings : []; + const definitions = [ + { + id: 'makemkv', + title: 'MakeMKV', + description: 'Disc-Analyse und Rip-Einstellungen.', + match: (key) => key.startsWith('makemkv_') + }, + { + id: 'mediainfo', + title: 'MediaInfo', + description: 'Track-Analyse und zusätzliche mediainfo Parameter.', + match: (key) => key.startsWith('mediainfo_') + }, + { + id: 'handbrake', + title: 'HandBrake', + description: 'Preset, Encoding-CLI und HandBrake-Optionen.', + match: (key) => key.startsWith('handbrake_') + }, + { + id: 'output', + title: 'Output', + description: 'Container-Format und Dateinamen-Template.', + match: (key) => key === 'output_extension' || key === 'filename_template' + } + ]; + + const buckets = definitions.map((item) => ({ + ...item, + settings: [] + })); + const fallbackBucket = { + id: 'other', + title: 'Weitere Tool-Settings', + description: null, + settings: [] + }; + + for (const setting of list) { + const key = normalizeSettingKey(setting?.key); + let assigned = false; + for (const bucket of buckets) { + if (bucket.match(key)) { + bucket.settings.push(setting); + assigned = true; + break; + } + } + if (!assigned) { + fallbackBucket.settings.push(setting); + } + } + + const sections = buckets.filter((item) => item.settings.length > 0); + if (fallbackBucket.settings.length > 0) { + sections.push(fallbackBucket); + } + return sections; +} + +function buildSectionsForCategory(categoryName, settings) { + const list = Array.isArray(settings) ? settings : []; + const normalizedCategory = normalizeText(categoryName); + if (normalizedCategory === 'tools') { + const sections = buildToolSections(list); + if (sections.length > 0) { + return sections; + } + } + return [ + { + id: 'all', + title: null, + description: null, + settings: list + } + ]; +} + +export default function DynamicSettingsForm({ + categories, + values, + errors, + dirtyKeys, + onChange +}) { + const safeCategories = Array.isArray(categories) ? categories : []; + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + if (safeCategories.length === 0) { + setActiveIndex(0); + return; + } + if (activeIndex < 0 || activeIndex >= safeCategories.length) { + setActiveIndex(0); + } + }, [activeIndex, safeCategories.length]); + + if (safeCategories.length === 0) { + return

Keine Kategorien vorhanden.

; + } + + return ( + setActiveIndex(Number(event.index || 0))} + scrollable + > + {safeCategories.map((category, categoryIndex) => ( + + {(() => { + const sections = buildSectionsForCategory(category?.category, category?.settings || []); + const grouped = sections.length > 1; + + return ( +
+ {sections.map((section) => ( +
+ {section.title ? ( +
+

{section.title}

+ {section.description ? {section.description} : null} +
+ ) : null} +
+ {(section.settings || []).map((setting) => { + const value = values?.[setting.key]; + const error = errors?.[setting.key] || null; + const dirty = Boolean(dirtyKeys?.has?.(setting.key)); + + return ( +
+ + + {setting.type === 'string' || setting.type === 'path' ? ( + onChange?.(setting.key, event.target.value)} + /> + ) : null} + + {setting.type === 'number' ? ( + onChange?.(setting.key, event.value)} + mode="decimal" + useGrouping={false} + /> + ) : null} + + {setting.type === 'boolean' ? ( + onChange?.(setting.key, event.value)} + /> + ) : null} + + {setting.type === 'select' ? ( + onChange?.(setting.key, event.value)} + /> + ) : null} + + {setting.description || ''} + {error ? ( + {error} + ) : ( + + )} +
+ ); + })} +
+
+ ))} +
+ ); + })()} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx new file mode 100644 index 0000000..57ce7d7 --- /dev/null +++ b/frontend/src/components/JobDetailDialog.jsx @@ -0,0 +1,230 @@ +import { Dialog } from 'primereact/dialog'; +import { Tag } from 'primereact/tag'; +import { Button } from 'primereact/button'; +import MediaInfoReviewPanel from './MediaInfoReviewPanel'; + +function JsonView({ title, value }) { + return ( +
+

{title}

+
{value ? JSON.stringify(value, null, 2) : '-'}
+
+ ); +} + +export default function JobDetailDialog({ + visible, + job, + onHide, + detailLoading = false, + onLoadLog, + logLoadingMode = null, + onAssignOmdb, + onReencode, + onDeleteFiles, + onDeleteEntry, + omdbAssignBusy = false, + actionBusy = false, + reencodeBusy = false, + deleteEntryBusy = false +}) { + const mkDone = !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS'; + const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status); + const showFinalLog = !running; + const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running); + const canDeleteEntry = !running; + const logCount = Number(job?.log_count || 0); + const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null; + const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log); + const logTruncated = Boolean(logMeta?.truncated); + + return ( + + {!job ? null : ( + <> + {detailLoading ?

Details werden geladen ...

: null} + +
+ {job.poster_url && job.poster_url !== 'N/A' ? ( + {job.title + ) : ( +
Kein Poster
+ )} + +
+
+ Titel: {job.title || job.detected_title || '-'} +
+
+ Jahr: {job.year || '-'} +
+
+ IMDb: {job.imdb_id || '-'} +
+
+ OMDb Match:{' '} + +
+
+ Status: +
+
+ Start: {job.start_time || '-'} +
+
+ Ende: {job.end_time || '-'} +
+
+ RAW Pfad: {job.raw_path || '-'} +
+
+ Output: {job.output_path || '-'} +
+
+ Encode Input: {job.encode_input_path || '-'} +
+
+ Mediainfo bestätigt: {job.encode_review_confirmed ? 'ja' : 'nein'} +
+
+ RAW vorhanden: {job.rawStatus?.exists ? 'ja' : 'nein'} +
+
+ RAW leer: {job.rawStatus?.isEmpty === null ? '-' : job.rawStatus?.isEmpty ? 'ja' : 'nein'} +
+
+ Movie Datei vorhanden: {job.outputStatus?.exists ? 'ja' : 'nein'} +
+
+ Movie-Dir leer: {job.movieDirStatus?.isEmpty === null ? '-' : job.movieDirStatus?.isEmpty ? 'ja' : 'nein'} +
+
+ Fehler: {job.error_message || '-'} +
+
+
+ +
+ + + + + +
+ + {job.encodePlan ? ( + <> +

Mediainfo-Prüfung (Auswertung)

+ + + ) : null} + +

Aktionen

+
+
+ +

Log

+ {showFinalLog ? ( + <> +
+
+ {logLoaded ? ( +
{job.log || ''}
+ ) : ( +

Log nicht vorgeladen. Über die Buttons oben laden.

+ )} + + ) : ( +

Live-Log wird nur im Dashboard während laufender Analyse/Rip/Encode angezeigt.

+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx new file mode 100644 index 0000000..09ce46f --- /dev/null +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -0,0 +1,827 @@ +function formatDuration(minutes) { + const value = Number(minutes || 0); + if (!Number.isFinite(value)) { + return '-'; + } + return `${value.toFixed(2)} min`; +} + +function formatBytes(bytes) { + const value = Number(bytes || 0); + if (!Number.isFinite(value) || value <= 0) { + return '-'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = value; + let index = 0; + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + return `${size.toFixed(2)} ${units[index]}`; +} + +function normalizeTrackId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeTrackIdList(values) { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const normalized = normalizeTrackId(value); + if (normalized === null) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +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; +} + +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']); + +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 shellQuote(value) { + const raw = String(value ?? ''); + if (raw.length === 0) { + return "''"; + } + if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) { + return raw; + } + return `'${raw.replace(/'/g, `'"'"'`)}'`; +} + +function buildHandBrakeCommandPreview({ + review, + title, + selectedAudioTrackIds, + selectedSubtitleTrackIds, + commandOutputPath = null +}) { + const inputPath = String(title?.filePath || review?.encodeInputPath || '').trim(); + const handBrakeCmd = String( + review?.selectors?.handbrakeCommand + || review?.selectors?.handBrakeCommand + || 'HandBrakeCLI' + ).trim() || 'HandBrakeCLI'; + const preset = String(review?.selectors?.preset || '').trim(); + const extraArgs = String(review?.selectors?.extraArgs || '').trim(); + const rawMappedTitleId = Number(review?.handBrakeTitleId); + const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0 + ? Math.trunc(rawMappedTitleId) + : null; + + const selectedSubtitleSet = new Set(normalizeTrackIdList(selectedSubtitleTrackIds).map((id) => String(id))); + const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => { + const id = normalizeTrackId(track?.id); + return id !== null && selectedSubtitleSet.has(String(id)); + }); + + const subtitleBurnTrackId = normalizeTrackIdList( + selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewBurnIn || track?.burnIn)).map((track) => track?.id) + )[0] || null; + const subtitleDefaultTrackId = normalizeTrackIdList( + selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id) + )[0] || null; + const subtitleForcedTrackId = normalizeTrackIdList( + selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewForced || track?.forced)).map((track) => track?.id) + )[0] || null; + const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.subtitlePreviewForcedOnly || track?.forcedOnly)); + + const baseArgs = [ + '-i', + inputPath || '', + '-o', + String(commandOutputPath || '').trim() || '' + ]; + + if (mappedTitleId !== null) { + baseArgs.push('-t', String(mappedTitleId)); + } + + if (preset) { + baseArgs.push('-Z', preset); + } + + const filteredExtra = removeSelectionArgs(splitArgs(extraArgs)); + const overrideArgs = [ + '-a', + normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none', + '-s', + normalizeTrackIdList(selectedSubtitleTrackIds).join(',') || 'none' + ]; + + if (subtitleBurnTrackId !== null) { + overrideArgs.push(`--subtitle-burned=${subtitleBurnTrackId}`); + } + if (subtitleDefaultTrackId !== null) { + overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`); + } + if (subtitleForcedTrackId !== null) { + overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`); + } else if (subtitleForcedOnly) { + overrideArgs.push('--subtitle-forced'); + } + + const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs]; + return `${handBrakeCmd} ${finalArgs.map((arg) => shellQuote(arg)).join(' ')}`; +} + +function toLang2(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return 'und'; + } + const map = { + en: 'en', + eng: 'en', + de: 'de', + deu: 'de', + ger: 'de', + tr: 'tr', + tur: 'tr', + fr: 'fr', + fra: 'fr', + fre: 'fr', + es: 'es', + spa: 'es', + it: 'it', + ita: 'it' + }; + if (map[raw]) { + return map[raw]; + } + if (raw.length === 2) { + return raw; + } + if (raw.length >= 3) { + return raw.slice(0, 2); + } + return raw; +} + +function simplifyCodec(type, value, hint = null) { + const raw = String(value || '').trim(); + const hintRaw = String(hint || '').trim(); + const lower = raw.toLowerCase(); + const merged = `${raw} ${hintRaw}`.toLowerCase(); + if (!raw) { + return '-'; + } + + if (type === 'subtitle') { + if (merged.includes('pgs')) { + return 'PGS'; + } + return raw.toUpperCase(); + } + + if (merged.includes('dts-hd ma') || merged.includes('dts hd ma')) { + return 'DTS-HD MA'; + } + if (merged.includes('dts-hd hra') || merged.includes('dts hd hra')) { + return 'DTS-HD HRA'; + } + if (merged.includes('dts-hd') || merged.includes('dts hd')) { + return 'DTS-HD'; + } + if (merged.includes('dts') || merged.includes('dca')) { + return 'DTS'; + } + if (merged.includes('truehd')) { + return 'TRUEHD'; + } + if (merged.includes('e-ac-3') || merged.includes('eac3') || merged.includes('dd+')) { + return 'E-AC-3'; + } + if (merged.includes('ac-3') || merged.includes('ac3') || merged.includes('dolby digital')) { + return 'AC-3'; + } + + const numeric = Number(raw); + if (Number.isFinite(numeric)) { + if (numeric === 262144) { + return 'DTS-HD'; + } + if (numeric === 131072) { + return 'DTS'; + } + } + + return raw.toUpperCase(); +} + +function extractAudioVariant(hint) { + const raw = String(hint || '').trim(); + if (!raw) { + return ''; + } + + const paren = raw.match(/\(([^)]+)\)/); + if (!paren) { + return ''; + } + + const parts = paren[1] + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + const extras = parts.filter((item) => { + const lower = item.toLowerCase(); + if (lower.includes('dts') || lower.includes('ac3') || lower.includes('e-ac3') || lower.includes('eac3')) { + return false; + } + if (/\d+(?:\.\d+)?\s*ch/i.test(item)) { + return false; + } + if (/\d+\s*kbps/i.test(lower)) { + return false; + } + return true; + }); + + return extras.join(', '); +} + +function channelCount(rawValue) { + const raw = String(rawValue || '').trim().toLowerCase(); + if (!raw) { + return null; + } + + if (raw.includes('7.1')) { + return 8; + } + if (raw.includes('5.1')) { + return 6; + } + if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix')) { + return 2; + } + if (raw.includes('mono') || raw.includes('1.0')) { + return 1; + } + + const numeric = Number(raw); + if (Number.isFinite(numeric) && numeric > 0) { + if (Math.abs(numeric - 7.1) < 0.2) { + return 8; + } + if (Math.abs(numeric - 5.1) < 0.2) { + return 6; + } + return Math.trunc(numeric); + } + + const match = raw.match(/(\d+)\s*ch/); + if (match) { + const value = Number(match[1]); + return Number.isFinite(value) && value > 0 ? Math.trunc(value) : null; + } + + return null; +} + +function audioChannelLabel(rawValue) { + const raw = String(rawValue || '').trim().toLowerCase(); + const count = channelCount(rawValue); + + if (raw.includes('7.1') || count === 8) { + return 'Surround 7.1'; + } + if (raw.includes('5.1') || count === 6) { + return 'Surround 5.1'; + } + if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix') || count === 2) { + return 'Stereo'; + } + if (count === 1) { + return 'Mono'; + } + return ''; +} + +const DEFAULT_AUDIO_FALLBACK_PREVIEW = 'av_aac'; + +function mapTrackToCopyCodec(track) { + const raw = [ + track?.codecToken, + track?.format, + track?.codecName, + track?.description, + track?.title + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); + + 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') || 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 resolveAudioEncoderPreviewLabel(track, encoderToken, copyMask, fallbackEncoder) { + const normalizedToken = String(encoderToken || '').trim().toLowerCase(); + if (!normalizedToken || normalizedToken === 'preset-default') { + return 'Preset-Default (HandBrake)'; + } + + if (normalizedToken.startsWith('copy')) { + const sourceCodec = mapTrackToCopyCodec(track); + const explicitCopyCodec = normalizedToken.includes(':') + ? normalizedToken.split(':').slice(1).join(':').trim().toLowerCase() + : null; + const normalizedCopyMask = Array.isArray(copyMask) + ? copyMask.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) + : []; + + let canCopy = false; + if (explicitCopyCodec) { + canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec); + } else if (sourceCodec && normalizedCopyMask.length > 0) { + canCopy = normalizedCopyMask.includes(sourceCodec); + } + + if (canCopy) { + return `Copy (${sourceCodec || track?.format || 'Quelle'})`; + } + + const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK_PREVIEW).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK_PREVIEW; + return `Fallback Transcode (${fallback})`; + } + + return `Transcode (${normalizedToken})`; +} + +function buildAudioActionPreviewSummary(track, selectedIndex, audioSelector) { + const selector = audioSelector && typeof audioSelector === 'object' ? audioSelector : {}; + const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : []; + let encoderPlan = []; + + if (selector.encoderSource === 'args' && availableEncoders.length > 0) { + const safeIndex = Number.isFinite(selectedIndex) && selectedIndex >= 0 ? selectedIndex : 0; + encoderPlan = [availableEncoders[Math.min(safeIndex, availableEncoders.length - 1)]]; + } else if (availableEncoders.length > 0) { + encoderPlan = [...availableEncoders]; + } else { + encoderPlan = ['preset-default']; + } + + const labels = encoderPlan + .map((token) => resolveAudioEncoderPreviewLabel(track, token, selector.copyMask, selector.fallbackEncoder)) + .filter(Boolean); + + return labels.join(' + ') || 'Übernehmen'; +} + +function TrackList({ + title, + tracks, + type = 'generic', + allowSelection = false, + selectedTrackIds = [], + onToggleTrack = null, + audioSelector = null +}) { + const selectedIds = normalizeTrackIdList(selectedTrackIds); + const checkedTrackOrder = (Array.isArray(tracks) ? tracks : []) + .map((track) => normalizeTrackId(track?.id)) + .filter((trackId, index) => { + if (trackId === null) { + return false; + } + if (allowSelection) { + return selectedIds.includes(trackId); + } + const track = tracks[index]; + return Boolean(track?.selectedForEncode); + }); + + return ( +
+

{title}

+ {!tracks || tracks.length === 0 ? ( +

Keine Einträge.

+ ) : ( +
+ {tracks.map((track) => { + const trackId = normalizeTrackId(track.id); + const checked = allowSelection + ? (trackId !== null && selectedIds.includes(trackId)) + : Boolean(track.selectedForEncode); + const selectedIndex = trackId !== null + ? checkedTrackOrder.indexOf(trackId) + : -1; + const actionInfo = type === 'audio' + ? (checked + ? (() => { + const base = String(track.encodePreviewSummary || track.encodeActionSummary || '').trim(); + const staleUnselectedSummary = /^nicht übernommen$/i.test(base); + if (staleUnselectedSummary) { + return buildAudioActionPreviewSummary(track, selectedIndex, audioSelector); + } + return base || buildAudioActionPreviewSummary(track, selectedIndex, audioSelector); + })() + : 'Nicht übernommen') + : type === 'subtitle' + ? (checked + ? (() => { + const base = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim(); + return /^nicht übernommen$/i.test(base) ? 'Übernehmen' : (base || 'Übernehmen'); + })() + : 'Nicht übernommen') + : null; + const subtitleFlags = type === 'subtitle' && checked + ? (Array.isArray(track.subtitlePreviewFlags) + ? track.subtitlePreviewFlags + : (Array.isArray(track.flags) ? track.flags : [])) + : []; + + const displayLanguage = toLang2(track.language || track.languageLabel || 'und'); + const displayHint = track.description || track.title; + const displayCodec = simplifyCodec(type, track.format, displayHint); + const displayChannelCount = channelCount(track.channels); + const displayAudioTitle = audioChannelLabel(track.channels); + const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : ''; + const burned = type === 'subtitle' && checked + ? Boolean( + track.subtitlePreviewBurnIn + || track.burnIn + || subtitleFlags.includes('burned') + || /burned/i.test(String(track.subtitlePreviewSummary || track.subtitleActionSummary || '')) + ) + : false; + + let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`; + if (type === 'audio') { + if (displayChannelCount !== null) { + displayText += ` | ${displayChannelCount}ch`; + } + if (displayAudioTitle) { + displayText += ` | ${displayAudioTitle}`; + } + if (audioVariant) { + displayText += ` | ${audioVariant}`; + } + } + if (type === 'subtitle' && burned) { + displayText += ' | burned'; + } + + return ( +
+ + {actionInfo ? Encode: {actionInfo} : null} +
+ ); + })} +
+ )} +
+ ); +} + +function normalizeTitleId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +export default function MediaInfoReviewPanel({ + review, + commandOutputPath = null, + selectedEncodeTitleId = null, + allowTitleSelection = false, + onSelectEncodeTitle = null, + allowTrackSelection = false, + trackSelectionByTitle = {}, + onTrackSelectionChange = null +}) { + if (!review) { + return

Keine Mediainfo-Daten vorhanden.

; + } + + const titles = review.titles || []; + const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(review.encodeInputTitleId); + const encodeInputTitle = titles.find((item) => item.id === currentSelectedId) || null; + const processedFiles = Number(review.processedFiles || titles.length || 0); + const totalFiles = Number(review.totalFiles || titles.length || 0); + const playlistRecommendation = review.playlistRecommendation || null; + + return ( +
+
+
Preset: {review.selectors?.preset || '-'}
+
Extra Args: {review.selectors?.extraArgs || '(keine)'}
+
Preset-Profil: {review.selectors?.presetProfileSource || '-'}
+
MIN_LENGTH_MINUTES: {review.minLengthMinutes}
+
Encode Input: {encodeInputTitle?.fileName || '-'}
+
Audio Auswahl: {review.selectors?.audio?.mode || '-'}
+
Audio Encoder: {(review.selectors?.audio?.encoders || []).join(', ') || 'Preset-Default'}
+
Audio Copy-Mask: {(review.selectors?.audio?.copyMask || []).join(', ') || '-'}
+
Audio Fallback: {review.selectors?.audio?.fallbackEncoder || '-'}
+
Subtitle Auswahl: {review.selectors?.subtitle?.mode || '-'}
+
Subtitle Flags: {review.selectors?.subtitle?.forcedOnly ? 'forced-only' : '-'}{review.selectors?.subtitle?.burnBehavior === 'first' ? ' + burned(first)' : ''}
+
+ + {review.partial ? ( + Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert. + ) : null} + + {playlistRecommendation ? ( +
+ + Empfehlung: {playlistRecommendation.playlistFile || '-'} + {playlistRecommendation.reviewTitleId ? ` (Titel #${playlistRecommendation.reviewTitleId})` : ''} + + {playlistRecommendation.reason ? {playlistRecommendation.reason} : null} +
+ ) : null} + + {Array.isArray(review.notes) && review.notes.length > 0 ? ( +
+ {review.notes.map((note, idx) => ( + {note} + ))} +
+ ) : null} + +

Titel

+
+ {titles.length === 0 ? ( +

Keine Titel analysiert.

+ ) : titles.map((title) => { + const titleEligible = title?.eligibleForEncode !== false; + const titleChecked = allowTitleSelection + ? currentSelectedId === normalizeTitleId(title.id) + : Boolean(title.selectedForEncode); + const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {}; + const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : []) + .filter((track) => Boolean(track?.selectedByRule)) + .map((track) => normalizeTrackId(track?.id)) + .filter((id) => id !== null); + const defaultSubtitleTrackIds = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedByRule)) + .map((track) => normalizeTrackId(track?.id)) + .filter((id) => id !== null); + const selectedAudioTrackIds = normalizeTrackIdList( + Array.isArray(titleSelectionEntry?.audioTrackIds) + ? titleSelectionEntry.audioTrackIds + : defaultAudioTrackIds + ); + const selectedSubtitleTrackIds = normalizeTrackIdList( + Array.isArray(titleSelectionEntry?.subtitleTrackIds) + ? titleSelectionEntry.subtitleTrackIds + : defaultSubtitleTrackIds + ); + const allowTrackSelectionForTitle = Boolean( + allowTrackSelection + && allowTitleSelection + && titleChecked + && titleEligible + ); + + return ( +
+ + + {title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? ( +
+ + Playlist: {title.playlistFile || '-'} + {title.playlistRecommended ? ' | empfohlen' : ''} + + {title.playlistEvaluationLabel ? ( + Bewertung: {title.playlistEvaluationLabel} + ) : null} + {title.playlistSegmentCommand ? ( + Analyse-Command: {title.playlistSegmentCommand} + ) : null} + {Array.isArray(title.playlistSegmentFiles) && title.playlistSegmentFiles.length > 0 ? ( +
+ Segment-Dateien anzeigen ({title.playlistSegmentFiles.length}) +
{title.playlistSegmentFiles.join('\n')}
+
+ ) : ( + Segment-Ausgabe: keine m2ts-Einträge gefunden. + )} +
+ ) : null} + +
+ { + if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') { + return; + } + onTrackSelectionChange(title.id, 'audio', trackId, checked); + }} + /> + { + if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') { + return; + } + onTrackSelectionChange(title.id, 'subtitle', trackId, checked); + }} + /> +
+ {titleChecked ? (() => { + const commandPreview = buildHandBrakeCommandPreview({ + review, + title, + selectedAudioTrackIds, + selectedSubtitleTrackIds, + commandOutputPath + }); + return ( +
+ Finaler HandBrakeCLI-Befehl (Preview): +
{commandPreview}
+
+ ); + })() : null} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx new file mode 100644 index 0000000..c6eb335 --- /dev/null +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -0,0 +1,172 @@ +import { useEffect, useMemo, 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'; + +export default function MetadataSelectionDialog({ + visible, + context, + onHide, + onSubmit, + onSearch, + busy +}) { + const [selected, setSelected] = useState(null); + const [query, setQuery] = useState(''); + const [manualTitle, setManualTitle] = useState(''); + const [manualYear, setManualYear] = useState(''); + const [manualImdb, setManualImdb] = useState(''); + const [extraResults, setExtraResults] = useState([]); + + useEffect(() => { + if (!visible) { + return; + } + + const selectedMetadata = context?.selectedMetadata || {}; + const defaultTitle = selectedMetadata.title || context?.detectedTitle || ''; + const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : ''; + const defaultImdb = selectedMetadata.imdbId || ''; + + setSelected(null); + setQuery(defaultTitle); + setManualTitle(defaultTitle); + setManualYear(defaultYear); + setManualImdb(defaultImdb); + setExtraResults([]); + }, [visible, context]); + + const rows = useMemo(() => { + const base = context?.omdbCandidates || []; + const all = [...base, ...extraResults]; + const map = new Map(); + + all.forEach((item) => { + if (item?.imdbId) { + map.set(item.imdbId, item); + } + }); + + return Array.from(map.values()); + }, [context, extraResults]); + + const titleWithPosterBody = (row) => ( +
+ {row.poster && row.poster !== 'N/A' ? ( + {row.title} + ) : ( +
-
+ )} +
+
{row.title}
+ {row.year} | {row.imdbId} +
+
+ ); + + const handleSearch = async () => { + if (!query.trim()) { + return; + } + const results = await onSearch(query.trim()); + setExtraResults(results || []); + }; + + const handleSubmit = async () => { + const payload = selected + ? { + jobId: context.jobId, + title: selected.title, + year: selected.year, + imdbId: selected.imdbId, + poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null, + fromOmdb: true + } + : { + jobId: context.jobId, + title: manualTitle, + year: manualYear, + imdbId: manualImdb, + poster: null, + fromOmdb: false + }; + + await onSubmit(payload); + }; + + return ( + +
+ setQuery(event.target.value)} + placeholder="Titel suchen" + /> +
+ +
+ setSelected(event.value)} + dataKey="imdbId" + size="small" + scrollable + scrollHeight="22rem" + emptyMessage="Keine Treffer" + responsiveLayout="stack" + breakpoint="960px" + > + + + + +
+ +

Manuelle Eingabe

+
+ setManualTitle(event.target.value)} + placeholder="Titel" + disabled={!!selected} + /> + setManualYear(event.target.value)} + placeholder="Jahr" + disabled={!!selected} + /> + setManualImdb(event.target.value)} + placeholder="IMDb-ID" + disabled={!!selected} + /> +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx new file mode 100644 index 0000000..838fe84 --- /dev/null +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -0,0 +1,598 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Card } from 'primereact/card'; +import { Tag } from 'primereact/tag'; +import { ProgressBar } from 'primereact/progressbar'; +import { Button } from 'primereact/button'; +import MediaInfoReviewPanel from './MediaInfoReviewPanel'; +import { api } from '../api/client'; + +const severityMap = { + IDLE: 'success', + DISC_DETECTED: 'info', + ANALYZING: 'warning', + METADATA_SELECTION: 'warning', + WAITING_FOR_USER_DECISION: 'warning', + READY_TO_START: 'info', + MEDIAINFO_CHECK: 'warning', + READY_TO_ENCODE: 'info', + RIPPING: 'warning', + ENCODING: 'warning', + FINISHED: 'success', + ERROR: 'danger' +}; + +function normalizeTitleId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizePlaylistId(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + const match = raw.match(/(\d{1,5})(?:\.mpls)?$/i); + return match ? String(match[1]).padStart(5, '0') : null; +} + +function normalizeTrackId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeTrackIdList(values) { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const normalized = normalizeTrackId(value); + if (normalized === null) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function buildDefaultTrackSelection(review) { + const titles = Array.isArray(review?.titles) ? review.titles : []; + const selection = {}; + + for (const title of titles) { + const titleId = normalizeTitleId(title?.id); + if (!titleId) { + continue; + } + + selection[titleId] = { + audioTrackIds: normalizeTrackIdList( + (Array.isArray(title?.audioTracks) ? title.audioTracks : []) + .filter((track) => Boolean(track?.selectedByRule)) + .map((track) => track?.id) + ), + subtitleTrackIds: normalizeTrackIdList( + (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedByRule)) + .map((track) => track?.id) + ) + }; + } + + return selection; +} + +function defaultTrackSelectionForTitle(review, titleId) { + const defaults = buildDefaultTrackSelection(review); + return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] }; +} + +function buildSettingsMap(categories) { + const map = {}; + const list = Array.isArray(categories) ? categories : []; + for (const category of list) { + for (const setting of (Array.isArray(category?.settings) ? category.settings : [])) { + map[setting.key] = setting.value; + } + } + return map; +} + +function sanitizeFileName(input) { + return String(input || 'untitled') + .replace(/[\\/:*?"<>|]/g, '_') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + +function renderTemplate(template, values) { + return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => { + const value = values[key.trim()]; + if (value === undefined || value === null || value === '') { + return 'unknown'; + } + return String(value); + }); +} + +function buildOutputPathPreview(settings, metadata, fallbackJobId = null) { + const movieDir = String(settings?.movie_dir || '').trim(); + if (!movieDir) { + return null; + } + + const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'); + const year = metadata?.year || new Date().getFullYear(); + const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb'); + const template = settings?.filename_template || '${title} (${year})'; + const folderName = sanitizeFileName(renderTemplate('${title} (${year})', { title, year, imdbId })); + const baseName = sanitizeFileName(renderTemplate(template, { title, year, imdbId })); + const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv'; + const root = movieDir.replace(/\/+$/g, ''); + return `${root}/${folderName}/${baseName}.${ext}`; +} + +export default function PipelineStatusCard({ + pipeline, + onAnalyze, + onReanalyze, + onStart, + onRestartEncode, + onConfirmReview, + onSelectPlaylist, + onCancel, + onRetry, + busy, + liveJobLog = '' +}) { + const state = pipeline?.state || 'IDLE'; + const progress = Number(pipeline?.progress || 0); + const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK'; + const retryJobId = pipeline?.context?.jobId; + const selectedMetadata = pipeline?.context?.selectedMetadata || null; + const mediaInfoReview = pipeline?.context?.mediaInfoReview || null; + const playlistAnalysis = pipeline?.context?.playlistAnalysis || null; + const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null; + const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed); + const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase(); + const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip); + const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null); + const [selectedPlaylistId, setSelectedPlaylistId] = useState(null); + const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({}); + const [settingsMap, setSettingsMap] = useState({}); + + useEffect(() => { + let cancelled = false; + const loadSettings = async () => { + try { + const response = await api.getSettings(); + if (!cancelled) { + setSettingsMap(buildSettingsMap(response?.categories || [])); + } + } catch (_error) { + if (!cancelled) { + setSettingsMap({}); + } + } + }; + loadSettings(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId); + setSelectedEncodeTitleId(fromReview); + setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview)); + }, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]); + + useEffect(() => { + const currentTitleId = normalizeTitleId(selectedEncodeTitleId); + if (!currentTitleId) { + return; + } + + setTrackSelectionByTitle((prev) => { + if (prev?.[currentTitleId] || prev?.[String(currentTitleId)]) { + return prev; + } + + const defaults = buildDefaultTrackSelection(mediaInfoReview); + const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] }; + return { + ...prev, + [currentTitleId]: fallback + }; + }); + }, [selectedEncodeTitleId, mediaInfoReview?.generatedAt]); + + const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired); + const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId)); + const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle; + const canStartReadyJob = isPreRipReview + ? Boolean(retryJobId) + : Boolean(retryJobId && encodeInputPath); + const canRestartEncodeFromLastSettings = Boolean( + state === 'ERROR' + && retryJobId + && pipeline?.context?.canRestartEncodeFromLastSettings + ); + + const waitingPlaylistRows = useMemo(() => { + const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates) + ? playlistAnalysis.evaluatedCandidates + : []; + + const rows = evaluated.length > 0 + ? evaluated + : (Array.isArray(pipeline?.context?.playlistCandidates) ? pipeline.context.playlistCandidates : []); + + const normalized = rows + .map((item) => { + const playlistId = normalizePlaylistId(item?.playlistId || item?.playlistFile || item); + if (!playlistId) { + return null; + } + const playlistFile = `${playlistId}.mpls`; + const score = Number(item?.score); + const sequenceCoherence = Number( + item?.structuralMetrics?.sequenceCoherence ?? item?.sequenceCoherence + ); + const handBrakeTitleId = Number(item?.handBrakeTitleId); + return { + playlistId, + playlistFile, + titleId: Number.isFinite(Number(item?.titleId)) ? Number(item.titleId) : null, + score: Number.isFinite(score) ? score : null, + evaluationLabel: item?.evaluationLabel || null, + segmentCommand: item?.segmentCommand + || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, + segmentFiles: Array.isArray(item?.segmentFiles) ? item.segmentFiles : [], + sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null, + recommended: Boolean(item?.recommended), + handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0 + ? Math.trunc(handBrakeTitleId) + : null, + audioSummary: item?.audioSummary || null, + audioTrackPreview: Array.isArray(item?.audioTrackPreview) ? item.audioTrackPreview : [] + }; + }) + .filter(Boolean); + + const dedup = []; + const seen = new Set(); + for (const row of normalized) { + if (seen.has(row.playlistId)) { + continue; + } + seen.add(row.playlistId); + dedup.push(row); + } + return dedup; + }, [playlistAnalysis, pipeline?.context?.playlistCandidates]); + + useEffect(() => { + if (state !== 'WAITING_FOR_USER_DECISION') { + setSelectedPlaylistId(null); + return; + } + + const current = normalizePlaylistId(pipeline?.context?.selectedPlaylist); + if (current) { + setSelectedPlaylistId(current); + return; + } + + const recommendedFromRows = waitingPlaylistRows.find((item) => item.recommended)?.playlistId || null; + const recommendedFromAnalysis = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId); + const fallback = waitingPlaylistRows[0]?.playlistId || null; + setSelectedPlaylistId(recommendedFromRows || recommendedFromAnalysis || fallback); + }, [ + state, + retryJobId, + waitingPlaylistRows, + playlistAnalysis?.recommendation?.playlistId, + pipeline?.context?.selectedPlaylist + ]); + + const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION'; + const commandOutputPath = useMemo( + () => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId), + [settingsMap, selectedMetadata, retryJobId] + ); + + return ( + +
+ + {pipeline?.statusText || 'Bereit'} +
+ + {running && ( +
+ + {pipeline?.eta ? `ETA ${pipeline.eta}` : 'ETA unbekannt'} +
+ )} + + {state === 'FINISHED' && ( +
+ +
+ )} + +
+ {(state === 'DISC_DETECTED' || state === 'IDLE') && ( +
+ + {running ? ( +
+

Aktueller Job-Log

+
{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}
+
+ ) : null} + + {playlistDecisionRequiredBeforeStart ? ( +
+

Playlist-Auswahl erforderlich

+ + Metadaten sind abgeschlossen. Vor Start muss ein Titel/Playlist manuell per Checkbox gewählt werden. + + {waitingPlaylistRows.length > 0 ? ( +
+ {waitingPlaylistRows.map((row) => ( +
+ + {row.evaluationLabel ? {row.evaluationLabel} : null} + {row.sequenceCoherence !== null ? ( + Sequenz-Kohärenz: {row.sequenceCoherence.toFixed(3)} + ) : null} + {row.handBrakeTitleId !== null ? ( + HandBrake Titel: -t {row.handBrakeTitleId} + ) : null} + {row.audioSummary ? ( + Audio: {row.audioSummary} + ) : null} + {row.segmentCommand ? Info: {row.segmentCommand} : null} + {Array.isArray(row.audioTrackPreview) && row.audioTrackPreview.length > 0 ? ( +
+ Audio-Spuren anzeigen ({row.audioTrackPreview.length}) +
{row.audioTrackPreview.join('\n')}
+
+ ) : null} + {Array.isArray(row.segmentFiles) && row.segmentFiles.length > 0 ? ( +
+ Segment-Dateien anzeigen ({row.segmentFiles.length}) +
{row.segmentFiles.join('\n')}
+
+ ) : ( + Keine Segmentliste aus TINFO:26 verfügbar. + )} +
+ ))} +
+ ) : ( + Keine Kandidaten gefunden. Bitte Analyse erneut ausführen. + )} +
+ ) : null} + + {selectedMetadata ? ( +
+ {selectedMetadata.poster ? ( + {selectedMetadata.title + ) : ( +
Kein Poster
+ )} +
+
+ Titel: {selectedMetadata.title || '-'} +
+
+ Jahr: {selectedMetadata.year || '-'} +
+
+ IMDb: {selectedMetadata.imdbId || '-'} +
+
+ Status: {state} +
+
+
+ ) : null} + + {(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? ( +
+

Titel-/Spurprüfung

+ {state === 'READY_TO_ENCODE' && !reviewConfirmed ? ( + + {isPreRipReview + ? 'Backup/Rip + Encode ist gesperrt, bis die Spurauswahl bestätigt wurde.' + : 'Encode ist gesperrt, bis die Titel-/Spurauswahl bestätigt wurde.'} + {reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''} + + ) : null} + setSelectedEncodeTitleId(normalizeTitleId(titleId))} + allowTrackSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed} + trackSelectionByTitle={trackSelectionByTitle} + onTrackSelectionChange={(titleId, trackType, trackId, checked) => { + const normalizedTitleId = normalizeTitleId(titleId); + const normalizedTrackId = normalizeTrackId(trackId); + if (!normalizedTitleId || normalizedTrackId === null) { + return; + } + + setTrackSelectionByTitle((prev) => { + const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || { + audioTrackIds: [], + subtitleTrackIds: [] + }; + const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds'; + const existing = normalizeTrackIdList(current?.[key] || []); + const next = checked + ? normalizeTrackIdList([...existing, normalizedTrackId]) + : existing.filter((id) => id !== normalizedTrackId); + + return { + ...prev, + [normalizedTitleId]: { + ...current, + [key]: next + } + }; + }); + }} + /> +
+ ) : null} +
+ ); +} diff --git a/frontend/src/hooks/useWebSocket.js b/frontend/src/hooks/useWebSocket.js new file mode 100644 index 0000000..5b091c5 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.js @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react'; + +function buildWsUrl() { + if (import.meta.env.VITE_WS_URL) { + return import.meta.env.VITE_WS_URL; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/ws`; +} + +export function useWebSocket({ onMessage }) { + const onMessageRef = useRef(onMessage); + onMessageRef.current = onMessage; + + useEffect(() => { + let socket; + let reconnectTimer; + let isUnmounted = false; + + const connect = () => { + if (isUnmounted) { + return; + } + + socket = new WebSocket(buildWsUrl()); + + socket.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + onMessageRef.current?.(message); + } catch (error) { + // ignore invalid json + } + }; + + socket.onclose = () => { + if (!isUnmounted) { + reconnectTimer = setTimeout(connect, 1500); + } + }; + + socket.onerror = () => { + if (socket && socket.readyState !== WebSocket.CLOSED) { + socket.close(); + } + }; + }; + + connect(); + + return () => { + isUnmounted = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + if (socket && socket.readyState !== WebSocket.CLOSED) { + socket.close(); + } + }; + }, []); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..69a9317 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import 'primereact/resources/themes/lara-light-amber/theme.css'; +import 'primereact/resources/primereact.min.css'; +import 'primeicons/primeicons.css'; +import './styles/app.css'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + +); diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..055c40d --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -0,0 +1,629 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Toast } from 'primereact/toast'; +import { Card } from 'primereact/card'; +import { Button } from 'primereact/button'; +import { Tag } from 'primereact/tag'; +import { ProgressBar } from 'primereact/progressbar'; +import { api } from '../api/client'; +import PipelineStatusCard from '../components/PipelineStatusCard'; +import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; +import blurayIndicatorIcon from '../assets/media-bluray.svg'; +import discIndicatorIcon from '../assets/media-disc.svg'; + +const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING']; +const dashboardStatuses = new Set([ + 'ANALYZING', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'READY_TO_START', + 'MEDIAINFO_CHECK', + 'READY_TO_ENCODE', + 'RIPPING', + 'ENCODING', + 'ERROR' +]); +const statusSeverityMap = { + IDLE: 'secondary', + DISC_DETECTED: 'info', + ANALYZING: 'warning', + METADATA_SELECTION: 'warning', + WAITING_FOR_USER_DECISION: 'warning', + READY_TO_START: 'info', + MEDIAINFO_CHECK: 'warning', + READY_TO_ENCODE: 'info', + RIPPING: 'warning', + ENCODING: 'warning', + FINISHED: 'success', + ERROR: 'danger' +}; + +function normalizeJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function getAnalyzeContext(job) { + return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object' + ? job.makemkvInfo.analyzeContext + : {}; +} + +function resolveMediaType(job) { + const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase(); + return raw === 'bluray' ? 'bluray' : 'disc'; +} + +function mediaIndicatorMeta(job) { + const mediaType = resolveMediaType(job); + return mediaType === 'bluray' + ? { + mediaType, + src: blurayIndicatorIcon, + alt: 'Blu-ray', + title: 'Blu-ray' + } + : { + mediaType, + src: discIndicatorIcon, + alt: 'Disc', + title: 'CD/sonstiges Medium' + }; +} + +function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) { + const jobId = normalizeJobId(job?.id); + if ( + jobId + && currentPipelineJobId + && jobId === currentPipelineJobId + && String(currentPipeline?.state || '').trim().toUpperCase() !== 'IDLE' + ) { + return currentPipeline; + } + + const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null; + const analyzeContext = getAnalyzeContext(job); + const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase(); + const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip); + const inputPath = isPreRip + ? null + : (job?.encode_input_path || encodePlan?.encodeInputPath || null); + const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed); + const hasEncodableTitle = isPreRip + ? Boolean(encodePlan?.encodeInputTitleId) + : Boolean(inputPath || job?.raw_path); + const jobStatus = String(job?.status || job?.last_state || 'IDLE').trim().toUpperCase() || 'IDLE'; + const lastState = String(job?.last_state || '').trim().toUpperCase(); + const errorText = String(job?.error_message || '').trim().toUpperCase(); + const hasEncodePlan = Boolean(encodePlan && Array.isArray(encodePlan?.titles) && encodePlan.titles.length > 0); + const looksLikeEncodingError = jobStatus === 'ERROR' && ( + errorText.includes('ENCODING') + || errorText.includes('HANDBRAKE') + || lastState === 'ENCODING' + || Boolean(job?.handbrakeInfo) + ); + const canRestartEncodeFromLastSettings = Boolean( + hasEncodePlan + && reviewConfirmed + && hasEncodableTitle + && ( + jobStatus === 'READY_TO_ENCODE' + || jobStatus === 'ENCODING' + || looksLikeEncodingError + ) + ); + + return { + state: jobStatus, + activeJobId: jobId, + progress: Number.isFinite(Number(job?.progress)) ? Number(job.progress) : 0, + eta: job?.eta || null, + statusText: job?.status_text || job?.error_message || null, + context: { + jobId, + inputPath, + hasEncodableTitle, + reviewConfirmed, + mode, + sourceJobId: encodePlan?.sourceJobId || null, + selectedMetadata: { + title: job?.title || job?.detected_title || null, + year: job?.year || null, + imdbId: job?.imdb_id || null, + poster: job?.poster_url || null + }, + mediaInfoReview: encodePlan, + playlistAnalysis: analyzeContext.playlistAnalysis || null, + playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired), + playlistCandidates: Array.isArray(analyzeContext?.playlistAnalysis?.evaluatedCandidates) + ? analyzeContext.playlistAnalysis.evaluatedCandidates + : [], + selectedPlaylist: analyzeContext.selectedPlaylist || null, + selectedTitleId: analyzeContext.selectedTitleId ?? null, + canRestartEncodeFromLastSettings + } + }; +} + +export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline }) { + const [busy, setBusy] = useState(false); + const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); + const [liveJobLog, setLiveJobLog] = useState(''); + const [jobsLoading, setJobsLoading] = useState(false); + const [dashboardJobs, setDashboardJobs] = useState([]); + const [expandedJobId, setExpandedJobId] = useState(undefined); + const toastRef = useRef(null); + + const state = String(pipeline?.state || 'IDLE').trim().toUpperCase(); + const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId); + const isProcessing = processingStates.includes(state); + + const loadDashboardJobs = async () => { + setJobsLoading(true); + try { + const response = await api.getJobs(); + const allJobs = Array.isArray(response?.jobs) ? response.jobs : []; + const next = allJobs + .filter((job) => dashboardStatuses.has(String(job?.status || '').trim().toUpperCase())) + .sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0)); + + if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) { + try { + const active = await api.getJob(currentPipelineJobId); + if (active?.job) { + next.unshift(active.job); + } + } catch (_error) { + // ignore; dashboard still shows available rows + } + } + + const seen = new Set(); + const deduped = []; + for (const job of next) { + const id = normalizeJobId(job?.id); + if (!id || seen.has(String(id))) { + continue; + } + seen.add(String(id)); + deduped.push(job); + } + + setDashboardJobs(deduped); + } catch (_error) { + setDashboardJobs([]); + } finally { + setJobsLoading(false); + } + }; + + useEffect(() => { + if (pipeline?.state !== 'METADATA_SELECTION' && pipeline?.state !== 'WAITING_FOR_USER_DECISION') { + setMetadataDialogVisible(false); + } + }, [pipeline?.state]); + + useEffect(() => { + void loadDashboardJobs(); + }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId]); + + useEffect(() => { + const normalizedExpanded = normalizeJobId(expandedJobId); + const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded); + if (hasExpanded) { + return; + } + + // Respect explicit user collapse. + if (expandedJobId === null) { + return; + } + + if (currentPipelineJobId && dashboardJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) { + setExpandedJobId(currentPipelineJobId); + return; + } + setExpandedJobId(normalizeJobId(dashboardJobs[0]?.id)); + }, [dashboardJobs, expandedJobId, currentPipelineJobId]); + + useEffect(() => { + if (!currentPipelineJobId || !isProcessing) { + setLiveJobLog(''); + return undefined; + } + + let cancelled = false; + const refreshLiveLog = async () => { + try { + const response = await api.getJob(currentPipelineJobId, { includeLiveLog: true }); + if (!cancelled) { + setLiveJobLog(response?.job?.log || ''); + } + } catch (_error) { + // ignore transient polling errors to avoid noisy toasts while background polling + } + }; + + void refreshLiveLog(); + const interval = setInterval(refreshLiveLog, 2500); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [currentPipelineJobId, isProcessing]); + + const pipelineByJobId = useMemo(() => { + const map = new Map(); + for (const job of dashboardJobs) { + const id = normalizeJobId(job?.id); + if (!id) { + continue; + } + map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId)); + } + return map; + }, [dashboardJobs, pipeline, currentPipelineJobId]); + + const showError = (error) => { + toastRef.current?.show({ + severity: 'error', + summary: 'Fehler', + detail: error.message, + life: 4500 + }); + }; + + const handleAnalyze = async () => { + setBusy(true); + try { + await api.analyzeDisc(); + await refreshPipeline(); + await loadDashboardJobs(); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleReanalyze = async () => { + const hasActiveJob = Boolean(pipeline?.context?.jobId || pipeline?.activeJobId); + if (hasActiveJob && !['IDLE', 'DISC_DETECTED', 'FINISHED'].includes(state)) { + const confirmed = window.confirm( + 'Aktuellen Ablauf verwerfen und die Disk ab der ersten MakeMKV-Analyse neu starten?' + ); + if (!confirmed) { + return; + } + } + await handleAnalyze(); + }; + + const handleRescan = async () => { + setBusy(true); + try { + const response = await api.rescanDisc(); + const emitted = response?.result?.emitted || 'none'; + toastRef.current?.show({ + severity: emitted === 'discInserted' ? 'success' : 'info', + summary: 'Laufwerk neu gelesen', + detail: + emitted === 'discInserted' + ? 'Disk-Event wurde neu ausgelöst.' + : 'Kein Medium erkannt.', + life: 2800 + }); + await refreshPipeline(); + await loadDashboardJobs(); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleCancel = async () => { + setBusy(true); + try { + await api.cancelPipeline(); + await refreshPipeline(); + await loadDashboardJobs(); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleStartJob = async (jobId) => { + setBusy(true); + try { + await api.startJob(jobId); + await refreshPipeline(); + await loadDashboardJobs(); + setExpandedJobId(normalizeJobId(jobId)); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleConfirmReview = async (jobId, selectedEncodeTitleId = null, selectedTrackSelection = null) => { + setBusy(true); + try { + await api.confirmEncodeReview(jobId, { + selectedEncodeTitleId, + selectedTrackSelection + }); + await refreshPipeline(); + await loadDashboardJobs(); + setExpandedJobId(normalizeJobId(jobId)); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleSelectPlaylist = async (jobId, selectedPlaylist = null) => { + setBusy(true); + try { + await api.selectMetadata({ + jobId, + selectedPlaylist: selectedPlaylist || null + }); + await refreshPipeline(); + await loadDashboardJobs(); + setExpandedJobId(normalizeJobId(jobId)); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleRetry = async (jobId) => { + setBusy(true); + try { + await api.retryJob(jobId); + await refreshPipeline(); + await loadDashboardJobs(); + setExpandedJobId(normalizeJobId(jobId)); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleRestartEncodeWithLastSettings = async (jobId) => { + setBusy(true); + try { + await api.restartEncodeWithLastSettings(jobId); + await refreshPipeline(); + await loadDashboardJobs(); + setExpandedJobId(normalizeJobId(jobId)); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const handleOmdbSearch = async (query) => { + try { + const response = await api.searchOmdb(query); + return response.results || []; + } catch (error) { + showError(error); + return []; + } + }; + + const handleMetadataSubmit = async (payload) => { + setBusy(true); + try { + await api.selectMetadata(payload); + await refreshPipeline(); + await loadDashboardJobs(); + setMetadataDialogVisible(false); + } catch (error) { + showError(error); + } finally { + setBusy(false); + } + }; + + const device = lastDiscEvent || pipeline?.context?.device; + const canReanalyze = !processingStates.includes(state); + const canOpenMetadataModal = pipeline?.state === 'METADATA_SELECTION' || pipeline?.state === 'WAITING_FOR_USER_DECISION'; + + return ( +
+ + + + {jobsLoading ? ( +

Jobs werden geladen ...

+ ) : dashboardJobs.length === 0 ? ( +

Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).

+ ) : ( +
+ {dashboardJobs.map((job) => { + const jobId = normalizeJobId(job?.id); + if (!jobId) { + return null; + } + const isExpanded = normalizeJobId(expandedJobId) === jobId; + const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE'; + const isResumable = String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' && !isCurrentSession; + const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0)); + const pipelineForJob = pipelineByJobId.get(jobId) || pipeline; + const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`; + const mediaIndicator = mediaIndicatorMeta(job); + const rawProgress = Number(pipelineForJob?.progress ?? 0); + const clampedProgress = Number.isFinite(rawProgress) + ? Math.max(0, Math.min(100, rawProgress)) + : 0; + const progressLabel = `${Math.round(clampedProgress)}%`; + const etaLabel = String(pipelineForJob?.eta || '').trim(); + + if (isExpanded) { + return ( +
+
+
+ + {mediaIndicator.alt} + #{jobId} | {jobTitle} + +
+ + {isCurrentSession ? : null} + {isResumable ? : null} + {String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' + ? + : null} +
+
+
+ +
+ ); + } + + return ( +