1.0.0 1.0.0 final
This commit is contained in:
@@ -1,257 +1,232 @@
|
|||||||
# Ripster
|
# Ripster
|
||||||
|
|
||||||
Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Plugin-basierte Architektur mit MakeMKV + HandBrake + FFmpeg, inklusive Metadaten-Auswahl, Track-Review, Queue, Skripten/Ketten und Job-Historie.
|
Ripster ist eine lokale Web-Anwendung für Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Das System kombiniert MakeMKV, HandBrake, FFmpeg, `cdparanoia`, `mkvmerge`, SQLite und eine React-Oberfläche zu einer durchgehenden Pipeline mit Queue, Historie, Downloads, Skript-Automation und Echtzeitstatus per WebSocket.
|
||||||
|
|
||||||
---
|
## Funktionsumfang
|
||||||
|
|
||||||
## Was Ripster kann
|
### Medien-Workflows
|
||||||
|
- Blu-ray-Ripping und -Encoding
|
||||||
|
- DVD-Ripping und -Encoding
|
||||||
|
- Audio-CD-Ripping und -Encoding
|
||||||
|
- Audiobook-Verarbeitung aus `.aax`
|
||||||
|
- Datei-Converter für Audio-, Video- und ISO-Dateien
|
||||||
|
|
||||||
### Disc-Ripping & Encoding
|
### Video (Blu-ray / DVD)
|
||||||
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
|
- automatische Disc-Erkennung, Rescan einzelner Laufwerke und Live-Status im Ripper
|
||||||
- Medienprofil-Erkennung (Blu-ray/DVD/CD/Sonstiges) aus Device-/Filesystem-Heuristik
|
- Metadaten-Suche über TMDB für Filme und Serien
|
||||||
- Metadaten-Suche und Zuordnung über OMDb
|
- HandBrake-Review mit Playlist-/Titel-Auswahl, Audio-/Untertitel-Track-Auswahl und Encode-Vorschau
|
||||||
- MakeMKV-Analyse und Rip (`mkv` oder `backup`) mit profilspezifischen Settings
|
- MakeMKV-Rip mit profilspezifischen Modi und Zusatzargumenten
|
||||||
- HandBrake-Review und Encoding mit Track-Auswahl, User-Presets, Extra-Args
|
- HandBrake-Encoding mit User-Presets, offiziellen HandBrake-Presets und Extra-Args
|
||||||
- **Audio-CD-Ripping** mit cdparanoia + Encoding nach FLAC/MP3/Opus/Vorbis (experimentell)
|
- Serien-Workflows für DVD- und Blu-ray-Discs inkl. Episoden-Zuordnung und Batch-Encoding
|
||||||
|
- Multipart-Movie-Workflows über mehrere Discs inkl. Merge-Job via `mkvmerge`
|
||||||
|
- Re-Encode aus RAW, Review-Neustart, Encode-Neustart, Retry und Resume laufender/unterbrochener Jobs
|
||||||
|
|
||||||
### Audiobook-Verarbeitung
|
### Audio-CD
|
||||||
- **AAX/Audible-Dateien** verarbeiten (Activation Bytes, DRM-Handling)
|
- TOC-Analyse und Rip mit `cdparanoia`
|
||||||
- FFmpeg-basiertes Kapitel-Splitting
|
- MusicBrainz-Suche und Übernahme von Album-/Track-Metadaten
|
||||||
- Ausgabeformate: M4B, MP3, FLAC mit Metadaten
|
- Track-Auswahl und Ausgabe als FLAC, WAV, MP3, Opus oder Ogg Vorbis
|
||||||
- MusicBrainz-Metadaten-Lookup
|
- RAW-Wiederverwendung und CD-Review-/Encode-Neustart aus vorhandenen Daten
|
||||||
|
|
||||||
### Datei-Converter
|
### Audiobooks
|
||||||
- **Generische Audio/Video-Dateien** konvertieren (MKV, MP4, FLAC, MP3, u. v. m.)
|
- Upload und Verarbeitung von Audible-/AAX-Dateien
|
||||||
- Datei-Explorer mit Upload, Umbenennen, Verschieben, Löschen
|
- Activation-Bytes-Cache für DRM-geschützte Titel
|
||||||
- Automatischer Verzeichnis-Scan (Polling konfigurierbar)
|
- Metadaten- und Kapitelanreicherung über Audnex plus lokale Probe-Daten
|
||||||
|
- Ausgabe als M4B, MP3 oder FLAC
|
||||||
|
- Einzeldatei oder kapitelweises Splitten
|
||||||
|
|
||||||
### Automatisierung & Verwaltung
|
### Converter
|
||||||
- Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten)
|
- Dateibaum und Datei-Explorer für das Converter-RAW-Verzeichnis
|
||||||
- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`)
|
- Upload, Ordner anlegen, Umbenennen, Verschieben und Löschen
|
||||||
- Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung)
|
- Jobs aus Dateiauswahl erzeugen, Dateien bestehenden Jobs zuweisen oder daraus entfernen
|
||||||
- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Ripper
|
- Audio-/Video-Erkennung und automatische RAW-Scans per Polling
|
||||||
- Download-Queue: Ausgabedateien als ZIP herunterladen
|
- TMDB-Metadatenzuordnung für passende Video-Jobs
|
||||||
- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import
|
|
||||||
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Ripper
|
|
||||||
|
|
||||||
## Tech-Stack
|
### Automation, Betrieb und Verwaltung
|
||||||
|
- Queue mit normalen Jobs sowie zusätzlichen `script`-, `chain`- und `wait`-Einträgen
|
||||||
|
- Pre-/Post-Encode-Skripte und Skript-Ketten
|
||||||
|
- Cron-Jobs für Skripte und Ketten inkl. Validierung, Logs und manueller Auslösung
|
||||||
|
- Download-Queue für ZIP-Archive aus Historienjobs
|
||||||
|
- Historie mit Detailansicht, Re-Encode, Review-/Encode-Neustart, Retry und Löschfunktionen
|
||||||
|
- `/database`-Ansicht für Orphan-RAW-Ordner (Import oder Löschen vorhandener RAW-Daten)
|
||||||
|
- Hardware-Monitoring mit Live-Werten und Verlaufshistorie
|
||||||
|
- Pushover-Benachrichtigungen für zentrale Pipeline-Ereignisse
|
||||||
|
- MakeMKV-Betakey-Prüfung/-Übernahme und Cover-Art-Recovery
|
||||||
|
|
||||||
- Backend: Node.js, Express, SQLite, WebSocket (`ws`) – **Plugin-Architektur**
|
## Oberfläche
|
||||||
- Frontend: React, Vite, PrimeReact
|
|
||||||
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`, `ffmpeg`/`ffprobe`, `cdparanoia`
|
|
||||||
|
|
||||||
## Dokumentation
|
- `Ripper`: Disc-Erkennung, Pipeline-Status, Queue, Review-Dialoge und aktive Jobs
|
||||||
|
- `Converter`: Dateibaum, Uploads und Converter-Jobs
|
||||||
|
- `Audiobooks`: AAX-Uploads und Audiobook-Jobs
|
||||||
|
- `Settings`: Pfade, Tools, Templates, Queue, Monitoring, Notifications, Scripts, Chains, Presets
|
||||||
|
- `Historie`: abgeschlossene und fehlerhafte Jobs mit Folgeaktionen
|
||||||
|
- `Downloads`: vorbereitete ZIP-Downloads
|
||||||
|
- `Database`: Orphan-RAW-Verwaltung (im Expertenmodus)
|
||||||
|
- Zusatzansichten: `Hardware` und `TMDB Migration`
|
||||||
|
|
||||||
- Ausführliche Dokumentation: https://mboehmlaender.github.io/ripster/
|
## Installation
|
||||||
|
|
||||||
## Voraussetzungen
|
Der korrekte Bootstrap läuft über `setup.sh`. `setup.sh` lädt das passende `install.sh` aus dem gewünschten Branch und startet es mit denselben Parametern.
|
||||||
|
|
||||||
- Debian 11/12 oder Ubuntu 22.04/24.04
|
Unterstützte Systeme laut Installer:
|
||||||
- root-Rechte + Internetzugang
|
- Debian
|
||||||
- optisches Laufwerk (oder gemountete Quelle) für Disc-Ripping
|
- Ubuntu
|
||||||
- Netzwerk-Zugang für Audiobook-Upload und Datei-Converter optional
|
- Linux Mint
|
||||||
|
- Pop!_OS
|
||||||
|
|
||||||
## Schnellstart (`install.sh`)
|
Standardinstallation:
|
||||||
|
|
||||||
Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh
|
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh | bash -s -- --branch dev
|
||||||
sudo bash install.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternativ direkt per Pipe:
|
Alternativ per Datei:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
wget -qO setup.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh
|
||||||
|
bash setup.sh --branch dev
|
||||||
```
|
```
|
||||||
|
|
||||||
`install.sh` übernimmt u. a.:
|
Hinweise:
|
||||||
|
- `setup.sh` nutzt bei Bedarf `sudo`; root-Rechte und Internetzugang sind erforderlich.
|
||||||
|
- Ohne `--branch` bietet `setup.sh` lokal eine Branch-Auswahl an. Bei `curl | bash` sollte `--branch` gesetzt werden.
|
||||||
|
- Der eigentliche Installer ist `install.sh`; `setup.sh` ist der empfohlene Einstiegspunkt.
|
||||||
|
|
||||||
- Node.js 20 (falls nötig)
|
Wichtige Standardparameter für den Installationslauf:
|
||||||
- Basistools inkl. `mediainfo`, `ffmpeg`, `ffprobe`
|
|
||||||
- CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
|
|
||||||
- MakeMKV
|
|
||||||
- HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding)
|
|
||||||
- nginx
|
|
||||||
- Repository-Checkout, npm-Install, Frontend-Build, systemd-Service (`ripster-backend`)
|
|
||||||
|
|
||||||
Danach ist Ripster unter `http://<Server-IP>` erreichbar.
|
|
||||||
|
|
||||||
Wichtige Optionen:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash install.sh --branch dev # Branch wählen (Default im Skript: dev)
|
bash setup.sh --branch dev --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10
|
||||||
sudo bash install.sh --dir /opt/ripster # Installationspfad
|
|
||||||
sudo bash install.sh --user ripster # Service-User
|
|
||||||
sudo bash install.sh --port 3001 # Backend-Port
|
|
||||||
sudo bash install.sh --host 192.168.1.10 # Host/IP für nginx/CORS
|
|
||||||
sudo bash install.sh --no-makemkv # MakeMKV überspringen
|
|
||||||
sudo bash install.sh --no-handbrake # HandBrake überspringen
|
|
||||||
sudo bash install.sh --no-nginx # nginx-Setup überspringen
|
|
||||||
sudo bash install.sh --reinstall # Update (Daten bleiben erhalten)
|
|
||||||
sudo bash install.sh --help # Hilfe anzeigen
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Verfügbare Optionen:
|
||||||
|
- `--branch <branch>`
|
||||||
|
- `--dir <pfad>`
|
||||||
|
- `--user <benutzer>`
|
||||||
|
- `--port <port>`
|
||||||
|
- `--host <hostname|ip>`
|
||||||
|
- `--no-makemkv`
|
||||||
|
- `--no-handbrake`
|
||||||
|
- `--no-nginx`
|
||||||
|
- `--no-system-deps`
|
||||||
|
- `--reinstall`
|
||||||
|
- `--help`
|
||||||
|
|
||||||
|
Was der Installer typischerweise einrichtet:
|
||||||
|
- Node.js 20
|
||||||
|
- `ffmpeg`, `ffprobe`, `mediainfo`, `mkvtoolnix`
|
||||||
|
- CD-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
|
||||||
|
- optional MakeMKV
|
||||||
|
- optional HandBrakeCLI
|
||||||
|
- optional nginx
|
||||||
|
- Repository-Checkout bzw. Update
|
||||||
|
- npm-Abhängigkeiten, Frontend-Build und `ripster-backend`-systemd-Service
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
Standard-Update einer bestehenden Installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/ripster/install.sh --reinstall --branch dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn die Installation mit abweichenden Kernparametern eingerichtet wurde, diese beim Update wieder mitgeben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/ripster/install.sh --reinstall --branch dev --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ kann auch erneut über `setup.sh` gebootstrapped werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh | bash -s -- --branch dev --reinstall
|
||||||
|
```
|
||||||
|
|
||||||
|
`--reinstall` aktualisiert die Installation und behält die persistenten Daten der bestehenden Instanz bei.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
Schnellstart für lokale Entwicklung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` prüft Node.js, installiert fehlende Abhängigkeiten und startet Backend und Frontend im Dev-Modus.
|
||||||
|
|
||||||
|
Wichtige npm-Skripte:
|
||||||
|
- `npm run dev`
|
||||||
|
- `npm run dev:backend`
|
||||||
|
- `npm run dev:frontend`
|
||||||
|
- `npm run build:frontend`
|
||||||
|
- `npm run start`
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
### UI-Settings (empfohlen)
|
Die meisten Einstellungen werden in der Weboberfläche unter `Settings` gepflegt und in SQLite gespeichert. Dazu gehören insbesondere:
|
||||||
|
- Pfade und Output-Templates für Blu-ray, DVD, Serie, CD, Audiobook, Converter, Downloads und Logs
|
||||||
|
- Tool-Kommandos für MakeMKV, HandBrake, MediaInfo, FFmpeg, FFprobe, `cdparanoia` und `mkvmerge`
|
||||||
|
- Laufwerksmodus, Disc-Erkennung und Queue-/Parallelisierungsregeln
|
||||||
|
- Hardware-Monitoring, Logging, Expert Mode und Cover-Art-Recovery
|
||||||
|
- TMDB-Zugangsdaten, Pushover, Skripte, Skript-Ketten, User-Presets und Preset-Defaults
|
||||||
|
|
||||||
Die meisten Einstellungen werden in der App unter `Settings` gepflegt und in SQLite gespeichert:
|
Zusätzliche Bootstrap-/Override-Variablen:
|
||||||
|
|
||||||
- Pfade: `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` (jeweils mit Blu-ray/DVD/Audiobook-Varianten)
|
Backend:
|
||||||
- Pfade Converter: `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)`
|
- `PORT`
|
||||||
- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`, `FFmpeg Kommando`, `FFprobe Kommando`
|
- `DB_PATH`
|
||||||
- Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat)
|
- `LOG_DIR`
|
||||||
- Audiobook: Output-Template, RAW-Template
|
- `LOG_LEVEL`
|
||||||
- Converter: erlaubte Endungen, Auto-Scan-Intervall, Output-Templates
|
- `CORS_ORIGIN`
|
||||||
- Queue/Monitoring: `Parallele Jobs`, `Hardware Monitoring aktiviert`, `Hardware Monitoring Intervall (ms)`
|
- `DEFAULT_TEMP_DIR`
|
||||||
- Benachrichtigungen: PushOver
|
- `DEFAULT_RAW_DIR`
|
||||||
|
- `DEFAULT_MOVIE_DIR`
|
||||||
|
- `DEFAULT_SERIES_DIR`
|
||||||
|
- `DEFAULT_CD_DIR`
|
||||||
|
- `DEFAULT_AUDIOBOOK_RAW_DIR`
|
||||||
|
- `DEFAULT_AUDIOBOOK_DIR`
|
||||||
|
- `DEFAULT_DOWNLOAD_DIR`
|
||||||
|
- `DEFAULT_CONVERTER_RAW_DIR`
|
||||||
|
- `DEFAULT_CONVERTER_MOVIE_DIR`
|
||||||
|
- `DEFAULT_CONVERTER_AUDIO_DIR`
|
||||||
|
|
||||||
### Umgebungsvariablen
|
Frontend:
|
||||||
|
- `VITE_API_BASE`
|
||||||
|
- `VITE_WS_URL`
|
||||||
|
|
||||||
Backend (`backend/src/config.js`):
|
## Daten, Logs und API
|
||||||
|
|
||||||
- `PORT` (Default: `3001`)
|
- Standard-DB: `backend/data/ripster.db`
|
||||||
- `DB_PATH` (Default: `backend/data/ripster.db`)
|
- Standard-Logs: `backend/logs`
|
||||||
- `LOG_DIR` (Default: `backend/logs`)
|
- Standard-Outputs liegen relativ zum Datenverzeichnis, sofern in den Settings nichts anderes gesetzt ist.
|
||||||
- `CORS_ORIGIN` (Default: `*`)
|
- Das Schema wird beim Start geprüft und fehlende DB-Elemente werden migriert.
|
||||||
- `LOG_LEVEL` (`debug|info|warn|error`, Default: `info`)
|
|
||||||
|
|
||||||
Frontend (Vite):
|
REST-Gruppen:
|
||||||
|
- `/api/health`
|
||||||
|
- `/api/pipeline/*`
|
||||||
|
- `/api/converter/*`
|
||||||
|
- `/api/history/*`
|
||||||
|
- `/api/downloads/*`
|
||||||
|
- `/api/settings/*`
|
||||||
|
- `/api/crons/*`
|
||||||
|
- `/api/runtime/*`
|
||||||
|
|
||||||
- `VITE_API_BASE` (Default: `/api`)
|
Realtime:
|
||||||
- `VITE_WS_URL` (optional, überschreibt automatische WS-URL)
|
- WebSocket unter `/ws`
|
||||||
- optional für Remote-Dev: `VITE_PUBLIC_ORIGIN`, `VITE_ALLOWED_HOSTS`, `VITE_HMR_PROTOCOL`, `VITE_HMR_HOST`, `VITE_HMR_CLIENT_PORT`
|
|
||||||
|
|
||||||
## Logs und Daten
|
|
||||||
|
|
||||||
Log-Ziel ist primär der in den Settings gepflegte `log_dir`.
|
|
||||||
|
|
||||||
- Backend-Logs: `<log_dir>/backend/backend-latest.log` und Tagesdateien
|
|
||||||
- Job-Logs: `<log_dir>/job-<id>.process.log`
|
|
||||||
- DB: `backend/data/ripster.db`
|
|
||||||
|
|
||||||
Hinweis: Beim DB-Init wird das Schema geprüft und fehlende Elemente werden migriert.
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ripster/
|
ripster/
|
||||||
backend/
|
backend/
|
||||||
src/
|
|
||||||
plugins/ # Plugin-System (BluRay, DVD, CD, Audiobook, Converter)
|
|
||||||
routes/
|
|
||||||
services/
|
|
||||||
db/
|
|
||||||
utils/
|
|
||||||
frontend/
|
frontend/
|
||||||
src/
|
db/
|
||||||
pages/ # Ripper, Settings, History, Converter, Downloads, Database
|
docs/
|
||||||
components/
|
|
||||||
api/
|
|
||||||
db/schema.sql
|
|
||||||
start.sh
|
|
||||||
install.sh
|
install.sh
|
||||||
install-dev.sh
|
setup.sh
|
||||||
|
start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## API-Überblick
|
## Dokumentation
|
||||||
|
|
||||||
**Health**
|
Ausführlichere Dokumentation liegt in `docs/` und veröffentlicht unter:
|
||||||
- `GET /api/health`
|
|
||||||
|
|
||||||
**Pipeline**
|
https://mboehmlaender.github.io/ripster/
|
||||||
- `GET /api/pipeline/state`
|
|
||||||
- `POST /api/pipeline/analyze`
|
|
||||||
- `POST /api/pipeline/rescan-disc`
|
|
||||||
- `POST /api/pipeline/select-metadata`
|
|
||||||
- `POST /api/pipeline/start/:jobId`
|
|
||||||
- `POST /api/pipeline/confirm-encode/:jobId`
|
|
||||||
- `POST /api/pipeline/cancel`
|
|
||||||
- `POST /api/pipeline/retry/:jobId`
|
|
||||||
- `POST /api/pipeline/reencode/:jobId`
|
|
||||||
- `POST /api/pipeline/restart-review/:jobId`
|
|
||||||
- `POST /api/pipeline/restart-encode/:jobId`
|
|
||||||
- `POST /api/pipeline/resume-ready/:jobId`
|
|
||||||
- `GET /api/pipeline/queue`
|
|
||||||
- `POST /api/pipeline/queue/reorder`
|
|
||||||
- `POST /api/pipeline/queue/entry`
|
|
||||||
- `DELETE /api/pipeline/queue/entry/:entryId`
|
|
||||||
|
|
||||||
**Converter**
|
|
||||||
- `GET /api/converter/tree`
|
|
||||||
- `GET /api/converter/browse`
|
|
||||||
- `POST /api/converter/scan`
|
|
||||||
- `POST /api/converter/create-jobs`
|
|
||||||
- `POST /api/converter/upload`
|
|
||||||
- `POST /api/converter/jobs/from-selection`
|
|
||||||
- `GET /api/converter/jobs`
|
|
||||||
- `GET /api/converter/jobs/:jobId`
|
|
||||||
- `POST /api/converter/jobs/:jobId/start`
|
|
||||||
- `POST /api/converter/jobs/:jobId/cancel`
|
|
||||||
- `DELETE /api/converter/jobs/:jobId`
|
|
||||||
- `DELETE /api/converter/files`
|
|
||||||
- `POST /api/converter/files/rename`
|
|
||||||
- `POST /api/converter/files/move`
|
|
||||||
- `POST /api/converter/files/folder`
|
|
||||||
|
|
||||||
**Downloads**
|
|
||||||
- `GET /api/downloads`
|
|
||||||
- `GET /api/downloads/summary`
|
|
||||||
- `POST /api/downloads/history/:jobId`
|
|
||||||
- `GET /api/downloads/:id/file`
|
|
||||||
- `DELETE /api/downloads/:id`
|
|
||||||
|
|
||||||
**History**
|
|
||||||
- `GET /api/history`
|
|
||||||
- `GET /api/history/:id`
|
|
||||||
- `GET /api/history/database`
|
|
||||||
- `GET /api/history/orphan-raw`
|
|
||||||
- `POST /api/history/orphan-raw/import`
|
|
||||||
- `POST /api/history/:id/omdb/assign`
|
|
||||||
- `POST /api/history/:id/delete-files`
|
|
||||||
- `POST /api/history/:id/delete`
|
|
||||||
|
|
||||||
**Settings**
|
|
||||||
- `GET /api/settings`
|
|
||||||
- `PUT /api/settings/:key`
|
|
||||||
- `PUT /api/settings`
|
|
||||||
- `GET/POST/PUT/DELETE /api/settings/scripts...`
|
|
||||||
- `GET/POST/PUT/DELETE /api/settings/script-chains...`
|
|
||||||
- `GET/POST/PUT/DELETE /api/settings/user-presets...`
|
|
||||||
- `POST /api/settings/pushover/test`
|
|
||||||
|
|
||||||
**Cron-Jobs**
|
|
||||||
- `GET /api/crons`
|
|
||||||
- `POST /api/crons`
|
|
||||||
- `GET /api/crons/:id`
|
|
||||||
- `PUT /api/crons/:id`
|
|
||||||
- `DELETE /api/crons/:id`
|
|
||||||
- `GET /api/crons/:id/logs`
|
|
||||||
- `POST /api/crons/:id/run`
|
|
||||||
- `POST /api/crons/validate-expression`
|
|
||||||
|
|
||||||
**Runtime-Aktivitäten**
|
|
||||||
- `GET /api/activities`
|
|
||||||
- `POST /api/activities/:id/cancel`
|
|
||||||
- `POST /api/activities/:id/next-step`
|
|
||||||
- `POST /api/activities/clear-recent`
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- WebSocket verbindet nicht:
|
|
||||||
- prüfen, ob Frontend über Vite-Proxy läuft (`/ws` -> Backend)
|
|
||||||
- bei Reverse-Proxy Upgrade-Header für `/ws` setzen
|
|
||||||
- Keine Disc erkannt:
|
|
||||||
- in den Settings `Laufwerksmodus` auf `Explizites Device` stellen und `Device Pfad` setzen (z. B. `/dev/sr0`)
|
|
||||||
- HandBrake/MakeMKV Fehler:
|
|
||||||
- CLI-Binaries im `PATH` prüfen
|
|
||||||
- Preset-Name mit `HandBrakeCLI -z` prüfen
|
|
||||||
- Startfehler wegen Schema:
|
|
||||||
- `db/schema.sql` vorhanden halten
|
|
||||||
|
|
||||||
## Sicherheit
|
|
||||||
|
|
||||||
- Keine echten Tokens/Passwörter ins Repository committen.
|
|
||||||
- Lokale Secrets in `.env` oder in Settings pflegen, aber nicht versionieren.
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1169,6 +1169,12 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
);
|
);
|
||||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0')`);
|
||||||
|
|
||||||
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`);
|
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`);
|
||||||
await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`);
|
await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`);
|
||||||
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_upper_pct'`);
|
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_upper_pct'`);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const REVIEW_REFRESH_SETTING_PREFIXES = [
|
|||||||
];
|
];
|
||||||
const REVIEW_REFRESH_SETTING_KEYS = new Set([
|
const REVIEW_REFRESH_SETTING_KEYS = new Set([
|
||||||
'makemkv_min_length_minutes',
|
'makemkv_min_length_minutes',
|
||||||
|
'playlist_tmdb_runtime_subtract_percent',
|
||||||
'handbrake_preset',
|
'handbrake_preset',
|
||||||
'handbrake_extra_args',
|
'handbrake_extra_args',
|
||||||
'mediainfo_extra_args',
|
'mediainfo_extra_args',
|
||||||
@@ -102,6 +103,7 @@ const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000;
|
|||||||
const tmdbSeriesSearchCache = new Map();
|
const tmdbSeriesSearchCache = new Map();
|
||||||
const PROCESS_LOG_RESET_TERMINAL_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']);
|
const PROCESS_LOG_RESET_TERMINAL_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']);
|
||||||
const PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS = 120;
|
const PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS = 120;
|
||||||
|
const PLAYLIST_RUNTIME_SUBTRACT_PERCENT_SETTING_KEY = 'playlist_tmdb_runtime_subtract_percent';
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -228,6 +230,24 @@ function resolveSeriesDiscLabel(mediaProfile) {
|
|||||||
return 'Disc';
|
return 'Disc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReviewTrackLanguageSettings(settings = {}, mediaProfile = null, isSeriesSelection = false) {
|
||||||
|
const source = settings && typeof settings === 'object' ? settings : {};
|
||||||
|
const normalizedMediaProfile = normalizeMediaProfile(mediaProfile);
|
||||||
|
if (normalizedMediaProfile !== 'bluray' && normalizedMediaProfile !== 'dvd') {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = isSeriesSelection ? 'series' : 'movie';
|
||||||
|
const audioKey = `handbrake_review_audio_languages_${normalizedMediaProfile}_${variant}`;
|
||||||
|
const subtitleKey = `handbrake_review_subtitle_languages_${normalizedMediaProfile}_${variant}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
handbrake_review_audio_languages: source[audioKey] ?? null,
|
||||||
|
handbrake_review_subtitle_languages: source[subtitleKey] ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMovieFingerprintLabel(value) {
|
function normalizeMovieFingerprintLabel(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.normalize('NFKD')
|
.normalize('NFKD')
|
||||||
@@ -3369,6 +3389,202 @@ function extractMovieRuntimeMinutesFromMetadata(selectedMetadata = null) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueNormalizedPlaylistIds(values = []) {
|
||||||
|
const seen = new Set();
|
||||||
|
const normalized = [];
|
||||||
|
for (const value of (Array.isArray(values) ? values : [])) {
|
||||||
|
const playlistId = normalizePlaylistId(value);
|
||||||
|
if (!playlistId || seen.has(playlistId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(playlistId);
|
||||||
|
normalized.push(playlistId);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPlaylistAnalysisPlaylistId(item = null) {
|
||||||
|
return normalizePlaylistId(item?.playlistId || item?.playlistFile || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPlaylistAnalysisDurationSeconds(item = null) {
|
||||||
|
const rawSeconds = Number(item?.durationSeconds ?? item?.duration ?? 0);
|
||||||
|
return Number.isFinite(rawSeconds) && rawSeconds > 0
|
||||||
|
? Math.trunc(rawSeconds)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTmdbRuntimeFilterToPlaylistAnalysis(
|
||||||
|
playlistAnalysis,
|
||||||
|
{
|
||||||
|
selectedMetadata = null,
|
||||||
|
settings = null,
|
||||||
|
disableMinLengthFilter = false
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const analysis = playlistAnalysis && typeof playlistAnalysis === 'object'
|
||||||
|
? playlistAnalysis
|
||||||
|
: null;
|
||||||
|
if (!analysis) {
|
||||||
|
return {
|
||||||
|
playlistAnalysis: analysis,
|
||||||
|
applied: false,
|
||||||
|
reason: 'missing_analysis'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata);
|
||||||
|
if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) {
|
||||||
|
return {
|
||||||
|
playlistAnalysis: analysis,
|
||||||
|
applied: false,
|
||||||
|
reason: 'missing_runtime'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const minLengthMinutes = disableMinLengthFilter
|
||||||
|
? 0
|
||||||
|
: Number(settings?.makemkv_min_length_minutes ?? analysis?.minLengthMinutes ?? 0);
|
||||||
|
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
|
||||||
|
? Math.round(minLengthMinutes * 60)
|
||||||
|
: 0;
|
||||||
|
const configuredPercentRaw = Number(settings?.[PLAYLIST_RUNTIME_SUBTRACT_PERCENT_SETTING_KEY] ?? 0);
|
||||||
|
const configuredPercent = Number.isFinite(configuredPercentRaw) && configuredPercentRaw > 0
|
||||||
|
? configuredPercentRaw
|
||||||
|
: 0;
|
||||||
|
const expectedSeconds = Math.round(runtimeMinutes * 60);
|
||||||
|
const configuredDeductionSeconds = configuredPercent > 0
|
||||||
|
? Math.round(expectedSeconds * (configuredPercent / 100))
|
||||||
|
: 0;
|
||||||
|
const deductionSeconds = Math.max(
|
||||||
|
PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS,
|
||||||
|
configuredDeductionSeconds
|
||||||
|
);
|
||||||
|
const lowerBoundSeconds = Math.max(minLengthSeconds, expectedSeconds - deductionSeconds);
|
||||||
|
|
||||||
|
const keepPlaylistIds = new Set(
|
||||||
|
[
|
||||||
|
...(Array.isArray(analysis?.evaluatedCandidates) ? analysis.evaluatedCandidates : []),
|
||||||
|
...(Array.isArray(analysis?.candidates) ? analysis.candidates : []),
|
||||||
|
...(Array.isArray(analysis?.titles) ? analysis.titles : [])
|
||||||
|
]
|
||||||
|
.filter((item) => extractPlaylistAnalysisDurationSeconds(item) >= lowerBoundSeconds)
|
||||||
|
.map((item) => extractPlaylistAnalysisPlaylistId(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterRows = (rows, { keepWithoutPlaylist = false } = {}) => (
|
||||||
|
(Array.isArray(rows) ? rows : []).filter((item) => {
|
||||||
|
const playlistId = extractPlaylistAnalysisPlaylistId(item);
|
||||||
|
if (!playlistId) {
|
||||||
|
return keepWithoutPlaylist;
|
||||||
|
}
|
||||||
|
return keepPlaylistIds.has(playlistId);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTitles = filterRows(analysis?.titles, { keepWithoutPlaylist: true });
|
||||||
|
const filteredCandidates = filterRows(analysis?.candidates);
|
||||||
|
const filteredEvaluatedCandidates = filterRows(analysis?.evaluatedCandidates);
|
||||||
|
const filteredCandidatePlaylistsAll = uniqueNormalizedPlaylistIds(
|
||||||
|
filteredCandidates.map((item) => extractPlaylistAnalysisPlaylistId(item))
|
||||||
|
);
|
||||||
|
const filteredCandidatePlaylistsRanked = uniqueNormalizedPlaylistIds(
|
||||||
|
filteredEvaluatedCandidates.map((item) => extractPlaylistAnalysisPlaylistId(item))
|
||||||
|
);
|
||||||
|
const filteredCandidatePlaylists = filteredCandidatePlaylistsAll.length > 1
|
||||||
|
? uniqueNormalizedPlaylistIds([...filteredCandidatePlaylistsRanked, ...filteredCandidatePlaylistsAll])
|
||||||
|
: [];
|
||||||
|
const filteredDuplicateDurationGroups = (Array.isArray(analysis?.duplicateDurationGroups) ? analysis.duplicateDurationGroups : [])
|
||||||
|
.map((group) => filterRows(group))
|
||||||
|
.filter((group) => group.length > 1);
|
||||||
|
const recommendationPlaylistId = normalizePlaylistId(analysis?.recommendation?.playlistId);
|
||||||
|
const recommendationSurvives = recommendationPlaylistId && keepPlaylistIds.has(recommendationPlaylistId);
|
||||||
|
const fallbackRecommendation = filteredEvaluatedCandidates[0] || filteredCandidates[0] || null;
|
||||||
|
const nextRecommendation = recommendationSurvives
|
||||||
|
? analysis.recommendation
|
||||||
|
: (fallbackRecommendation
|
||||||
|
? {
|
||||||
|
titleId: Number.isFinite(Number(fallbackRecommendation?.titleId))
|
||||||
|
? Math.trunc(Number(fallbackRecommendation.titleId))
|
||||||
|
: null,
|
||||||
|
playlistId: extractPlaylistAnalysisPlaylistId(fallbackRecommendation),
|
||||||
|
score: Number.isFinite(Number(fallbackRecommendation?.score))
|
||||||
|
? Number(fallbackRecommendation.score)
|
||||||
|
: 0,
|
||||||
|
reason: Array.isArray(fallbackRecommendation?.reasons) && fallbackRecommendation.reasons.length > 0
|
||||||
|
? fallbackRecommendation.reasons.join('; ')
|
||||||
|
: (String(fallbackRecommendation?.evaluationLabel || '').trim() || 'höchster Struktur-Score')
|
||||||
|
}
|
||||||
|
: null);
|
||||||
|
|
||||||
|
const nextPlaylistToTitleId = Object.fromEntries(
|
||||||
|
Object.entries(
|
||||||
|
analysis?.playlistToTitleId && typeof analysis.playlistToTitleId === 'object'
|
||||||
|
? analysis.playlistToTitleId
|
||||||
|
: {}
|
||||||
|
).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key)))
|
||||||
|
);
|
||||||
|
const nextPlaylistAliasMap = Object.fromEntries(
|
||||||
|
Object.entries(
|
||||||
|
analysis?.playlistAliasMap && typeof analysis.playlistAliasMap === 'object'
|
||||||
|
? analysis.playlistAliasMap
|
||||||
|
: {}
|
||||||
|
).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key)))
|
||||||
|
);
|
||||||
|
const nextPlaylistSegments = Object.fromEntries(
|
||||||
|
Object.entries(
|
||||||
|
analysis?.playlistSegments && typeof analysis.playlistSegments === 'object'
|
||||||
|
? analysis.playlistSegments
|
||||||
|
: {}
|
||||||
|
).filter(([key]) => keepPlaylistIds.has(normalizePlaylistId(key)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredAnalysis = {
|
||||||
|
...analysis,
|
||||||
|
titles: filteredTitles,
|
||||||
|
candidates: filteredCandidates,
|
||||||
|
duplicateDurationGroups: filteredDuplicateDurationGroups,
|
||||||
|
obfuscationDetected: filteredDuplicateDurationGroups.length > 0,
|
||||||
|
manualDecisionRequired: filteredCandidatePlaylists.length > 1,
|
||||||
|
manualDecisionReason: filteredCandidatePlaylists.length > 1
|
||||||
|
? (filteredDuplicateDurationGroups.length > 0
|
||||||
|
? 'multiple_similar_candidates'
|
||||||
|
: 'multiple_candidates_after_min_length')
|
||||||
|
: null,
|
||||||
|
candidatePlaylists: filteredCandidatePlaylists,
|
||||||
|
candidatePlaylistFiles: filteredCandidatePlaylists.map((item) => `${item}.mpls`),
|
||||||
|
playlistToTitleId: nextPlaylistToTitleId,
|
||||||
|
playlistAliasMap: nextPlaylistAliasMap,
|
||||||
|
recommendation: nextRecommendation,
|
||||||
|
evaluatedCandidates: filteredEvaluatedCandidates,
|
||||||
|
playlistSegments: nextPlaylistSegments,
|
||||||
|
structuralAnalysis: {
|
||||||
|
...(analysis?.structuralAnalysis && typeof analysis.structuralAnalysis === 'object'
|
||||||
|
? analysis.structuralAnalysis
|
||||||
|
: {}),
|
||||||
|
analyzedPlaylists: Object.keys(nextPlaylistSegments).length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
playlistAnalysis: filteredAnalysis,
|
||||||
|
applied: true,
|
||||||
|
runtimeMinutes,
|
||||||
|
expectedSeconds,
|
||||||
|
minLengthSeconds,
|
||||||
|
configuredPercent,
|
||||||
|
configuredDeductionSeconds,
|
||||||
|
deductionSeconds,
|
||||||
|
lowerBoundSeconds,
|
||||||
|
sourceCount: uniqueNormalizedPlaylistIds(
|
||||||
|
(Array.isArray(analysis?.candidates) ? analysis.candidates : [])
|
||||||
|
.map((item) => extractPlaylistAnalysisPlaylistId(item))
|
||||||
|
).length,
|
||||||
|
resultCount: filteredCandidatePlaylistsAll.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePlaylistByRuntimeAutoMatch({ playlistCandidates = [], selectedMetadata = null } = {}) {
|
function resolvePlaylistByRuntimeAutoMatch({ playlistCandidates = [], selectedMetadata = null } = {}) {
|
||||||
const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata);
|
const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata);
|
||||||
if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) {
|
if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) {
|
||||||
@@ -7274,6 +7490,8 @@ function buildPlaylistCandidates(playlistAnalysis) {
|
|||||||
);
|
);
|
||||||
const score = Number(source?.score);
|
const score = Number(source?.score);
|
||||||
const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence);
|
const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence);
|
||||||
|
const correctedCoherence = Number(source?.correctedCoherence ?? source?.structuralMetrics?.correctedCoherence);
|
||||||
|
const legacySequenceCoherence = Number(source?.legacySequenceCoherence ?? source?.structuralMetrics?.legacySequenceCoherence);
|
||||||
const titleId = Number(source?.titleId ?? source?.id);
|
const titleId = Number(source?.titleId ?? source?.id);
|
||||||
const handBrakeTitleId = Number(source?.handBrakeTitleId);
|
const handBrakeTitleId = Number(source?.handBrakeTitleId);
|
||||||
const durationSecondsRaw = Number(source?.durationSeconds ?? source?.duration ?? 0);
|
const durationSecondsRaw = Number(source?.durationSeconds ?? source?.duration ?? 0);
|
||||||
@@ -7329,6 +7547,14 @@ function buildPlaylistCandidates(playlistAnalysis) {
|
|||||||
recommended: Boolean(source?.recommended),
|
recommended: Boolean(source?.recommended),
|
||||||
evaluationLabel: source?.evaluationLabel || null,
|
evaluationLabel: source?.evaluationLabel || null,
|
||||||
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
|
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
|
||||||
|
correctedCoherence: Number.isFinite(correctedCoherence) ? correctedCoherence : null,
|
||||||
|
legacySequenceCoherence: Number.isFinite(legacySequenceCoherence) ? legacySequenceCoherence : null,
|
||||||
|
clusterId: String(source?.clusterId || '').trim() || null,
|
||||||
|
clusterSize: Number(source?.clusterSize || 0) || null,
|
||||||
|
commonRuns: Array.isArray(source?.commonRuns) ? source.commonRuns : [],
|
||||||
|
variantSpans: Array.isArray(source?.variantSpans) ? source.variantSpans : [],
|
||||||
|
overlapEntries: Array.isArray(source?.overlapEntries) ? source.overlapEntries : [],
|
||||||
|
positionReason: String(source?.positionReason || '').trim() || null,
|
||||||
segmentCommand: source?.segmentCommand
|
segmentCommand: source?.segmentCommand
|
||||||
|| segmentEntry?.segmentCommand
|
|| segmentEntry?.segmentCommand
|
||||||
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
|
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
|
||||||
@@ -7344,6 +7570,79 @@ function buildPlaylistCandidates(playlistAnalysis) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPlaylistCandidateRunsForLog(commonRuns) {
|
||||||
|
return (Array.isArray(commonRuns) ? commonRuns : [])
|
||||||
|
.map((run) => {
|
||||||
|
const start = Number(run?.startSegment);
|
||||||
|
const end = Number(run?.endSegment);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${String(Math.trunc(start)).padStart(5, '0')}-${String(Math.trunc(end)).padStart(5, '0')}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlaylistCandidateVariantSpansForLog(variantSpans) {
|
||||||
|
return (Array.isArray(variantSpans) ? variantSpans : [])
|
||||||
|
.map((span) => {
|
||||||
|
const before = Number.isFinite(Number(span?.beforeSegment))
|
||||||
|
? String(Math.trunc(Number(span.beforeSegment))).padStart(5, '0')
|
||||||
|
: 'START';
|
||||||
|
const after = Number.isFinite(Number(span?.afterSegment))
|
||||||
|
? String(Math.trunc(Number(span.afterSegment))).padStart(5, '0')
|
||||||
|
: 'END';
|
||||||
|
const segments = (Array.isArray(span?.segments) ? span.segments : [])
|
||||||
|
.map((segment) => Number(segment))
|
||||||
|
.filter((segment) => Number.isFinite(segment))
|
||||||
|
.map((segment) => String(Math.trunc(segment)).padStart(5, '0'));
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${before}->${after}: [${segments.join(', ')}]`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlaylistCandidateOverlapForLog(overlapEntries) {
|
||||||
|
return (Array.isArray(overlapEntries) ? overlapEntries : [])
|
||||||
|
.map((entry) => {
|
||||||
|
const otherPlaylistId = normalizePlaylistId(entry?.otherPlaylistId);
|
||||||
|
const ratio = Number(entry?.ratioToSmaller || 0);
|
||||||
|
if (!otherPlaylistId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${otherPlaylistId}.mpls=${ratio.toFixed(3)}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistCandidateDiagnosticLine(candidate) {
|
||||||
|
const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`;
|
||||||
|
const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-';
|
||||||
|
const scoreLabel = Number(Number(candidate?.score)).toFixed(2);
|
||||||
|
const legacyCoherence = Number(candidate?.legacySequenceCoherence || 0).toFixed(3);
|
||||||
|
const correctedCoherence = Number(candidate?.correctedCoherence || candidate?.sequenceCoherence || 0).toFixed(3);
|
||||||
|
const clusterLabel = candidate?.clusterId
|
||||||
|
? `${candidate.clusterId}${candidate?.clusterSize ? `(${candidate.clusterSize})` : ''}`
|
||||||
|
: 'standalone';
|
||||||
|
const overlapLabel = formatPlaylistCandidateOverlapForLog(candidate?.overlapEntries) || '-';
|
||||||
|
const commonRunsLabel = formatPlaylistCandidateRunsForLog(candidate?.commonRuns) || '-';
|
||||||
|
const variantSpansLabel = formatPlaylistCandidateVariantSpansForLog(candidate?.variantSpans) || '-';
|
||||||
|
const handBrakeTitleId = Number(candidate?.handBrakeTitleId);
|
||||||
|
const titleLabel = Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
|
||||||
|
? `HB #${Math.trunc(handBrakeTitleId)}`
|
||||||
|
: `MakeMKV #${candidate?.titleId ?? '-'}`;
|
||||||
|
const recommendedLabel = candidate?.recommended ? ' | empfohlen' : '';
|
||||||
|
const reasonLabel = String(candidate?.positionReason || candidate?.evaluationLabel || '').trim() || '-';
|
||||||
|
return `${playlistFile} | ${titleLabel} | Dauer ${durationLabel} | Score ${scoreLabel} | altKoh ${legacyCoherence} | neuKoh ${correctedCoherence}`
|
||||||
|
+ ` | Cluster ${clusterLabel} | Overlap ${overlapLabel} | CommonRuns ${commonRunsLabel} | VariantSpans ${variantSpansLabel}`
|
||||||
|
+ `${recommendedLabel} | Grund: ${reasonLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildHandBrakeAudioTrackPreview(titleInfo) {
|
function buildHandBrakeAudioTrackPreview(titleInfo) {
|
||||||
const tracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : [];
|
const tracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : [];
|
||||||
return tracks
|
return tracks
|
||||||
@@ -9702,6 +10001,7 @@ function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds,
|
|||||||
function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) {
|
function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) {
|
||||||
const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0;
|
const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0;
|
||||||
const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0;
|
const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0;
|
||||||
|
const previousReviewConfirmed = Boolean(previousPlan?.reviewConfirmed);
|
||||||
if (!hasReviewTitles || !hasPreviousTitles) {
|
if (!hasReviewTitles || !hasPreviousTitles) {
|
||||||
return {
|
return {
|
||||||
plan: reviewPlan,
|
plan: reviewPlan,
|
||||||
@@ -9731,7 +10031,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n
|
|||||||
const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
|
const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
|
||||||
const nextSelectedTitle = findSelectedTitleInPlan(nextPlan);
|
const nextSelectedTitle = findSelectedTitleInPlan(nextPlan);
|
||||||
let trackSelectionApplied = false;
|
let trackSelectionApplied = false;
|
||||||
if (previousSelectedTitle && nextSelectedTitle) {
|
if (previousReviewConfirmed && previousSelectedTitle && nextSelectedTitle) {
|
||||||
const previousSelectedAudioTracks = (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : [])
|
const previousSelectedAudioTracks = (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : [])
|
||||||
.filter((track) => Boolean(track?.selectedForEncode));
|
.filter((track) => Boolean(track?.selectedForEncode));
|
||||||
const previousSelectedSubtitleTracks = (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : [])
|
const previousSelectedSubtitleTracks = (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : [])
|
||||||
@@ -18571,9 +18871,14 @@ class PipelineService extends EventEmitter {
|
|||||||
if (playlistAnalysis && handBrakePlaylistScan) {
|
if (playlistAnalysis && handBrakePlaylistScan) {
|
||||||
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
||||||
}
|
}
|
||||||
|
let playlistRuntimeFilter = {
|
||||||
|
applied: false,
|
||||||
|
reason: 'not_evaluated'
|
||||||
|
};
|
||||||
const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze)
|
const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze)
|
||||||
? (options?.selectedPlaylist || null)
|
? (options?.selectedPlaylist || null)
|
||||||
: (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null);
|
: (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null);
|
||||||
|
const selectedPlaylistExplicit = Boolean(normalizePlaylistId(options?.selectedPlaylist || null));
|
||||||
let selectedPlaylistId = normalizePlaylistId(
|
let selectedPlaylistId = normalizePlaylistId(
|
||||||
selectedPlaylistSource
|
selectedPlaylistSource
|
||||||
);
|
);
|
||||||
@@ -18584,6 +18889,14 @@ class PipelineService extends EventEmitter {
|
|||||||
const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {});
|
const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {});
|
||||||
const disableAnalyzeMinLengthFilter = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext);
|
const disableAnalyzeMinLengthFilter = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext);
|
||||||
const isSeriesBackupReview = disableAnalyzeMinLengthFilter;
|
const isSeriesBackupReview = disableAnalyzeMinLengthFilter;
|
||||||
|
if (playlistAnalysis && !isSeriesBackupReview) {
|
||||||
|
playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, {
|
||||||
|
selectedMetadata,
|
||||||
|
settings,
|
||||||
|
disableMinLengthFilter: disableAnalyzeMinLengthFilter
|
||||||
|
});
|
||||||
|
playlistAnalysis = playlistRuntimeFilter.playlistAnalysis;
|
||||||
|
}
|
||||||
const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId);
|
const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId);
|
||||||
const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(
|
const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(
|
||||||
options?.selectedHandBrakeTitleIds
|
options?.selectedHandBrakeTitleIds
|
||||||
@@ -18694,11 +19007,30 @@ class PipelineService extends EventEmitter {
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
playlistAnalysis = analyzed || null;
|
playlistAnalysis = analyzed || null;
|
||||||
|
if (playlistAnalysis && !isSeriesBackupReview) {
|
||||||
|
playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, {
|
||||||
|
selectedMetadata,
|
||||||
|
settings,
|
||||||
|
disableMinLengthFilter: disableAnalyzeMinLengthFilter
|
||||||
|
});
|
||||||
|
playlistAnalysis = playlistRuntimeFilter.playlistAnalysis;
|
||||||
|
}
|
||||||
analyzedFromFreshRun = true;
|
analyzedFromFreshRun = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired);
|
const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired);
|
||||||
let playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
let playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
||||||
|
if (selectedPlaylistId && playlistCandidates.length > 0) {
|
||||||
|
const isKnownPlaylistAfterRuntimeFilter = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId);
|
||||||
|
if (!isKnownPlaylistAfterRuntimeFilter && !selectedPlaylistExplicit) {
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Vorherige Playlist-Auswahl ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId} passt nicht mehr zur aktuellen TMDb-Runtime-Filterung und wurde verworfen.`
|
||||||
|
);
|
||||||
|
selectedPlaylistId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
let selectedTitleFromPlaylist = selectedPlaylistId
|
let selectedTitleFromPlaylist = selectedPlaylistId
|
||||||
? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId)
|
? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId)
|
||||||
: null;
|
: null;
|
||||||
@@ -18765,6 +19097,14 @@ class PipelineService extends EventEmitter {
|
|||||||
if (preparedCache) {
|
if (preparedCache) {
|
||||||
handBrakePlaylistScan = preparedCache;
|
handBrakePlaylistScan = preparedCache;
|
||||||
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
||||||
|
if (playlistAnalysis && !isSeriesBackupReview) {
|
||||||
|
playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, {
|
||||||
|
selectedMetadata,
|
||||||
|
settings,
|
||||||
|
disableMinLengthFilter: disableAnalyzeMinLengthFilter
|
||||||
|
});
|
||||||
|
playlistAnalysis = playlistRuntimeFilter.playlistAnalysis;
|
||||||
|
}
|
||||||
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
@@ -18803,6 +19143,14 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
||||||
|
if (playlistAnalysis && !isSeriesBackupReview) {
|
||||||
|
playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, {
|
||||||
|
selectedMetadata,
|
||||||
|
settings,
|
||||||
|
disableMinLengthFilter: disableAnalyzeMinLengthFilter
|
||||||
|
});
|
||||||
|
playlistAnalysis = playlistRuntimeFilter.playlistAnalysis;
|
||||||
|
}
|
||||||
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
@@ -18814,6 +19162,22 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (playlistRuntimeFilter.applied) {
|
||||||
|
const runtimeLabel = `${playlistRuntimeFilter.runtimeMinutes} min`;
|
||||||
|
const lowerBoundLabel = formatDurationClock(playlistRuntimeFilter.lowerBoundSeconds)
|
||||||
|
|| `${playlistRuntimeFilter.lowerBoundSeconds}s`;
|
||||||
|
const deductionLabel = playlistRuntimeFilter.configuredPercent > 0
|
||||||
|
? `${Number(playlistRuntimeFilter.configuredPercent).toFixed(2).replace(/\.00$/, '')}%`
|
||||||
|
: '2 min Standardtoleranz';
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`TMDb-Runtime-Playlistfilter: ${runtimeLabel}, Untergrenze ${lowerBoundLabel} `
|
||||||
|
+ `(Abzug ${deductionLabel}, min. ${formatDurationClock(PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS)}). `
|
||||||
|
+ `${playlistRuntimeFilter.sourceCount} -> ${playlistRuntimeFilter.resultCount} Playlist-Kandidaten.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let selectedPlaylistByRuntimeAutoMatch = false;
|
let selectedPlaylistByRuntimeAutoMatch = false;
|
||||||
const shouldTryRuntimeAutoMatch = Boolean(
|
const shouldTryRuntimeAutoMatch = Boolean(
|
||||||
playlistDecisionRequired
|
playlistDecisionRequired
|
||||||
@@ -19110,7 +19474,7 @@ class PipelineService extends EventEmitter {
|
|||||||
size: Number(primaryTitleInfo?.sizeBytes || 0)
|
size: Number(primaryTitleInfo?.sizeBytes || 0)
|
||||||
}],
|
}],
|
||||||
mediaInfoByPath: syntheticMediaInfoByPath,
|
mediaInfoByPath: syntheticMediaInfoByPath,
|
||||||
settings,
|
settings: resolveReviewTrackLanguageSettings(settings, mediaProfile, true),
|
||||||
presetProfile,
|
presetProfile,
|
||||||
playlistAnalysis,
|
playlistAnalysis,
|
||||||
preferredEncodeTitleId: null,
|
preferredEncodeTitleId: null,
|
||||||
@@ -19350,17 +19714,7 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.appendLog(jobId, 'SYSTEM', 'Mehrere mögliche Haupttitel erkannt!');
|
await historyService.appendLog(jobId, 'SYSTEM', 'Mehrere mögliche Haupttitel erkannt!');
|
||||||
await historyService.appendLog(jobId, 'SYSTEM', decisionContext.detailText);
|
await historyService.appendLog(jobId, 'SYSTEM', decisionContext.detailText);
|
||||||
for (const candidate of evaluated) {
|
for (const candidate of evaluated) {
|
||||||
const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`;
|
await historyService.appendLog(jobId, 'SYSTEM', buildPlaylistCandidateDiagnosticLine(candidate));
|
||||||
const score = Number(candidate?.score);
|
|
||||||
const scoreLabel = Number.isFinite(score) ? score.toFixed(0) : '-';
|
|
||||||
const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-';
|
|
||||||
const recommendedLabel = candidate?.recommended ? ' (empfohlen)' : '';
|
|
||||||
const evaluationLabel = candidate?.evaluationLabel ? ` | ${candidate.evaluationLabel}` : '';
|
|
||||||
await historyService.appendLog(
|
|
||||||
jobId,
|
|
||||||
'SYSTEM',
|
|
||||||
`${playlistFile} -> Dauer ${durationLabel} | Score ${scoreLabel}${recommendedLabel}${evaluationLabel}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
@@ -19744,7 +20098,11 @@ class PipelineService extends EventEmitter {
|
|||||||
size: Number(candidate?.titleInfo?.sizeBytes || 0)
|
size: Number(candidate?.titleInfo?.sizeBytes || 0)
|
||||||
})),
|
})),
|
||||||
mediaInfoByPath: syntheticMediaInfoByPath,
|
mediaInfoByPath: syntheticMediaInfoByPath,
|
||||||
settings,
|
settings: resolveReviewTrackLanguageSettings(
|
||||||
|
settings,
|
||||||
|
mediaProfile,
|
||||||
|
isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext)
|
||||||
|
),
|
||||||
presetProfile,
|
presetProfile,
|
||||||
playlistAnalysis,
|
playlistAnalysis,
|
||||||
preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview,
|
preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview,
|
||||||
@@ -19786,7 +20144,7 @@ class PipelineService extends EventEmitter {
|
|||||||
const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
|
const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
|
||||||
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
||||||
const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
|
const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
|
||||||
const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule);
|
const selectedByRule = Boolean(track?.selectedByRule ?? sourceMeta?.selectedByRule);
|
||||||
const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
|
const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
|
||||||
const subtitleType = String(
|
const subtitleType = String(
|
||||||
sourceMeta?.subtitleType
|
sourceMeta?.subtitleType
|
||||||
@@ -19811,42 +20169,42 @@ class PipelineService extends EventEmitter {
|
|||||||
sourceConfidence = 'low';
|
sourceConfidence = 'low';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn);
|
const subtitlePreviewBurnIn = Boolean(track?.subtitlePreviewBurnIn ?? sourceMeta?.subtitlePreviewBurnIn);
|
||||||
const subtitlePreviewForced = Boolean(
|
const subtitlePreviewForced = Boolean(
|
||||||
sourceMeta?.subtitlePreviewForced
|
track?.subtitlePreviewForced
|
||||||
?? track?.subtitlePreviewForced
|
?? sourceMeta?.subtitlePreviewForced
|
||||||
?? (selectedByRule && subtitleType === 'forced')
|
?? (selectedByRule && subtitleType === 'forced')
|
||||||
);
|
);
|
||||||
const subtitlePreviewForcedOnly = Boolean(
|
const subtitlePreviewForcedOnly = Boolean(
|
||||||
sourceMeta?.subtitlePreviewForcedOnly
|
track?.subtitlePreviewForcedOnly
|
||||||
?? track?.subtitlePreviewForcedOnly
|
?? sourceMeta?.subtitlePreviewForcedOnly
|
||||||
);
|
);
|
||||||
const isForcedOnly = Boolean(
|
const isForcedOnly = Boolean(
|
||||||
sourceMeta?.isForcedOnly
|
track?.isForcedOnly
|
||||||
?? track?.isForcedOnly
|
?? sourceMeta?.isForcedOnly
|
||||||
?? sourceMeta?.forcedOnly
|
?? sourceMeta?.forcedOnly
|
||||||
?? track?.forcedOnly
|
?? track?.forcedOnly
|
||||||
?? subtitlePreviewForcedOnly
|
?? subtitlePreviewForcedOnly
|
||||||
?? subtitleType === 'forced'
|
?? subtitleType === 'forced'
|
||||||
);
|
);
|
||||||
const fullHasForced = !isForcedOnly && Boolean(
|
const fullHasForced = !isForcedOnly && Boolean(
|
||||||
sourceMeta?.fullHasForced
|
track?.fullHasForced
|
||||||
?? track?.fullHasForced
|
?? sourceMeta?.fullHasForced
|
||||||
?? sourceMeta?.subtitleFullHasForced
|
?? sourceMeta?.subtitleFullHasForced
|
||||||
?? track?.subtitleFullHasForced
|
?? track?.subtitleFullHasForced
|
||||||
);
|
);
|
||||||
const subtitlePreviewDefaultTrack = Boolean(
|
const subtitlePreviewDefaultTrack = Boolean(
|
||||||
sourceMeta?.subtitlePreviewDefaultTrack
|
track?.subtitlePreviewDefaultTrack
|
||||||
?? track?.subtitlePreviewDefaultTrack
|
?? sourceMeta?.subtitlePreviewDefaultTrack
|
||||||
?? sourceMeta?.defaultFlag
|
?? sourceMeta?.defaultFlag
|
||||||
?? track?.defaultFlag
|
?? track?.defaultFlag
|
||||||
);
|
);
|
||||||
const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags)
|
const subtitlePreviewFlags = Array.isArray(track?.subtitlePreviewFlags)
|
||||||
? sourceMeta.subtitlePreviewFlags
|
? track.subtitlePreviewFlags
|
||||||
: (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []);
|
: (Array.isArray(sourceMeta?.subtitlePreviewFlags) ? sourceMeta.subtitlePreviewFlags : []);
|
||||||
const subtitlePreviewSummary = String(
|
const subtitlePreviewSummary = String(
|
||||||
sourceMeta?.subtitlePreviewSummary
|
track?.subtitlePreviewSummary
|
||||||
|| track?.subtitlePreviewSummary
|
|| sourceMeta?.subtitlePreviewSummary
|
||||||
|| (selectedByRule
|
|| (selectedByRule
|
||||||
? 'Übernehmen'
|
? 'Übernehmen'
|
||||||
: (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
|
: (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
|
||||||
@@ -20050,6 +20408,9 @@ class PipelineService extends EventEmitter {
|
|||||||
`Playlist-Empfehlung: ${recommendationFile}`
|
`Playlist-Empfehlung: ${recommendationFile}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const candidate of playlistCandidates) {
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', buildPlaylistCandidateDiagnosticLine(candidate));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId);
|
const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId);
|
||||||
@@ -24032,6 +24393,7 @@ class PipelineService extends EventEmitter {
|
|||||||
makemkv_min_length_minutes: 0
|
makemkv_min_length_minutes: 0
|
||||||
}
|
}
|
||||||
: settings);
|
: settings);
|
||||||
|
reviewSettings = resolveReviewTrackLanguageSettings(reviewSettings, mediaProfile, effectiveIsSeriesDvdReview);
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
'SYSTEM',
|
'SYSTEM',
|
||||||
@@ -24181,6 +24543,7 @@ class PipelineService extends EventEmitter {
|
|||||||
...reviewSettings,
|
...reviewSettings,
|
||||||
makemkv_min_length_minutes: 0
|
makemkv_min_length_minutes: 0
|
||||||
};
|
};
|
||||||
|
reviewSettings = resolveReviewTrackLanguageSettings(reviewSettings, mediaProfile, true);
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
'SYSTEM',
|
'SYSTEM',
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ const PROFILED_SETTINGS = {
|
|||||||
bluray: 'handbrake_extra_args_bluray',
|
bluray: 'handbrake_extra_args_bluray',
|
||||||
dvd: 'handbrake_extra_args_dvd'
|
dvd: 'handbrake_extra_args_dvd'
|
||||||
},
|
},
|
||||||
|
handbrake_review_audio_languages: {
|
||||||
|
bluray: 'handbrake_review_audio_languages_bluray',
|
||||||
|
dvd: 'handbrake_review_audio_languages_dvd'
|
||||||
|
},
|
||||||
|
handbrake_review_subtitle_languages: {
|
||||||
|
bluray: 'handbrake_review_subtitle_languages_bluray',
|
||||||
|
dvd: 'handbrake_review_subtitle_languages_dvd'
|
||||||
|
},
|
||||||
output_extension: {
|
output_extension: {
|
||||||
bluray: 'output_extension_bluray',
|
bluray: 'output_extension_bluray',
|
||||||
dvd: 'output_extension_dvd'
|
dvd: 'output_extension_dvd'
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const ISO2_TO_3_LANGUAGE = {
|
|||||||
ro: 'ron',
|
ro: 'ron',
|
||||||
uk: 'ukr',
|
uk: 'ukr',
|
||||||
ja: 'jpn',
|
ja: 'jpn',
|
||||||
|
jp: 'jpn',
|
||||||
ko: 'kor',
|
ko: 'kor',
|
||||||
zh: 'zho',
|
zh: 'zho',
|
||||||
ar: 'ara'
|
ar: 'ara'
|
||||||
@@ -873,6 +874,29 @@ function normalizeBurnBehavior(raw) {
|
|||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasConfiguredLanguageSelection(rawValue) {
|
||||||
|
return parseList(rawValue, normalizeSelectionLanguage)
|
||||||
|
.filter((item) => item !== 'none' && item !== 'any')
|
||||||
|
.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitPresetTrackSelection(profile = {}, trackType = 'audio') {
|
||||||
|
const source = String(profile?.source || '').trim().toLowerCase();
|
||||||
|
if (source !== 'preset-export') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackType === 'audio') {
|
||||||
|
const behavior = String(profile?.audioTrackSelectionBehavior || '').trim().toLowerCase();
|
||||||
|
const languages = Array.isArray(profile?.audioLanguages) ? profile.audioLanguages : [];
|
||||||
|
return behavior === 'all' || behavior === 'language' || behavior === 'none' || languages.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const behavior = String(profile?.subtitleTrackSelectionBehavior || '').trim().toLowerCase();
|
||||||
|
const languages = Array.isArray(profile?.subtitleLanguages) ? profile.subtitleLanguages : [];
|
||||||
|
return behavior === 'all' || behavior === 'language' || behavior === 'first' || languages.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function buildBaseTrackSelectors(settings, presetProfile = null) {
|
function buildBaseTrackSelectors(settings, presetProfile = null) {
|
||||||
const profile = presetProfile && typeof presetProfile === 'object' ? presetProfile : {};
|
const profile = presetProfile && typeof presetProfile === 'object' ? presetProfile : {};
|
||||||
const audioLanguages = Array.isArray(profile.audioLanguages)
|
const audioLanguages = Array.isArray(profile.audioLanguages)
|
||||||
@@ -881,6 +905,14 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
|||||||
const subtitleLanguages = Array.isArray(profile.subtitleLanguages)
|
const subtitleLanguages = Array.isArray(profile.subtitleLanguages)
|
||||||
? profile.subtitleLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean)
|
? profile.subtitleLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
const configuredAudioLanguages = parseList(settings?.handbrake_review_audio_languages, normalizeSelectionLanguage)
|
||||||
|
.filter((item) => item !== 'none' && item !== 'any');
|
||||||
|
const configuredSubtitleLanguages = parseList(settings?.handbrake_review_subtitle_languages, normalizeSelectionLanguage)
|
||||||
|
.filter((item) => item !== 'none' && item !== 'any');
|
||||||
|
const useConfiguredAudioLanguages = configuredAudioLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'audio');
|
||||||
|
const useConfiguredSubtitleLanguages = configuredSubtitleLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'subtitle');
|
||||||
|
const effectiveAudioLanguages = useConfiguredAudioLanguages ? configuredAudioLanguages : audioLanguages;
|
||||||
|
const effectiveSubtitleLanguages = useConfiguredSubtitleLanguages ? configuredSubtitleLanguages : subtitleLanguages;
|
||||||
const audioEncoders = Array.isArray(profile.audioEncoders)
|
const audioEncoders = Array.isArray(profile.audioEncoders)
|
||||||
? profile.audioEncoders.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
? profile.audioEncoders.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
@@ -896,6 +928,18 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
|||||||
|
|
||||||
const baseAudioMode = normalizeTrackSelectionMode(profile.audioTrackSelectionBehavior, 'audio');
|
const baseAudioMode = normalizeTrackSelectionMode(profile.audioTrackSelectionBehavior, 'audio');
|
||||||
const baseSubtitleMode = normalizeTrackSelectionMode(profile.subtitleTrackSelectionBehavior, 'subtitle');
|
const baseSubtitleMode = normalizeTrackSelectionMode(profile.subtitleTrackSelectionBehavior, 'subtitle');
|
||||||
|
const effectiveAudioMode = useConfiguredAudioLanguages && !hasConfiguredLanguageSelection(profile?.audioLanguages)
|
||||||
|
? 'language'
|
||||||
|
: baseAudioMode;
|
||||||
|
const effectiveSubtitleMode = useConfiguredSubtitleLanguages && !hasConfiguredLanguageSelection(profile?.subtitleLanguages)
|
||||||
|
? 'language'
|
||||||
|
: baseSubtitleMode;
|
||||||
|
const audioSelectionSource = useConfiguredAudioLanguages
|
||||||
|
? 'settings'
|
||||||
|
: (profile.source === 'preset-export' ? 'preset' : 'default');
|
||||||
|
const subtitleSelectionSource = useConfiguredSubtitleLanguages
|
||||||
|
? 'settings'
|
||||||
|
: (profile.source === 'preset-export' ? 'preset' : 'default');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
preset: settings?.handbrake_preset || null,
|
preset: settings?.handbrake_preset || null,
|
||||||
@@ -903,11 +947,11 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
|||||||
presetProfileSource: profile.source || 'fallback',
|
presetProfileSource: profile.source || 'fallback',
|
||||||
presetProfileMessage: profile.message || null,
|
presetProfileMessage: profile.message || null,
|
||||||
audio: {
|
audio: {
|
||||||
mode: baseAudioMode,
|
mode: effectiveAudioMode,
|
||||||
languages: audioLanguages.filter((item) => item !== 'none'),
|
languages: effectiveAudioLanguages.filter((item) => item !== 'none'),
|
||||||
explicitIds: [],
|
explicitIds: [],
|
||||||
firstOnly: baseAudioMode === 'first',
|
firstOnly: effectiveAudioMode === 'first',
|
||||||
selectionSource: profile.source === 'preset-export' ? 'preset' : 'default',
|
selectionSource: audioSelectionSource,
|
||||||
encoders: audioEncoders,
|
encoders: audioEncoders,
|
||||||
encoderSource: audioEncoders.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default',
|
encoderSource: audioEncoders.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default',
|
||||||
copyMask: normalizedCopyMask.length > 0 ? normalizedCopyMask : [...DEFAULT_AUDIO_COPY_MASK],
|
copyMask: normalizedCopyMask.length > 0 ? normalizedCopyMask : [...DEFAULT_AUDIO_COPY_MASK],
|
||||||
@@ -916,11 +960,11 @@ function buildBaseTrackSelectors(settings, presetProfile = null) {
|
|||||||
fallbackSource: profile.audioFallback ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default'
|
fallbackSource: profile.audioFallback ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default'
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
mode: baseSubtitleMode,
|
mode: effectiveSubtitleMode,
|
||||||
languages: subtitleLanguages.filter((item) => item !== 'none'),
|
languages: effectiveSubtitleLanguages.filter((item) => item !== 'none'),
|
||||||
explicitIds: [],
|
explicitIds: [],
|
||||||
firstOnly: baseSubtitleMode === 'first',
|
firstOnly: effectiveSubtitleMode === 'first',
|
||||||
selectionSource: profile.source === 'preset-export' ? 'preset' : 'default',
|
selectionSource: subtitleSelectionSource,
|
||||||
// Do not auto-burn subtitle tracks from exported preset metadata.
|
// Do not auto-burn subtitle tracks from exported preset metadata.
|
||||||
// Burn-in should only be activated via explicit CLI args/selection.
|
// Burn-in should only be activated via explicit CLI args/selection.
|
||||||
burnBehavior: 'none',
|
burnBehavior: 'none',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const LARGE_JUMP_THRESHOLD = 20;
|
|||||||
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
|
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
|
||||||
const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2;
|
const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2;
|
||||||
const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024;
|
const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024;
|
||||||
|
const BRANCHING_OVERLAP_THRESHOLD = 0.6;
|
||||||
|
const BRANCHING_AUDIO_SIMILARITY_THRESHOLD = 0.75;
|
||||||
|
|
||||||
function parseDurationSeconds(raw) {
|
function parseDurationSeconds(raw) {
|
||||||
const text = String(raw || '').trim();
|
const text = String(raw || '').trim();
|
||||||
@@ -548,7 +550,330 @@ function buildSimilarityGroups(candidates, durationSimilaritySeconds) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeSegmentMetrics(segmentNumbers) {
|
function clamp01(value) {
|
||||||
|
const numeric = Number(value || 0);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(1, Math.max(0, numeric));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackSignaturePart(value, fallback = 'na') {
|
||||||
|
const text = String(value || '').trim().toLowerCase();
|
||||||
|
return text || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAudioSignatureTokens(title) {
|
||||||
|
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
|
||||||
|
if (audioTracks.length === 0) {
|
||||||
|
const count = Number(title?.audioTrackCount || 0);
|
||||||
|
return count > 0 ? [`count:${count}`] : [];
|
||||||
|
}
|
||||||
|
return uniqueOrdered(audioTracks.map((track) => (
|
||||||
|
`${normalizeTrackSignaturePart(track?.language || track?.languageLabel || 'und', 'und')}`
|
||||||
|
+ `|${normalizeTrackSignaturePart(track?.format)}`
|
||||||
|
+ `|${normalizeTrackSignaturePart(track?.channels)}`
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTokenJaccardSimilarity(leftTokens, rightTokens) {
|
||||||
|
const left = new Set((Array.isArray(leftTokens) ? leftTokens : []).map((value) => String(value || '').trim()).filter(Boolean));
|
||||||
|
const right = new Set((Array.isArray(rightTokens) ? rightTokens : []).map((value) => String(value || '').trim()).filter(Boolean));
|
||||||
|
if (left.size === 0 && right.size === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (left.size === 0 || right.size === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let shared = 0;
|
||||||
|
for (const token of left) {
|
||||||
|
if (right.has(token)) {
|
||||||
|
shared += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unionSize = new Set([...left, ...right]).size;
|
||||||
|
return unionSize > 0 ? Number((shared / unionSize).toFixed(4)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSegmentOverlapInfo(leftSegments, rightSegments) {
|
||||||
|
const leftNumbers = Array.isArray(leftSegments)
|
||||||
|
? leftSegments.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||||
|
: [];
|
||||||
|
const rightNumbers = Array.isArray(rightSegments)
|
||||||
|
? rightSegments.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||||
|
: [];
|
||||||
|
const leftSet = new Set(leftNumbers);
|
||||||
|
const rightSet = new Set(rightNumbers);
|
||||||
|
if (leftSet.size === 0 || rightSet.size === 0) {
|
||||||
|
return {
|
||||||
|
sharedCount: 0,
|
||||||
|
ratioToSmaller: 0,
|
||||||
|
ratioToLarger: 0,
|
||||||
|
jaccard: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let sharedCount = 0;
|
||||||
|
for (const value of leftSet) {
|
||||||
|
if (rightSet.has(value)) {
|
||||||
|
sharedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const smaller = Math.max(1, Math.min(leftSet.size, rightSet.size));
|
||||||
|
const larger = Math.max(1, Math.max(leftSet.size, rightSet.size));
|
||||||
|
const unionSize = new Set([...leftSet, ...rightSet]).size;
|
||||||
|
return {
|
||||||
|
sharedCount,
|
||||||
|
ratioToSmaller: Number((sharedCount / smaller).toFixed(4)),
|
||||||
|
ratioToLarger: Number((sharedCount / larger).toFixed(4)),
|
||||||
|
jaccard: unionSize > 0 ? Number((sharedCount / unionSize).toFixed(4)) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDisjointSet(size) {
|
||||||
|
const parent = Array.from({ length: Math.max(0, Number(size || 0)) }, (_, index) => index);
|
||||||
|
const rank = Array.from({ length: parent.length }, () => 0);
|
||||||
|
const find = (value) => {
|
||||||
|
if (parent[value] !== value) {
|
||||||
|
parent[value] = find(parent[value]);
|
||||||
|
}
|
||||||
|
return parent[value];
|
||||||
|
};
|
||||||
|
const union = (left, right) => {
|
||||||
|
const rootLeft = find(left);
|
||||||
|
const rootRight = find(right);
|
||||||
|
if (rootLeft === rootRight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rank[rootLeft] < rank[rootRight]) {
|
||||||
|
parent[rootLeft] = rootRight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rank[rootLeft] > rank[rootRight]) {
|
||||||
|
parent[rootRight] = rootLeft;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parent[rootRight] = rootLeft;
|
||||||
|
rank[rootLeft] += 1;
|
||||||
|
};
|
||||||
|
return { find, union };
|
||||||
|
}
|
||||||
|
|
||||||
|
function longestCommonSubsequence(leftValues, rightValues) {
|
||||||
|
const left = Array.isArray(leftValues) ? leftValues : [];
|
||||||
|
const right = Array.isArray(rightValues) ? rightValues : [];
|
||||||
|
if (left.length === 0 || right.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const dp = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
|
||||||
|
for (let i = left.length - 1; i >= 0; i -= 1) {
|
||||||
|
for (let j = right.length - 1; j >= 0; j -= 1) {
|
||||||
|
if (left[i] === right[j]) {
|
||||||
|
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const output = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < left.length && j < right.length) {
|
||||||
|
if (left[i] === right[j]) {
|
||||||
|
output.push(left[i]);
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackCoreSequence(clusterTitles) {
|
||||||
|
const titles = Array.isArray(clusterTitles) ? clusterTitles : [];
|
||||||
|
const minimumFrequency = Math.max(2, Math.ceil(titles.length * 0.75));
|
||||||
|
const frequencyMap = new Map();
|
||||||
|
const positionMap = new Map();
|
||||||
|
for (const title of titles) {
|
||||||
|
const numbers = Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [];
|
||||||
|
const seenInTitle = new Set();
|
||||||
|
numbers.forEach((segment, index) => {
|
||||||
|
if (!Number.isFinite(segment)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = Math.trunc(segment);
|
||||||
|
if (!frequencyMap.has(normalized)) {
|
||||||
|
frequencyMap.set(normalized, 0);
|
||||||
|
positionMap.set(normalized, []);
|
||||||
|
}
|
||||||
|
if (!seenInTitle.has(normalized)) {
|
||||||
|
frequencyMap.set(normalized, Number(frequencyMap.get(normalized) || 0) + 1);
|
||||||
|
seenInTitle.add(normalized);
|
||||||
|
}
|
||||||
|
positionMap.get(normalized).push(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(frequencyMap.entries())
|
||||||
|
.filter(([, frequency]) => Number(frequency || 0) >= minimumFrequency)
|
||||||
|
.map(([segment]) => {
|
||||||
|
const positions = positionMap.get(segment) || [];
|
||||||
|
const averagePosition = positions.length > 0
|
||||||
|
? positions.reduce((sum, value) => sum + Number(value || 0), 0) / positions.length
|
||||||
|
: Number.MAX_SAFE_INTEGER;
|
||||||
|
return {
|
||||||
|
segment,
|
||||||
|
averagePosition
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.averagePosition - b.averagePosition || a.segment - b.segment)
|
||||||
|
.map((entry) => entry.segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClusterCoreSequence(clusterTitles) {
|
||||||
|
const titles = Array.isArray(clusterTitles) ? clusterTitles : [];
|
||||||
|
const sequences = titles
|
||||||
|
.map((title) => Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [])
|
||||||
|
.filter((sequence) => sequence.length > 0);
|
||||||
|
if (sequences.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let coreSequence = sequences[0].slice();
|
||||||
|
for (let index = 1; index < sequences.length; index += 1) {
|
||||||
|
coreSequence = longestCommonSubsequence(coreSequence, sequences[index]);
|
||||||
|
if (coreSequence.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (coreSequence.length >= Math.max(2, Math.floor(sequences[0].length / 3))) {
|
||||||
|
return coreSequence;
|
||||||
|
}
|
||||||
|
return buildFallbackCoreSequence(clusterTitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeVariantBoundaryKey(beforeSegment, afterSegment) {
|
||||||
|
const before = Number.isFinite(Number(beforeSegment)) ? String(Math.trunc(Number(beforeSegment))).padStart(5, '0') : 'START';
|
||||||
|
const after = Number.isFinite(Number(afterSegment)) ? String(Math.trunc(Number(afterSegment))).padStart(5, '0') : 'END';
|
||||||
|
return `${before}->${after}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVariantSpanLabel(span) {
|
||||||
|
if (!span || !Array.isArray(span.segments) || span.segments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const before = Number.isFinite(Number(span.beforeSegment))
|
||||||
|
? String(Math.trunc(Number(span.beforeSegment))).padStart(5, '0')
|
||||||
|
: 'START';
|
||||||
|
const after = Number.isFinite(Number(span.afterSegment))
|
||||||
|
? String(Math.trunc(Number(span.afterSegment))).padStart(5, '0')
|
||||||
|
: 'END';
|
||||||
|
const segmentList = span.segments
|
||||||
|
.map((segment) => String(Math.trunc(Number(segment))).padStart(5, '0'))
|
||||||
|
.join(', ');
|
||||||
|
return `${before}->${after}: [${segmentList}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVariantSpansForTitle(segmentNumbers, coreSequence) {
|
||||||
|
const numbers = Array.isArray(segmentNumbers)
|
||||||
|
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||||
|
: [];
|
||||||
|
const core = Array.isArray(coreSequence)
|
||||||
|
? coreSequence.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||||
|
: [];
|
||||||
|
if (numbers.length === 0 || core.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreIndexBySegment = new Map(core.map((segment, index) => [segment, index]));
|
||||||
|
const spans = [];
|
||||||
|
let matchedCoreIndex = -1;
|
||||||
|
let currentSpan = [];
|
||||||
|
|
||||||
|
const flushCurrentSpan = (afterSegment = null) => {
|
||||||
|
if (currentSpan.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spans.push({
|
||||||
|
beforeSegment: matchedCoreIndex >= 0 ? core[matchedCoreIndex] : null,
|
||||||
|
afterSegment,
|
||||||
|
segments: currentSpan.slice(),
|
||||||
|
boundaryKey: makeVariantBoundaryKey(
|
||||||
|
matchedCoreIndex >= 0 ? core[matchedCoreIndex] : null,
|
||||||
|
afterSegment
|
||||||
|
)
|
||||||
|
});
|
||||||
|
currentSpan = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const segment of numbers) {
|
||||||
|
const coreIndex = coreIndexBySegment.has(segment) ? coreIndexBySegment.get(segment) : -1;
|
||||||
|
const expectedCoreIndex = matchedCoreIndex + 1;
|
||||||
|
if (coreIndex === expectedCoreIndex) {
|
||||||
|
flushCurrentSpan(segment);
|
||||||
|
matchedCoreIndex = coreIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (coreIndex > expectedCoreIndex) {
|
||||||
|
flushCurrentSpan(core[expectedCoreIndex] ?? segment);
|
||||||
|
matchedCoreIndex = coreIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
currentSpan.push(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushCurrentSpan(matchedCoreIndex + 1 < core.length ? core[matchedCoreIndex + 1] : null);
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClusterCommonRuns(coreSequence, titleSpanRows) {
|
||||||
|
const core = Array.isArray(coreSequence)
|
||||||
|
? coreSequence.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||||
|
: [];
|
||||||
|
if (core.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const boundaries = new Set();
|
||||||
|
for (const row of titleSpanRows || []) {
|
||||||
|
for (const span of row?.variantSpans || []) {
|
||||||
|
if (!Number.isFinite(Number(span?.beforeSegment)) || !Number.isFinite(Number(span?.afterSegment))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boundaries.add(makeVariantBoundaryKey(span.beforeSegment, span.afterSegment));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const runs = [];
|
||||||
|
let startIndex = 0;
|
||||||
|
for (let index = 0; index < core.length - 1; index += 1) {
|
||||||
|
const boundaryKey = makeVariantBoundaryKey(core[index], core[index + 1]);
|
||||||
|
if (!boundaries.has(boundaryKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const runSegments = core.slice(startIndex, index + 1);
|
||||||
|
if (runSegments.length > 0) {
|
||||||
|
runs.push({
|
||||||
|
startSegment: runSegments[0],
|
||||||
|
endSegment: runSegments[runSegments.length - 1],
|
||||||
|
length: runSegments.length,
|
||||||
|
segments: runSegments
|
||||||
|
});
|
||||||
|
}
|
||||||
|
startIndex = index + 1;
|
||||||
|
}
|
||||||
|
const tailSegments = core.slice(startIndex);
|
||||||
|
if (tailSegments.length > 0) {
|
||||||
|
runs.push({
|
||||||
|
startSegment: tailSegments[0],
|
||||||
|
endSegment: tailSegments[tailSegments.length - 1],
|
||||||
|
length: tailSegments.length,
|
||||||
|
segments: tailSegments
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLegacySegmentMetrics(segmentNumbers) {
|
||||||
const numbers = Array.isArray(segmentNumbers)
|
const numbers = Array.isArray(segmentNumbers)
|
||||||
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||||
: [];
|
: [];
|
||||||
@@ -627,17 +952,272 @@ function computeSegmentMetrics(segmentNumbers) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeStandaloneSegmentMetrics(title) {
|
||||||
|
const legacyMetrics = computeLegacySegmentMetrics(title?.segmentNumbers);
|
||||||
|
const transitions = Math.max(1, legacyMetrics.segmentCount - 1);
|
||||||
|
const directRatio = legacyMetrics.directSequenceSteps / transitions;
|
||||||
|
const nonBackwardRatio = 1 - (legacyMetrics.backwardJumps / transitions);
|
||||||
|
const boundedJumpRatio = 1 - (legacyMetrics.largeJumps / transitions);
|
||||||
|
const antiAlternatingRatio = 1 - legacyMetrics.alternatingRatio;
|
||||||
|
const correctedCoherence = clamp01(
|
||||||
|
(directRatio * 0.18)
|
||||||
|
+ (clamp01(nonBackwardRatio) * 0.34)
|
||||||
|
+ (clamp01(boundedJumpRatio) * 0.34)
|
||||||
|
+ (clamp01(antiAlternatingRatio) * 0.14)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...legacyMetrics,
|
||||||
|
clusterId: null,
|
||||||
|
clusterSize: 1,
|
||||||
|
averageSegmentOverlap: 0,
|
||||||
|
commonRuns: [],
|
||||||
|
commonRunCount: 0,
|
||||||
|
sharedCoreCount: 0,
|
||||||
|
sharedCoreRatio: 0,
|
||||||
|
sharedAdjacencyRatio: 0,
|
||||||
|
variantSpans: [],
|
||||||
|
variantSpanCount: 0,
|
||||||
|
variantSegmentCount: 0,
|
||||||
|
singleSegmentVariantSpans: 0,
|
||||||
|
multiSegmentVariantSpans: 0,
|
||||||
|
variantComplexity: 0,
|
||||||
|
legacySequenceCoherence: legacyMetrics.sequenceCoherence,
|
||||||
|
legacyStructureScore: legacyMetrics.score,
|
||||||
|
correctedCoherence: Number(correctedCoherence.toFixed(4)),
|
||||||
|
sequenceCoherence: Number(correctedCoherence.toFixed(4)),
|
||||||
|
score: Number((correctedCoherence * 35).toFixed(4)),
|
||||||
|
coherenceMode: 'standalone',
|
||||||
|
positionReason: 'Einzelkandidat oder kein Branching-Cluster erkannt.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClusterContexts(titles, options = {}) {
|
||||||
|
const rows = (Array.isArray(titles) ? titles : []).map((title, index) => ({
|
||||||
|
...title,
|
||||||
|
_clusterIndex: index,
|
||||||
|
_audioSignatureTokens: buildAudioSignatureTokens(title)
|
||||||
|
}));
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const durationTolerance = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS))
|
||||||
|
);
|
||||||
|
const dsu = buildDisjointSet(rows.length);
|
||||||
|
for (let leftIndex = 0; leftIndex < rows.length; leftIndex += 1) {
|
||||||
|
for (let rightIndex = leftIndex + 1; rightIndex < rows.length; rightIndex += 1) {
|
||||||
|
const left = rows[leftIndex];
|
||||||
|
const right = rows[rightIndex];
|
||||||
|
const durationClose = Math.abs(Number(left?.durationSeconds || 0) - Number(right?.durationSeconds || 0)) <= durationTolerance;
|
||||||
|
if (!durationClose) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const audioSimilarity = computeTokenJaccardSimilarity(left._audioSignatureTokens, right._audioSignatureTokens);
|
||||||
|
if (audioSimilarity < BRANCHING_AUDIO_SIMILARITY_THRESHOLD) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const overlapInfo = computeSegmentOverlapInfo(left?.segmentNumbers, right?.segmentNumbers);
|
||||||
|
if (overlapInfo.ratioToSmaller < BRANCHING_OVERLAP_THRESHOLD) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dsu.union(leftIndex, rightIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map();
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const root = dsu.find(index);
|
||||||
|
if (!grouped.has(root)) {
|
||||||
|
grouped.set(root, []);
|
||||||
|
}
|
||||||
|
grouped.get(root).push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(grouped.values())
|
||||||
|
.filter((clusterRows) => clusterRows.length > 1)
|
||||||
|
.sort((left, right) => right.length - left.length)
|
||||||
|
.map((clusterRows, clusterIndex) => {
|
||||||
|
const clusterId = `branching-${clusterIndex + 1}`;
|
||||||
|
const pairwiseOverlap = {};
|
||||||
|
for (const row of clusterRows) {
|
||||||
|
const playlistId = normalizePlaylistId(row?.playlistId);
|
||||||
|
if (!playlistId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pairwiseOverlap[playlistId] = {};
|
||||||
|
}
|
||||||
|
for (let i = 0; i < clusterRows.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < clusterRows.length; j += 1) {
|
||||||
|
const left = clusterRows[i];
|
||||||
|
const right = clusterRows[j];
|
||||||
|
const leftPlaylistId = normalizePlaylistId(left?.playlistId);
|
||||||
|
const rightPlaylistId = normalizePlaylistId(right?.playlistId);
|
||||||
|
const overlapInfo = computeSegmentOverlapInfo(left?.segmentNumbers, right?.segmentNumbers);
|
||||||
|
if (leftPlaylistId && rightPlaylistId) {
|
||||||
|
pairwiseOverlap[leftPlaylistId][rightPlaylistId] = overlapInfo;
|
||||||
|
pairwiseOverlap[rightPlaylistId][leftPlaylistId] = overlapInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreSequence = buildClusterCoreSequence(clusterRows);
|
||||||
|
const titleSpanRows = clusterRows.map((row) => {
|
||||||
|
const variantSpans = extractVariantSpansForTitle(row?.segmentNumbers, coreSequence);
|
||||||
|
const variantSegmentCount = variantSpans.reduce((sum, span) => sum + span.segments.length, 0);
|
||||||
|
const singleSegmentVariantSpans = variantSpans.filter((span) => span.segments.length === 1).length;
|
||||||
|
const multiSegmentVariantSpans = variantSpans.filter((span) => span.segments.length > 1).length;
|
||||||
|
const variantComplexity = variantSpans.reduce((sum, span) => sum + (span.segments.length ** 2), 0);
|
||||||
|
const playlistId = normalizePlaylistId(row?.playlistId);
|
||||||
|
const overlapEntries = playlistId && pairwiseOverlap[playlistId]
|
||||||
|
? Object.entries(pairwiseOverlap[playlistId]).map(([otherPlaylistId, info]) => ({
|
||||||
|
otherPlaylistId,
|
||||||
|
ratioToSmaller: Number(info?.ratioToSmaller || 0),
|
||||||
|
sharedCount: Number(info?.sharedCount || 0)
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const averageSegmentOverlap = overlapEntries.length > 0
|
||||||
|
? overlapEntries.reduce((sum, entry) => sum + Number(entry.ratioToSmaller || 0), 0) / overlapEntries.length
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
variantSpans,
|
||||||
|
variantSegmentCount,
|
||||||
|
singleSegmentVariantSpans,
|
||||||
|
multiSegmentVariantSpans,
|
||||||
|
variantComplexity,
|
||||||
|
overlapEntries,
|
||||||
|
averageSegmentOverlap: Number(averageSegmentOverlap.toFixed(4))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonRuns = buildClusterCommonRuns(coreSequence, titleSpanRows);
|
||||||
|
const maxVariantComplexity = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantComplexity || 0)), 0);
|
||||||
|
const maxVariantSegmentCount = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantSegmentCount || 0)), 0);
|
||||||
|
const maxVariantSpanCount = titleSpanRows.reduce((max, row) => Math.max(max, Number(row.variantSpans.length || 0)), 0);
|
||||||
|
const legacyStructureScores = titleSpanRows.map((row) => Number(computeLegacySegmentMetrics(row.row?.segmentNumbers).score || 0));
|
||||||
|
const minLegacyStructureScore = legacyStructureScores.length > 0 ? Math.min(...legacyStructureScores) : 0;
|
||||||
|
const maxLegacyStructureScore = legacyStructureScores.length > 0 ? Math.max(...legacyStructureScores) : 0;
|
||||||
|
|
||||||
|
const titlesWithMetrics = titleSpanRows.map((spanRow, index) => {
|
||||||
|
const legacyMetrics = computeLegacySegmentMetrics(spanRow.row?.segmentNumbers);
|
||||||
|
const sharedCoreCount = coreSequence.length;
|
||||||
|
const segmentCount = Math.max(1, Number(legacyMetrics.segmentCount || 0));
|
||||||
|
const sharedCoreRatio = sharedCoreCount > 0 ? sharedCoreCount / segmentCount : 0;
|
||||||
|
const variantSpanCount = spanRow.variantSpans.length;
|
||||||
|
const variantComplexityNorm = maxVariantComplexity > 0
|
||||||
|
? Number(spanRow.variantComplexity || 0) / maxVariantComplexity
|
||||||
|
: 0;
|
||||||
|
const variantSegmentNorm = maxVariantSegmentCount > 0
|
||||||
|
? Number(spanRow.variantSegmentCount || 0) / maxVariantSegmentCount
|
||||||
|
: 0;
|
||||||
|
const variantSpanNorm = maxVariantSpanCount > 0
|
||||||
|
? Number(variantSpanCount || 0) / maxVariantSpanCount
|
||||||
|
: 0;
|
||||||
|
const singleSegmentVariantRatio = variantSpanCount > 0
|
||||||
|
? spanRow.singleSegmentVariantSpans / variantSpanCount
|
||||||
|
: 1;
|
||||||
|
const multiSegmentPenalty = variantSpanCount > 0
|
||||||
|
? spanRow.multiSegmentVariantSpans / variantSpanCount
|
||||||
|
: 0;
|
||||||
|
const legacyRange = maxLegacyStructureScore - minLegacyStructureScore;
|
||||||
|
const legacyTieHint = legacyRange > 0
|
||||||
|
? clamp01((Number(legacyMetrics.score || 0) - minLegacyStructureScore) / legacyRange)
|
||||||
|
: 0;
|
||||||
|
const correctedCoherence = clamp01(
|
||||||
|
(sharedCoreRatio * 0.38)
|
||||||
|
+ (spanRow.averageSegmentOverlap * 0.12)
|
||||||
|
+ (singleSegmentVariantRatio * 0.22)
|
||||||
|
+ ((1 - variantComplexityNorm) * 0.16)
|
||||||
|
+ ((1 - variantSegmentNorm) * 0.08)
|
||||||
|
+ ((1 - variantSpanNorm) * 0.04)
|
||||||
|
+ (legacyTieHint * 0.02)
|
||||||
|
- (multiSegmentPenalty * 0.08)
|
||||||
|
);
|
||||||
|
|
||||||
|
let positionReason = 'nahe Branching-Variante';
|
||||||
|
if (spanRow.multiSegmentVariantSpans === 0 && spanRow.variantSegmentCount <= Math.max(1, variantSpanCount)) {
|
||||||
|
positionReason = 'strukturell einfachster Basis-/Referenzpfad im Cluster';
|
||||||
|
} else if (spanRow.multiSegmentVariantSpans > 0) {
|
||||||
|
positionReason = 'Variante mit zusätzlichen Mehrsegment-Branches';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...spanRow.row,
|
||||||
|
...legacyMetrics,
|
||||||
|
clusterId,
|
||||||
|
clusterSize: clusterRows.length,
|
||||||
|
averageSegmentOverlap: spanRow.averageSegmentOverlap,
|
||||||
|
overlapEntries: spanRow.overlapEntries,
|
||||||
|
commonRuns,
|
||||||
|
commonRunCount: commonRuns.length,
|
||||||
|
sharedCoreCount,
|
||||||
|
sharedCoreRatio: Number(sharedCoreRatio.toFixed(4)),
|
||||||
|
sharedAdjacencyRatio: coreSequence.length > 1 ? 1 : 0,
|
||||||
|
variantSpans: spanRow.variantSpans,
|
||||||
|
variantSpanCount,
|
||||||
|
variantSegmentCount: spanRow.variantSegmentCount,
|
||||||
|
singleSegmentVariantSpans: spanRow.singleSegmentVariantSpans,
|
||||||
|
multiSegmentVariantSpans: spanRow.multiSegmentVariantSpans,
|
||||||
|
variantComplexity: spanRow.variantComplexity,
|
||||||
|
legacySequenceCoherence: legacyMetrics.sequenceCoherence,
|
||||||
|
legacyStructureScore: legacyMetrics.score,
|
||||||
|
correctedCoherence: Number(correctedCoherence.toFixed(4)),
|
||||||
|
sequenceCoherence: Number(correctedCoherence.toFixed(4)),
|
||||||
|
score: Number((((correctedCoherence * 60) + Math.min(18, (clusterRows.length - 1) * 9))).toFixed(4)),
|
||||||
|
coherenceMode: 'cluster_relative',
|
||||||
|
positionReason,
|
||||||
|
_legacyTieHint: legacyTieHint,
|
||||||
|
_clusterSortIndex: index
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const variantBoundaries = uniqueOrdered(
|
||||||
|
titleSpanRows
|
||||||
|
.flatMap((row) => row.variantSpans.map((span) => formatVariantSpanLabel(span)))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: clusterId,
|
||||||
|
size: clusterRows.length,
|
||||||
|
coreSequence,
|
||||||
|
commonRuns,
|
||||||
|
variantBoundaries,
|
||||||
|
pairwiseOverlap,
|
||||||
|
titles: titlesWithMetrics
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSegmentMetrics(title, clusterMetricsByPlaylist = null) {
|
||||||
|
const playlistId = normalizePlaylistId(title?.playlistId);
|
||||||
|
if (playlistId && clusterMetricsByPlaylist && clusterMetricsByPlaylist[playlistId]) {
|
||||||
|
return clusterMetricsByPlaylist[playlistId];
|
||||||
|
}
|
||||||
|
return computeStandaloneSegmentMetrics(title);
|
||||||
|
}
|
||||||
|
|
||||||
function buildEvaluationLabel(metrics) {
|
function buildEvaluationLabel(metrics) {
|
||||||
if (!metrics || metrics.segmentCount === 0) {
|
if (!metrics || metrics.segmentCount === 0) {
|
||||||
return 'Keine Segmentliste aus TINFO:26 verfügbar';
|
return 'Keine Segmentliste aus TINFO:26 verfügbar';
|
||||||
}
|
}
|
||||||
|
if (String(metrics?.coherenceMode || '') === 'cluster_relative' && Number(metrics?.clusterSize || 0) > 1) {
|
||||||
|
if (metrics.multiSegmentVariantSpans === 0 && metrics.variantSegmentCount <= Math.max(1, Number(metrics.variantSpanCount || 0))) {
|
||||||
|
return 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster';
|
||||||
|
}
|
||||||
|
if (metrics.correctedCoherence >= 0.6) {
|
||||||
|
return 'nahe Branching-Variante mit plausibler Segmentstruktur';
|
||||||
|
}
|
||||||
|
return 'komplexere Branching-Variante - Sichtprüfung empfohlen';
|
||||||
|
}
|
||||||
if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) {
|
if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) {
|
||||||
return 'Fake-Struktur (alternierendes Sprungmuster)';
|
return 'Auffällige Segmentstruktur - Sichtprüfung empfohlen';
|
||||||
}
|
}
|
||||||
if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) {
|
if (metrics.correctedCoherence < 0.45) {
|
||||||
return 'Auffällige Segmentreihenfolge';
|
return 'unklare Segmentstruktur - Sichtprüfung empfohlen';
|
||||||
}
|
}
|
||||||
return 'wahrscheinlich korrekt (lineare Segmentfolge)';
|
return 'wahrscheinlich konsistente Segmentstruktur';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRelativeScore(value, maxValue) {
|
function normalizeRelativeScore(value, maxValue) {
|
||||||
@@ -652,7 +1232,7 @@ function normalizeRelativeScore(value, maxValue) {
|
|||||||
return Math.min(1, Math.max(0, numericValue / numericMax));
|
return Math.min(1, Math.max(0, numericValue / numericMax));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreCandidates(groupTitles) {
|
function scoreCandidates(groupTitles, options = {}) {
|
||||||
const titles = Array.isArray(groupTitles) ? groupTitles : [];
|
const titles = Array.isArray(groupTitles) ? groupTitles : [];
|
||||||
if (titles.length === 0) {
|
if (titles.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -670,20 +1250,70 @@ function scoreCandidates(groupTitles) {
|
|||||||
(max, title) => Math.max(max, Number(title?.chapters || 0)),
|
(max, title) => Math.max(max, Number(title?.chapters || 0)),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
const clusterContexts = buildClusterContexts(titles, options);
|
||||||
|
const clusterMetricsByPlaylist = {};
|
||||||
|
const clusterSummaryRows = [];
|
||||||
|
for (const clusterContext of clusterContexts) {
|
||||||
|
const playlistIds = [];
|
||||||
|
for (const metrics of clusterContext.titles || []) {
|
||||||
|
const playlistId = normalizePlaylistId(metrics?.playlistId);
|
||||||
|
if (playlistId) {
|
||||||
|
clusterMetricsByPlaylist[playlistId] = metrics;
|
||||||
|
playlistIds.push(playlistId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clusterSummaryRows.push({
|
||||||
|
id: clusterContext.id,
|
||||||
|
size: clusterContext.size,
|
||||||
|
playlistIds: uniqueOrdered(playlistIds),
|
||||||
|
commonRuns: clusterContext.commonRuns,
|
||||||
|
variantBoundaries: clusterContext.variantBoundaries
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return titles
|
return titles
|
||||||
.map((title) => {
|
.map((title) => {
|
||||||
const metrics = computeSegmentMetrics(title.segmentNumbers);
|
const metrics = computeSegmentMetrics(title, clusterMetricsByPlaylist);
|
||||||
const structureScore = Number(metrics.score || 0);
|
const structureScore = Number(metrics.score || 0);
|
||||||
const durationScore = normalizeRelativeScore(title.durationSeconds, maxDurationSeconds) * 20;
|
const durationScore = normalizeRelativeScore(title.durationSeconds, maxDurationSeconds) * 20;
|
||||||
const audioScore = normalizeRelativeScore(title.audioTrackCount, maxAudioTrackCount) * 4;
|
const audioScore = normalizeRelativeScore(title.audioTrackCount, maxAudioTrackCount) * 4;
|
||||||
const chapterScore = normalizeRelativeScore(title.chapters, maxChapterCount) * 6;
|
const chapterScore = normalizeRelativeScore(title.chapters, maxChapterCount) * 6;
|
||||||
const totalScore = structureScore + durationScore + audioScore + chapterScore;
|
const totalScore = structureScore + durationScore + audioScore + chapterScore;
|
||||||
|
const overlapSummary = Array.isArray(metrics?.overlapEntries)
|
||||||
|
? metrics.overlapEntries
|
||||||
|
.map((entry) => `${entry.otherPlaylistId}:${Number(entry.ratioToSmaller || 0).toFixed(3)}`)
|
||||||
|
.join(', ')
|
||||||
|
: '';
|
||||||
|
const commonRunsSummary = Array.isArray(metrics?.commonRuns)
|
||||||
|
? metrics.commonRuns
|
||||||
|
.map((run) => `${String(run.startSegment).padStart(5, '0')}-${String(run.endSegment).padStart(5, '0')}`)
|
||||||
|
.join(' | ')
|
||||||
|
: '';
|
||||||
|
const variantSummary = Array.isArray(metrics?.variantSpans)
|
||||||
|
? metrics.variantSpans
|
||||||
|
.map((span) => formatVariantSpanLabel(span))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ')
|
||||||
|
: '';
|
||||||
const reasons = [
|
const reasons = [
|
||||||
`structure_score=${structureScore.toFixed(2)}`,
|
`structure_score=${structureScore.toFixed(2)}`,
|
||||||
`duration_score=${durationScore.toFixed(2)}`,
|
`duration_score=${durationScore.toFixed(2)}`,
|
||||||
`audio_score=${audioScore.toFixed(2)}`,
|
`audio_score=${audioScore.toFixed(2)}`,
|
||||||
`chapter_score=${chapterScore.toFixed(2)}`,
|
`chapter_score=${chapterScore.toFixed(2)}`,
|
||||||
|
`legacy_structure_score=${Number(metrics.legacyStructureScore || 0).toFixed(2)}`,
|
||||||
|
`legacy_sequence_coherence=${Number(metrics.legacySequenceCoherence || 0).toFixed(3)}`,
|
||||||
|
`corrected_coherence=${Number(metrics.correctedCoherence || 0).toFixed(3)}`,
|
||||||
|
`cluster=${metrics.clusterId || 'standalone'}${metrics.clusterSize > 1 ? `(${metrics.clusterSize})` : ''}`,
|
||||||
|
`avg_segment_overlap=${Number(metrics.averageSegmentOverlap || 0).toFixed(3)}`,
|
||||||
|
`core_coverage=${Number(metrics.sharedCoreRatio || 0).toFixed(3)}`,
|
||||||
|
`variant_spans=${Number(metrics.variantSpanCount || 0)}`,
|
||||||
|
`variant_segments=${Number(metrics.variantSegmentCount || 0)}`,
|
||||||
|
`single_segment_spans=${Number(metrics.singleSegmentVariantSpans || 0)}`,
|
||||||
|
`multi_segment_spans=${Number(metrics.multiSegmentVariantSpans || 0)}`,
|
||||||
|
`common_runs=${commonRunsSummary || '-'}`,
|
||||||
|
`variant_boundaries=${variantSummary || '-'}`,
|
||||||
|
`segment_overlap_map=${overlapSummary || '-'}`,
|
||||||
|
`position_reason=${metrics.positionReason || '-'}`,
|
||||||
`sequence_steps=${metrics.directSequenceSteps}`,
|
`sequence_steps=${metrics.directSequenceSteps}`,
|
||||||
`sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`,
|
`sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`,
|
||||||
`backward_jumps=${metrics.backwardJumps}`,
|
`backward_jumps=${metrics.backwardJumps}`,
|
||||||
@@ -696,19 +1326,31 @@ function scoreCandidates(groupTitles) {
|
|||||||
score: Number(totalScore.toFixed(4)),
|
score: Number(totalScore.toFixed(4)),
|
||||||
reasons,
|
reasons,
|
||||||
structuralMetrics: metrics,
|
structuralMetrics: metrics,
|
||||||
evaluationLabel: buildEvaluationLabel(metrics)
|
evaluationLabel: buildEvaluationLabel(metrics),
|
||||||
|
correctedCoherence: Number(metrics.correctedCoherence || 0),
|
||||||
|
legacySequenceCoherence: Number(metrics.legacySequenceCoherence || 0),
|
||||||
|
legacyStructureScore: Number(metrics.legacyStructureScore || 0),
|
||||||
|
clusterId: metrics.clusterId || null,
|
||||||
|
clusterSize: Number(metrics.clusterSize || 1),
|
||||||
|
averageSegmentOverlap: Number(metrics.averageSegmentOverlap || 0),
|
||||||
|
commonRuns: Array.isArray(metrics.commonRuns) ? metrics.commonRuns : [],
|
||||||
|
variantSpans: Array.isArray(metrics.variantSpans) ? metrics.variantSpans : [],
|
||||||
|
overlapEntries: Array.isArray(metrics.overlapEntries) ? metrics.overlapEntries : [],
|
||||||
|
positionReason: metrics.positionReason || null
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
b.score - a.score
|
b.score - a.score
|
||||||
|| b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence
|
|| b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence
|
||||||
|
|| b.legacyStructureScore - a.legacyStructureScore
|
||||||
|| b.durationSeconds - a.durationSeconds
|
|| b.durationSeconds - a.durationSeconds
|
||||||
|| b.sizeBytes - a.sizeBytes
|
|| b.sizeBytes - a.sizeBytes
|
||||||
|| a.titleId - b.titleId
|
|| a.titleId - b.titleId
|
||||||
)
|
)
|
||||||
.map((item, index) => ({
|
.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
recommended: index === 0
|
recommended: index === 0,
|
||||||
|
clusterSummary: clusterSummaryRows.find((row) => row.id === item.clusterId) || null
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,11 +1451,24 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
|||||||
const multipleCandidatesDetected = candidatePlaylistsAll.length > 1;
|
const multipleCandidatesDetected = candidatePlaylistsAll.length > 1;
|
||||||
const manualDecisionRequired = multipleCandidatesDetected;
|
const manualDecisionRequired = multipleCandidatesDetected;
|
||||||
const decisionPool = manualDecisionRequired ? playlistBackedCandidates : [];
|
const decisionPool = manualDecisionRequired ? playlistBackedCandidates : [];
|
||||||
const evaluatedCandidates = decisionPool.length > 0 ? scoreCandidates(decisionPool) : [];
|
const evaluatedCandidates = decisionPool.length > 0
|
||||||
|
? scoreCandidates(decisionPool, { durationSimilaritySeconds })
|
||||||
|
: [];
|
||||||
const recommendation = evaluatedCandidates[0] || null;
|
const recommendation = evaluatedCandidates[0] || null;
|
||||||
const candidatePlaylists = manualDecisionRequired ? candidatePlaylistsAll : [];
|
const rankedCandidatePlaylists = uniqueOrdered(
|
||||||
|
evaluatedCandidates.map((item) => normalizePlaylistId(item?.playlistId)).filter(Boolean)
|
||||||
|
);
|
||||||
|
const candidatePlaylists = manualDecisionRequired
|
||||||
|
? uniqueOrdered([...rankedCandidatePlaylists, ...candidatePlaylistsAll])
|
||||||
|
: [];
|
||||||
const playlistSegments = buildPlaylistSegmentMap(decisionPool);
|
const playlistSegments = buildPlaylistSegmentMap(decisionPool);
|
||||||
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitlesWithAliases);
|
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitlesWithAliases);
|
||||||
|
const branchingClusters = uniqueOrdered(
|
||||||
|
evaluatedCandidates
|
||||||
|
.map((item) => item?.clusterSummary)
|
||||||
|
.filter((value) => value && typeof value === 'object')
|
||||||
|
.map((value) => JSON.stringify(value))
|
||||||
|
).map((value) => JSON.parse(value));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
@@ -845,6 +1500,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {})
|
|||||||
: null,
|
: null,
|
||||||
evaluatedCandidates,
|
evaluatedCandidates,
|
||||||
playlistSegments,
|
playlistSegments,
|
||||||
|
branchingClusters,
|
||||||
structuralAnalysis: {
|
structuralAnalysis: {
|
||||||
method: 'makemkv_tinfo_26',
|
method: 'makemkv_tinfo_26',
|
||||||
sourceCommand: 'makemkvcon -r info disc:0 --robot',
|
sourceCommand: 'makemkvcon -r info disc:0 --robot',
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { analyzePlaylistObfuscation } = require('../src/utils/playlistAnalysis');
|
||||||
|
|
||||||
|
function buildTitleLines({
|
||||||
|
titleId,
|
||||||
|
playlistId,
|
||||||
|
duration = '01:34:41',
|
||||||
|
chapters = 20,
|
||||||
|
audioTrackCount = 3,
|
||||||
|
subtitleTrackCount = 2,
|
||||||
|
segmentNumbers = []
|
||||||
|
}) {
|
||||||
|
const lines = [
|
||||||
|
`MSG:3016,0,0,"${playlistId}.mpls","${titleId}"`,
|
||||||
|
`TINFO:${titleId},16,0,"${playlistId}.mpls"`,
|
||||||
|
`TINFO:${titleId},9,0,"${duration}"`,
|
||||||
|
`TINFO:${titleId},8,0,"${chapters}"`,
|
||||||
|
`TINFO:${titleId},26,0,"${segmentNumbers.join(', ')}"`
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let index = 0; index < audioTrackCount; index += 1) {
|
||||||
|
lines.push(`SINFO:${titleId},${index},1,0,"Audio"`);
|
||||||
|
lines.push(`SINFO:${titleId},${index},3,0,"eng"`);
|
||||||
|
lines.push(`SINFO:${titleId},${index},4,0,"English"`);
|
||||||
|
lines.push(`SINFO:${titleId},${index},6,0,"DTS-HD MA"`);
|
||||||
|
lines.push(`SINFO:${titleId},${index},40,0,"5.1 ch"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < subtitleTrackCount; index += 1) {
|
||||||
|
const streamIndex = audioTrackCount + index;
|
||||||
|
lines.push(`SINFO:${titleId},${streamIndex},1,0,"Subtitle"`);
|
||||||
|
lines.push(`SINFO:${titleId},${streamIndex},3,0,"eng"`);
|
||||||
|
lines.push(`SINFO:${titleId},${streamIndex},4,0,"English"`);
|
||||||
|
lines.push(`SINFO:${titleId},${streamIndex},6,0,"PGS"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('branching cluster prefers the structurally simplest reference playlist', () => {
|
||||||
|
const lines = [
|
||||||
|
...buildTitleLines({
|
||||||
|
titleId: 0,
|
||||||
|
playlistId: '00802',
|
||||||
|
segmentNumbers: [
|
||||||
|
866, 868, 829, 848, 830, 849, 831, 850, 832, 851,
|
||||||
|
833, 852, 834, 853, 835, 854, 869, 870, 872, 855,
|
||||||
|
837, 856, 838, 857, 839, 858, 840, 859, 841, 860,
|
||||||
|
842, 861, 843, 862, 844, 863, 845, 864, 846
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...buildTitleLines({
|
||||||
|
titleId: 1,
|
||||||
|
playlistId: '00800',
|
||||||
|
segmentNumbers: [
|
||||||
|
847, 829, 848, 830, 849, 831, 850, 832, 851, 833,
|
||||||
|
852, 834, 853, 835, 854, 836, 855, 837, 856, 838,
|
||||||
|
857, 839, 858, 840, 859, 841, 860, 842, 861, 843,
|
||||||
|
862, 844, 863, 845, 864, 846
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...buildTitleLines({
|
||||||
|
titleId: 3,
|
||||||
|
playlistId: '00804',
|
||||||
|
segmentNumbers: [
|
||||||
|
867, 868, 829, 848, 830, 849, 831, 850, 832, 851,
|
||||||
|
833, 852, 834, 853, 835, 854, 869, 871, 872, 855,
|
||||||
|
837, 856, 838, 857, 839, 858, 840, 859, 841, 860,
|
||||||
|
842, 861, 843, 862, 844, 863, 845, 864, 846
|
||||||
|
]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const analysis = analyzePlaylistObfuscation(lines, 60, {
|
||||||
|
durationSimilaritySeconds: 90
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(analysis?.recommendation?.playlistId, '00800');
|
||||||
|
assert.deepEqual(
|
||||||
|
(analysis?.evaluatedCandidates || []).map((item) => item.playlistId),
|
||||||
|
['00800', '00804', '00802']
|
||||||
|
);
|
||||||
|
assert.deepEqual(analysis?.candidatePlaylists || [], ['00800', '00804', '00802']);
|
||||||
|
|
||||||
|
const preferred = (analysis?.evaluatedCandidates || [])[0];
|
||||||
|
assert.equal(preferred?.recommended, true);
|
||||||
|
assert.equal(preferred?.evaluationLabel, 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster');
|
||||||
|
|
||||||
|
const variantSummary = (preferred?.variantSpans || []).map((span) => ({
|
||||||
|
before: span.beforeSegment,
|
||||||
|
after: span.afterSegment,
|
||||||
|
segments: span.segments
|
||||||
|
}));
|
||||||
|
assert.deepEqual(variantSummary, [
|
||||||
|
{ before: null, after: 829, segments: [847] },
|
||||||
|
{ before: 854, after: 855, segments: [836] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -444,6 +444,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('makemkv_min_length_minutes', 'Tools', 'Minimale Titellänge (Minuten)', 'number', 1, 'Filtert kurze Titel beim Rip.', '60', '[]', '{"min":1,"max":1000}', 210);
|
VALUES ('makemkv_min_length_minutes', 'Tools', 'Minimale Titellänge (Minuten)', 'number', 1, 'Filtert kurze Titel beim Rip.', '60', '[]', '{"min":1,"max":1000}', 210);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_min_length_minutes', '60');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_min_length_minutes', '60');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('handbrake_command', 'Tools', 'HandBrake Kommando', 'string', 1, 'Pfad oder Befehl für HandBrakeCLI.', 'HandBrakeCLI', '[]', '{"minLength":1}', 215);
|
VALUES ('handbrake_command', 'Tools', 'HandBrake Kommando', 'string', 1, 'Pfad oder Befehl für HandBrakeCLI.', 'HandBrakeCLI', '[]', '{"minLength":1}', 215);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_command', 'HandBrakeCLI');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_command', 'HandBrakeCLI');
|
||||||
@@ -502,10 +506,50 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('handbrake_extra_args_bluray', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (Blu-ray).', NULL, '[]', '{}', 325);
|
VALUES ('handbrake_extra_args_bluray', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (Blu-ray).', NULL, '[]', '{}', 325);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_bluray', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_bluray', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_review_audio_languages_bluray_movie', 'Tools', 'Review Audio-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 326);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_movie', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_review_subtitle_languages_bluray_movie', 'Tools', 'Review UT-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 327);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_movie', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_review_audio_languages_bluray_series', 'Tools', 'Review Audio-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 328);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_series', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_review_subtitle_languages_bluray_series', 'Tools', 'Review UT-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 329);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_series', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (Blu-ray).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 330);
|
VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (Blu-ray).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 330);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv');
|
||||||
|
|
||||||
|
UPDATE settings_values
|
||||||
|
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_bluray')
|
||||||
|
WHERE key IN ('handbrake_review_audio_languages_bluray_movie', 'handbrake_review_audio_languages_bluray_series')
|
||||||
|
AND (value IS NULL OR TRIM(value) = '')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM settings_values legacy
|
||||||
|
WHERE legacy.key = 'handbrake_review_audio_languages_bluray'
|
||||||
|
AND legacy.value IS NOT NULL
|
||||||
|
AND TRIM(legacy.value) <> ''
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE settings_values
|
||||||
|
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray')
|
||||||
|
WHERE key IN ('handbrake_review_subtitle_languages_bluray_movie', 'handbrake_review_subtitle_languages_bluray_series')
|
||||||
|
AND (value IS NULL OR TRIM(value) = '')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM settings_values legacy
|
||||||
|
WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray'
|
||||||
|
AND legacy.value IS NOT NULL
|
||||||
|
AND TRIM(legacy.value) <> ''
|
||||||
|
);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335);
|
VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})');
|
||||||
@@ -545,10 +589,50 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('handbrake_extra_args_dvd', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (DVD).', NULL, '[]', '{}', 525);
|
VALUES ('handbrake_extra_args_dvd', 'Tools', 'HandBrake Extra Args', 'string', 0, 'Zusätzliche CLI-Argumente (DVD).', NULL, '[]', '{}', 525);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_dvd', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_dvd', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_review_audio_languages_dvd_movie', 'Tools', 'Review Audio-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 526);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_movie', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_review_subtitle_languages_dvd_movie', 'Tools', 'Review UT-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 527);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_movie', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_review_audio_languages_dvd_series', 'Tools', 'Review Audio-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 528);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_series', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_review_subtitle_languages_dvd_series', 'Tools', 'Review UT-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 529);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_series', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (DVD).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 530);
|
VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (DVD).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 530);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv');
|
||||||
|
|
||||||
|
UPDATE settings_values
|
||||||
|
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_dvd')
|
||||||
|
WHERE key IN ('handbrake_review_audio_languages_dvd_movie', 'handbrake_review_audio_languages_dvd_series')
|
||||||
|
AND (value IS NULL OR TRIM(value) = '')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM settings_values legacy
|
||||||
|
WHERE legacy.key = 'handbrake_review_audio_languages_dvd'
|
||||||
|
AND legacy.value IS NOT NULL
|
||||||
|
AND TRIM(legacy.value) <> ''
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE settings_values
|
||||||
|
SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd')
|
||||||
|
WHERE key IN ('handbrake_review_subtitle_languages_dvd_movie', 'handbrake_review_subtitle_languages_dvd_series')
|
||||||
|
AND (value IS NULL OR TRIM(value) = '')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM settings_values legacy
|
||||||
|
WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd'
|
||||||
|
AND legacy.value IS NOT NULL
|
||||||
|
AND TRIM(legacy.value) <> ''
|
||||||
|
);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535);
|
VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})');
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ Diese Seite beschreibt **jede** aktuelle Einstellung aus der Settings-GUI: was t
|
|||||||
| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `HandBrake Preset` | `handbrake_preset_bluray` | `string` | `H.264 MKV 1080p30` | Standard | Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet. | Wenn ein bevorzugtes Standard-Preset fuer Blu-ray nicht jedes Mal manuell gewaehlt werden soll. |
|
| `HandBrake Preset` | `handbrake_preset_bluray` | `string` | `H.264 MKV 1080p30` | Standard | Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet. | Wenn ein bevorzugtes Standard-Preset fuer Blu-ray nicht jedes Mal manuell gewaehlt werden soll. |
|
||||||
| `HandBrake Extra Args` | `handbrake_extra_args_bluray` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-ma...` | Standard | Zusätzliche CLI-Argumente (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
| `HandBrake Extra Args` | `handbrake_extra_args_bluray` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-ma...` | Standard | Zusätzliche CLI-Argumente (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
|
| `Review Audio-Sprachen (Blu-ray Film)` | `handbrake_review_audio_languages_bluray_movie` | `string` | `leer` | Standard | Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Filmen. Beispiel: `de,en,jp`. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben. | Wenn bei Blu-ray-Filmen im Review nicht mehr alle Audio-Spuren vorausgewählt werden sollen, sondern nur bestimmte Sprachen. |
|
||||||
|
| `Review UT-Sprachen (Blu-ray Film)` | `handbrake_review_subtitle_languages_bluray_movie` | `string` | `leer` | Standard | Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Filmen. Beispiel: `de,en,jp`. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben. | Wenn bei Blu-ray-Filmen im Review nicht mehr alle Untertitel vorausgewählt werden sollen, sondern nur bestimmte Sprachen. |
|
||||||
|
| `Review Audio-Sprachen (Blu-ray Serie)` | `handbrake_review_audio_languages_bluray_series` | `string` | `leer` | Standard | Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Serien. Beispiel: `de,en,jp`. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben. | Wenn bei Blu-ray-Serien im Review nicht mehr alle Audio-Spuren vorausgewählt werden sollen, sondern nur bestimmte Sprachen. |
|
||||||
|
| `Review UT-Sprachen (Blu-ray Serie)` | `handbrake_review_subtitle_languages_bluray_series` | `string` | `leer` | Standard | Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Serien. Beispiel: `de,en,jp`. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben. | Wenn bei Blu-ray-Serien im Review nicht mehr alle Untertitel vorausgewählt werden sollen, sondern nur bestimmte Sprachen. |
|
||||||
| `Ausgabeformat` | `output_extension_bluray` | `select` | `mkv` | Standard | Dateiendung für finale Datei (Blu-ray). | Wenn finale Blu-ray-Dateien standardmaessig als MKV oder MP4 ausgegeben werden sollen. |
|
| `Ausgabeformat` | `output_extension_bluray` | `select` | `mkv` | Standard | Dateiendung für finale Datei (Blu-ray). | Wenn finale Blu-ray-Dateien standardmaessig als MKV oder MP4 ausgegeben werden sollen. |
|
||||||
| `Mediainfo Extra Args` | `mediainfo_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
| `Mediainfo Extra Args` | `mediainfo_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `MakeMKV Rip Modus` | `makemkv_rip_mode_dvd` | `select` | `mkv` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner. | Wenn fuer 'MakeMKV Rip Modus' ein anderes Standardverhalten benoetigt wird. |
|
| `MakeMKV Rip Modus` | `makemkv_rip_mode_dvd` | `select` | `mkv` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner. | Wenn fuer 'MakeMKV Rip Modus' ein anderes Standardverhalten benoetigt wird. |
|
||||||
@@ -163,6 +167,10 @@ Diese Seite beschreibt **jede** aktuelle Einstellung aus der Settings-GUI: was t
|
|||||||
| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `HandBrake Preset` | `handbrake_preset_dvd` | `string` | `H.264 MKV 480p30` | Standard | Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet. | Wenn ein bevorzugtes Standard-Preset fuer DVD nicht jedes Mal manuell gewaehlt werden soll. |
|
| `HandBrake Preset` | `handbrake_preset_dvd` | `string` | `H.264 MKV 480p30` | Standard | Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet. | Wenn ein bevorzugtes Standard-Preset fuer DVD nicht jedes Mal manuell gewaehlt werden soll. |
|
||||||
| `HandBrake Extra Args` | `handbrake_extra_args_dvd` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-ma...` | Standard | Zusätzliche CLI-Argumente (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
| `HandBrake Extra Args` | `handbrake_extra_args_dvd` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-ma...` | Standard | Zusätzliche CLI-Argumente (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
|
| `Review Audio-Sprachen (DVD Film)` | `handbrake_review_audio_languages_dvd_movie` | `string` | `leer` | Standard | Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Filmen. Beispiel: `de,en,jp`. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben. | Wenn bei DVD-Filmen im Review nicht mehr alle Audio-Spuren vorausgewählt werden sollen, sondern nur bestimmte Sprachen. |
|
||||||
|
| `Review UT-Sprachen (DVD Film)` | `handbrake_review_subtitle_languages_dvd_movie` | `string` | `leer` | Standard | Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Filmen. Beispiel: `de,en,jp`. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben. | Wenn bei DVD-Filmen im Review nicht mehr alle Untertitel vorausgewählt werden sollen, sondern nur bestimmte Sprachen. |
|
||||||
|
| `Review Audio-Sprachen (DVD Serie)` | `handbrake_review_audio_languages_dvd_series` | `string` | `leer` | Standard | Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Serien. Beispiel: `de,en,jp`. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben. | Wenn bei DVD-Serien im Review nicht mehr alle Audio-Spuren vorausgewählt werden sollen, sondern nur bestimmte Sprachen. |
|
||||||
|
| `Review UT-Sprachen (DVD Serie)` | `handbrake_review_subtitle_languages_dvd_series` | `string` | `leer` | Standard | Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Serien. Beispiel: `de,en,jp`. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben. | Wenn bei DVD-Serien im Review nicht mehr alle Untertitel vorausgewählt werden sollen, sondern nur bestimmte Sprachen. |
|
||||||
| `Ausgabeformat` | `output_extension_dvd` | `select` | `mkv` | Standard | Dateiendung für finale Datei (DVD). | Wenn finale DVD-Dateien standardmaessig als MKV oder MP4 ausgegeben werden sollen. |
|
| `Ausgabeformat` | `output_extension_dvd` | `select` | `mkv` | Standard | Dateiendung für finale Datei (DVD). | Wenn finale DVD-Dateien standardmaessig als MKV oder MP4 ausgegeben werden sollen. |
|
||||||
| `mkvmerge Kommando` | `mkvmerge_command` | `string` | `mkvmerge` | Standard | Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
| `mkvmerge Kommando` | `mkvmerge_command` | `string` | `mkvmerge` | Standard | Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const GENERAL_TOOL_KEYS = new Set([
|
|||||||
'makemkv_command',
|
'makemkv_command',
|
||||||
'makemkv_registration_key',
|
'makemkv_registration_key',
|
||||||
'makemkv_min_length_minutes',
|
'makemkv_min_length_minutes',
|
||||||
|
'playlist_tmdb_runtime_subtract_percent',
|
||||||
'mediainfo_command',
|
'mediainfo_command',
|
||||||
'handbrake_command',
|
'handbrake_command',
|
||||||
'ffmpeg_command',
|
'ffmpeg_command',
|
||||||
@@ -57,7 +58,11 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
|
|||||||
'makemkv_rip_mode',
|
'makemkv_rip_mode',
|
||||||
'makemkv_rip_mode_bluray',
|
'makemkv_rip_mode_bluray',
|
||||||
'makemkv_rip_mode_dvd',
|
'makemkv_rip_mode_dvd',
|
||||||
'makemkv_backup_mode'
|
'makemkv_backup_mode',
|
||||||
|
'handbrake_review_audio_languages_bluray',
|
||||||
|
'handbrake_review_subtitle_languages_bluray',
|
||||||
|
'handbrake_review_audio_languages_dvd',
|
||||||
|
'handbrake_review_subtitle_languages_dvd'
|
||||||
]);
|
]);
|
||||||
const EXPERT_ONLY_SETTING_KEYS = new Set([
|
const EXPERT_ONLY_SETTING_KEYS = new Set([
|
||||||
'pushover_device',
|
'pushover_device',
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-rc5",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user