diff --git a/README.md b/README.md index 3607cc8..5ddc464 100644 --- a/README.md +++ b/README.md @@ -1,257 +1,232 @@ # 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 -- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket) -- Medienprofil-Erkennung (Blu-ray/DVD/CD/Sonstiges) aus Device-/Filesystem-Heuristik -- Metadaten-Suche und Zuordnung über OMDb -- MakeMKV-Analyse und Rip (`mkv` oder `backup`) mit profilspezifischen Settings -- HandBrake-Review und Encoding mit Track-Auswahl, User-Presets, Extra-Args -- **Audio-CD-Ripping** mit cdparanoia + Encoding nach FLAC/MP3/Opus/Vorbis (experimentell) +### Video (Blu-ray / DVD) +- automatische Disc-Erkennung, Rescan einzelner Laufwerke und Live-Status im Ripper +- Metadaten-Suche über TMDB für Filme und Serien +- HandBrake-Review mit Playlist-/Titel-Auswahl, Audio-/Untertitel-Track-Auswahl und Encode-Vorschau +- MakeMKV-Rip mit profilspezifischen Modi und Zusatzargumenten +- HandBrake-Encoding mit User-Presets, offiziellen HandBrake-Presets und Extra-Args +- Serien-Workflows für DVD- und Blu-ray-Discs inkl. Episoden-Zuordnung und Batch-Encoding +- Multipart-Movie-Workflows über mehrere Discs inkl. Merge-Job via `mkvmerge` +- Re-Encode aus RAW, Review-Neustart, Encode-Neustart, Retry und Resume laufender/unterbrochener Jobs -### Audiobook-Verarbeitung -- **AAX/Audible-Dateien** verarbeiten (Activation Bytes, DRM-Handling) -- FFmpeg-basiertes Kapitel-Splitting -- Ausgabeformate: M4B, MP3, FLAC mit Metadaten -- MusicBrainz-Metadaten-Lookup +### Audio-CD +- TOC-Analyse und Rip mit `cdparanoia` +- MusicBrainz-Suche und Übernahme von Album-/Track-Metadaten +- Track-Auswahl und Ausgabe als FLAC, WAV, MP3, Opus oder Ogg Vorbis +- RAW-Wiederverwendung und CD-Review-/Encode-Neustart aus vorhandenen Daten -### Datei-Converter -- **Generische Audio/Video-Dateien** konvertieren (MKV, MP4, FLAC, MP3, u. v. m.) -- Datei-Explorer mit Upload, Umbenennen, Verschieben, Löschen -- Automatischer Verzeichnis-Scan (Polling konfigurierbar) +### Audiobooks +- Upload und Verarbeitung von Audible-/AAX-Dateien +- Activation-Bytes-Cache für DRM-geschützte Titel +- Metadaten- und Kapitelanreicherung über Audnex plus lokale Probe-Daten +- Ausgabe als M4B, MP3 oder FLAC +- Einzeldatei oder kapitelweises Splitten -### Automatisierung & Verwaltung -- Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten) -- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`) -- Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung) -- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Ripper -- Download-Queue: Ausgabedateien als ZIP herunterladen -- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import -- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Ripper +### Converter +- Dateibaum und Datei-Explorer für das Converter-RAW-Verzeichnis +- Upload, Ordner anlegen, Umbenennen, Verschieben und Löschen +- Jobs aus Dateiauswahl erzeugen, Dateien bestehenden Jobs zuweisen oder daraus entfernen +- Audio-/Video-Erkennung und automatische RAW-Scans per Polling +- TMDB-Metadatenzuordnung für passende Video-Jobs -## 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** -- Frontend: React, Vite, PrimeReact -- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`, `ffmpeg`/`ffprobe`, `cdparanoia` +## Oberfläche -## 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 -- root-Rechte + Internetzugang -- optisches Laufwerk (oder gemountete Quelle) für Disc-Ripping -- Netzwerk-Zugang für Audiobook-Upload und Datei-Converter optional +Unterstützte Systeme laut Installer: +- Debian +- Ubuntu +- Linux Mint +- Pop!_OS -## Schnellstart (`install.sh`) - -Auf Debian 11/12 oder Ubuntu 22.04/24.04 (root erforderlich): +Standardinstallation: ```bash -wget -qO install.sh https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh -sudo bash install.sh +curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/setup.sh | bash -s -- --branch dev ``` -Alternativ direkt per Pipe: +Alternativ per Datei: ```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) -- Basistools inkl. `mediainfo`, `ffmpeg`, `ffprobe` -- CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`) -- MakeMKV -- HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding) -- nginx -- Repository-Checkout, npm-Install, Frontend-Build, systemd-Service (`ripster-backend`) - -Danach ist Ripster unter `http://` erreichbar. - -Wichtige Optionen: +Wichtige Standardparameter für den Installationslauf: ```bash -sudo bash install.sh --branch dev # Branch wählen (Default im Skript: dev) -sudo bash install.sh --dir /opt/ripster # Installationspfad -sudo bash install.sh --user ripster # Service-User -sudo bash install.sh --port 3001 # Backend-Port -sudo bash install.sh --host 192.168.1.10 # Host/IP für nginx/CORS -sudo bash install.sh --no-makemkv # MakeMKV überspringen -sudo bash install.sh --no-handbrake # HandBrake überspringen -sudo bash install.sh --no-nginx # nginx-Setup überspringen -sudo bash install.sh --reinstall # Update (Daten bleiben erhalten) -sudo bash install.sh --help # Hilfe anzeigen +bash setup.sh --branch dev --dir /opt/ripster --user ripster --port 3001 --host 192.168.1.10 ``` +Verfügbare Optionen: +- `--branch ` +- `--dir ` +- `--user ` +- `--port ` +- `--host ` +- `--no-makemkv` +- `--no-handbrake` +- `--no-nginx` +- `--no-system-deps` +- `--reinstall` +- `--help` + +Was der Installer typischerweise einrichtet: +- Node.js 20 +- `ffmpeg`, `ffprobe`, `mediainfo`, `mkvtoolnix` +- CD-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`) +- optional MakeMKV +- optional HandBrakeCLI +- optional nginx +- Repository-Checkout bzw. Update +- npm-Abhängigkeiten, Frontend-Build und `ripster-backend`-systemd-Service + +## Update + +Standard-Update einer bestehenden Installation: + +```bash +sudo bash /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 -### 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) -- Pfade Converter: `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)` -- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`, `FFmpeg Kommando`, `FFprobe Kommando` -- Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat) -- Audiobook: Output-Template, RAW-Template -- Converter: erlaubte Endungen, Auto-Scan-Intervall, Output-Templates -- Queue/Monitoring: `Parallele Jobs`, `Hardware Monitoring aktiviert`, `Hardware Monitoring Intervall (ms)` -- Benachrichtigungen: PushOver +Backend: +- `PORT` +- `DB_PATH` +- `LOG_DIR` +- `LOG_LEVEL` +- `CORS_ORIGIN` +- `DEFAULT_TEMP_DIR` +- `DEFAULT_RAW_DIR` +- `DEFAULT_MOVIE_DIR` +- `DEFAULT_SERIES_DIR` +- `DEFAULT_CD_DIR` +- `DEFAULT_AUDIOBOOK_RAW_DIR` +- `DEFAULT_AUDIOBOOK_DIR` +- `DEFAULT_DOWNLOAD_DIR` +- `DEFAULT_CONVERTER_RAW_DIR` +- `DEFAULT_CONVERTER_MOVIE_DIR` +- `DEFAULT_CONVERTER_AUDIO_DIR` -### Umgebungsvariablen +Frontend: +- `VITE_API_BASE` +- `VITE_WS_URL` -Backend (`backend/src/config.js`): +## Daten, Logs und API -- `PORT` (Default: `3001`) -- `DB_PATH` (Default: `backend/data/ripster.db`) -- `LOG_DIR` (Default: `backend/logs`) -- `CORS_ORIGIN` (Default: `*`) -- `LOG_LEVEL` (`debug|info|warn|error`, Default: `info`) +- Standard-DB: `backend/data/ripster.db` +- Standard-Logs: `backend/logs` +- Standard-Outputs liegen relativ zum Datenverzeichnis, sofern in den Settings nichts anderes gesetzt ist. +- Das Schema wird beim Start geprüft und fehlende DB-Elemente werden migriert. -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`) -- `VITE_WS_URL` (optional, überschreibt automatische WS-URL) -- optional für Remote-Dev: `VITE_PUBLIC_ORIGIN`, `VITE_ALLOWED_HOSTS`, `VITE_HMR_PROTOCOL`, `VITE_HMR_HOST`, `VITE_HMR_CLIENT_PORT` - -## Logs und Daten - -Log-Ziel ist primär der in den Settings gepflegte `log_dir`. - -- Backend-Logs: `/backend/backend-latest.log` und Tagesdateien -- Job-Logs: `/job-.process.log` -- DB: `backend/data/ripster.db` - -Hinweis: Beim DB-Init wird das Schema geprüft und fehlende Elemente werden migriert. +Realtime: +- WebSocket unter `/ws` ## Projektstruktur ```text ripster/ backend/ - src/ - plugins/ # Plugin-System (BluRay, DVD, CD, Audiobook, Converter) - routes/ - services/ - db/ - utils/ frontend/ - src/ - pages/ # Ripper, Settings, History, Converter, Downloads, Database - components/ - api/ - db/schema.sql - start.sh + db/ + docs/ install.sh - install-dev.sh + setup.sh + start.sh ``` -## API-Überblick +## Dokumentation -**Health** -- `GET /api/health` +Ausführlichere Dokumentation liegt in `docs/` und veröffentlicht unter: -**Pipeline** -- `GET /api/pipeline/state` -- `POST /api/pipeline/analyze` -- `POST /api/pipeline/rescan-disc` -- `POST /api/pipeline/select-metadata` -- `POST /api/pipeline/start/:jobId` -- `POST /api/pipeline/confirm-encode/:jobId` -- `POST /api/pipeline/cancel` -- `POST /api/pipeline/retry/:jobId` -- `POST /api/pipeline/reencode/:jobId` -- `POST /api/pipeline/restart-review/:jobId` -- `POST /api/pipeline/restart-encode/:jobId` -- `POST /api/pipeline/resume-ready/:jobId` -- `GET /api/pipeline/queue` -- `POST /api/pipeline/queue/reorder` -- `POST /api/pipeline/queue/entry` -- `DELETE /api/pipeline/queue/entry/:entryId` - -**Converter** -- `GET /api/converter/tree` -- `GET /api/converter/browse` -- `POST /api/converter/scan` -- `POST /api/converter/create-jobs` -- `POST /api/converter/upload` -- `POST /api/converter/jobs/from-selection` -- `GET /api/converter/jobs` -- `GET /api/converter/jobs/:jobId` -- `POST /api/converter/jobs/:jobId/start` -- `POST /api/converter/jobs/:jobId/cancel` -- `DELETE /api/converter/jobs/:jobId` -- `DELETE /api/converter/files` -- `POST /api/converter/files/rename` -- `POST /api/converter/files/move` -- `POST /api/converter/files/folder` - -**Downloads** -- `GET /api/downloads` -- `GET /api/downloads/summary` -- `POST /api/downloads/history/:jobId` -- `GET /api/downloads/:id/file` -- `DELETE /api/downloads/:id` - -**History** -- `GET /api/history` -- `GET /api/history/:id` -- `GET /api/history/database` -- `GET /api/history/orphan-raw` -- `POST /api/history/orphan-raw/import` -- `POST /api/history/:id/omdb/assign` -- `POST /api/history/:id/delete-files` -- `POST /api/history/:id/delete` - -**Settings** -- `GET /api/settings` -- `PUT /api/settings/:key` -- `PUT /api/settings` -- `GET/POST/PUT/DELETE /api/settings/scripts...` -- `GET/POST/PUT/DELETE /api/settings/script-chains...` -- `GET/POST/PUT/DELETE /api/settings/user-presets...` -- `POST /api/settings/pushover/test` - -**Cron-Jobs** -- `GET /api/crons` -- `POST /api/crons` -- `GET /api/crons/:id` -- `PUT /api/crons/:id` -- `DELETE /api/crons/:id` -- `GET /api/crons/:id/logs` -- `POST /api/crons/:id/run` -- `POST /api/crons/validate-expression` - -**Runtime-Aktivitäten** -- `GET /api/activities` -- `POST /api/activities/:id/cancel` -- `POST /api/activities/:id/next-step` -- `POST /api/activities/clear-recent` - -## Troubleshooting - -- WebSocket verbindet nicht: - - prüfen, ob Frontend über Vite-Proxy läuft (`/ws` -> Backend) - - bei Reverse-Proxy Upgrade-Header für `/ws` setzen -- Keine Disc erkannt: - - in den Settings `Laufwerksmodus` auf `Explizites Device` stellen und `Device Pfad` setzen (z. B. `/dev/sr0`) -- HandBrake/MakeMKV Fehler: - - CLI-Binaries im `PATH` prüfen - - Preset-Name mit `HandBrakeCLI -z` prüfen -- Startfehler wegen Schema: - - `db/schema.sql` vorhanden halten - -## Sicherheit - -- Keine echten Tokens/Passwörter ins Repository committen. -- Lokale Secrets in `.env` oder in Settings pflegen, aber nicht versionieren. +https://mboehmlaender.github.io/ripster/ diff --git a/backend/package-lock.json b/backend/package-lock.json index b648748..e88b5f3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "1.0.0-rc5", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "1.0.0-rc5", + "version": "1.0.0", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 7130f5f..9a596cb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "1.0.0-rc5", + "version": "1.0.0", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 235cc15..cfab3d2 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -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_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0')`); + await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`); await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`); await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_upper_pct'`); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 8f5bad9..2fdd1f3 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -43,6 +43,7 @@ const REVIEW_REFRESH_SETTING_PREFIXES = [ ]; const REVIEW_REFRESH_SETTING_KEYS = new Set([ 'makemkv_min_length_minutes', + 'playlist_tmdb_runtime_subtract_percent', 'handbrake_preset', 'handbrake_extra_args', 'mediainfo_extra_args', @@ -102,6 +103,7 @@ const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000; const tmdbSeriesSearchCache = new Map(); const PROCESS_LOG_RESET_TERMINAL_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']); const PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS = 120; +const PLAYLIST_RUNTIME_SUBTRACT_PERCENT_SETTING_KEY = 'playlist_tmdb_runtime_subtract_percent'; function nowIso() { return new Date().toISOString(); @@ -228,6 +230,24 @@ function resolveSeriesDiscLabel(mediaProfile) { return 'Disc'; } +function resolveReviewTrackLanguageSettings(settings = {}, mediaProfile = null, isSeriesSelection = false) { + const source = settings && typeof settings === 'object' ? settings : {}; + const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); + if (normalizedMediaProfile !== 'bluray' && normalizedMediaProfile !== 'dvd') { + return source; + } + + const variant = isSeriesSelection ? 'series' : 'movie'; + const audioKey = `handbrake_review_audio_languages_${normalizedMediaProfile}_${variant}`; + const subtitleKey = `handbrake_review_subtitle_languages_${normalizedMediaProfile}_${variant}`; + + return { + ...source, + handbrake_review_audio_languages: source[audioKey] ?? null, + handbrake_review_subtitle_languages: source[subtitleKey] ?? null + }; +} + function normalizeMovieFingerprintLabel(value) { return String(value || '') .normalize('NFKD') @@ -3369,6 +3389,202 @@ function extractMovieRuntimeMinutesFromMetadata(selectedMetadata = 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 } = {}) { const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata); if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) { @@ -7274,6 +7490,8 @@ function buildPlaylistCandidates(playlistAnalysis) { ); const score = Number(source?.score); const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence); + const correctedCoherence = Number(source?.correctedCoherence ?? source?.structuralMetrics?.correctedCoherence); + const legacySequenceCoherence = Number(source?.legacySequenceCoherence ?? source?.structuralMetrics?.legacySequenceCoherence); const titleId = Number(source?.titleId ?? source?.id); const handBrakeTitleId = Number(source?.handBrakeTitleId); const durationSecondsRaw = Number(source?.durationSeconds ?? source?.duration ?? 0); @@ -7329,6 +7547,14 @@ function buildPlaylistCandidates(playlistAnalysis) { recommended: Boolean(source?.recommended), evaluationLabel: source?.evaluationLabel || null, sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null, + correctedCoherence: Number.isFinite(correctedCoherence) ? correctedCoherence : null, + legacySequenceCoherence: Number.isFinite(legacySequenceCoherence) ? legacySequenceCoherence : null, + clusterId: String(source?.clusterId || '').trim() || null, + clusterSize: Number(source?.clusterSize || 0) || null, + commonRuns: Array.isArray(source?.commonRuns) ? source.commonRuns : [], + variantSpans: Array.isArray(source?.variantSpans) ? source.variantSpans : [], + overlapEntries: Array.isArray(source?.overlapEntries) ? source.overlapEntries : [], + positionReason: String(source?.positionReason || '').trim() || null, segmentCommand: source?.segmentCommand || segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, @@ -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) { const tracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; return tracks @@ -9702,6 +10001,7 @@ function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) { const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0; const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0; + const previousReviewConfirmed = Boolean(previousPlan?.reviewConfirmed); if (!hasReviewTitles || !hasPreviousTitles) { return { plan: reviewPlan, @@ -9731,7 +10031,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n const previousSelectedTitle = findSelectedTitleInPlan(previousPlan); const nextSelectedTitle = findSelectedTitleInPlan(nextPlan); let trackSelectionApplied = false; - if (previousSelectedTitle && nextSelectedTitle) { + if (previousReviewConfirmed && previousSelectedTitle && nextSelectedTitle) { const previousSelectedAudioTracks = (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : []) .filter((track) => Boolean(track?.selectedForEncode)); const previousSelectedSubtitleTracks = (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : []) @@ -18571,9 +18871,14 @@ class PipelineService extends EventEmitter { if (playlistAnalysis && handBrakePlaylistScan) { playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); } + let playlistRuntimeFilter = { + applied: false, + reason: 'not_evaluated' + }; const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze) ? (options?.selectedPlaylist || null) : (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null); + const selectedPlaylistExplicit = Boolean(normalizePlaylistId(options?.selectedPlaylist || null)); let selectedPlaylistId = normalizePlaylistId( selectedPlaylistSource ); @@ -18584,6 +18889,14 @@ class PipelineService extends EventEmitter { const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); const disableAnalyzeMinLengthFilter = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); const isSeriesBackupReview = disableAnalyzeMinLengthFilter; + if (playlistAnalysis && !isSeriesBackupReview) { + playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, { + selectedMetadata, + settings, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + playlistAnalysis = playlistRuntimeFilter.playlistAnalysis; + } const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId); const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList( options?.selectedHandBrakeTitleIds @@ -18694,11 +19007,30 @@ class PipelineService extends EventEmitter { {} ); playlistAnalysis = analyzed || null; + if (playlistAnalysis && !isSeriesBackupReview) { + playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, { + selectedMetadata, + settings, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + playlistAnalysis = playlistRuntimeFilter.playlistAnalysis; + } analyzedFromFreshRun = true; } const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired); let playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + if (selectedPlaylistId && playlistCandidates.length > 0) { + const isKnownPlaylistAfterRuntimeFilter = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId); + if (!isKnownPlaylistAfterRuntimeFilter && !selectedPlaylistExplicit) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorherige Playlist-Auswahl ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId} passt nicht mehr zur aktuellen TMDb-Runtime-Filterung und wurde verworfen.` + ); + selectedPlaylistId = null; + } + } let selectedTitleFromPlaylist = selectedPlaylistId ? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId) : null; @@ -18765,6 +19097,14 @@ class PipelineService extends EventEmitter { if (preparedCache) { handBrakePlaylistScan = preparedCache; playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + if (playlistAnalysis && !isSeriesBackupReview) { + playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, { + selectedMetadata, + settings, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + playlistAnalysis = playlistRuntimeFilter.playlistAnalysis; + } playlistCandidates = buildPlaylistCandidates(playlistAnalysis); await historyService.appendLog( jobId, @@ -18803,6 +19143,14 @@ class PipelineService extends EventEmitter { } } else { playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + if (playlistAnalysis && !isSeriesBackupReview) { + playlistRuntimeFilter = applyTmdbRuntimeFilterToPlaylistAnalysis(playlistAnalysis, { + selectedMetadata, + settings, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); + playlistAnalysis = playlistRuntimeFilter.playlistAnalysis; + } playlistCandidates = buildPlaylistCandidates(playlistAnalysis); await historyService.appendLog( jobId, @@ -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; const shouldTryRuntimeAutoMatch = Boolean( playlistDecisionRequired @@ -19110,7 +19474,7 @@ class PipelineService extends EventEmitter { size: Number(primaryTitleInfo?.sizeBytes || 0) }], mediaInfoByPath: syntheticMediaInfoByPath, - settings, + settings: resolveReviewTrackLanguageSettings(settings, mediaProfile, true), presetProfile, playlistAnalysis, 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', decisionContext.detailText); for (const candidate of evaluated) { - const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`; - const score = Number(candidate?.score); - const scoreLabel = Number.isFinite(score) ? score.toFixed(0) : '-'; - const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-'; - const recommendedLabel = candidate?.recommended ? ' (empfohlen)' : ''; - const evaluationLabel = candidate?.evaluationLabel ? ` | ${candidate.evaluationLabel}` : ''; - await historyService.appendLog( - jobId, - 'SYSTEM', - `${playlistFile} -> Dauer ${durationLabel} | Score ${scoreLabel}${recommendedLabel}${evaluationLabel}` - ); + await historyService.appendLog(jobId, 'SYSTEM', buildPlaylistCandidateDiagnosticLine(candidate)); } await historyService.appendLog( jobId, @@ -19744,7 +20098,11 @@ class PipelineService extends EventEmitter { size: Number(candidate?.titleInfo?.sizeBytes || 0) })), mediaInfoByPath: syntheticMediaInfoByPath, - settings, + settings: resolveReviewTrackLanguageSettings( + settings, + mediaProfile, + isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext) + ), presetProfile, playlistAnalysis, preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview, @@ -19786,7 +20144,7 @@ class PipelineService extends EventEmitter { const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null; - const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule); + const selectedByRule = Boolean(track?.selectedByRule ?? sourceMeta?.selectedByRule); const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate); const subtitleType = String( sourceMeta?.subtitleType @@ -19811,42 +20169,42 @@ class PipelineService extends EventEmitter { sourceConfidence = 'low'; } } - const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn); + const subtitlePreviewBurnIn = Boolean(track?.subtitlePreviewBurnIn ?? sourceMeta?.subtitlePreviewBurnIn); const subtitlePreviewForced = Boolean( - sourceMeta?.subtitlePreviewForced - ?? track?.subtitlePreviewForced + track?.subtitlePreviewForced + ?? sourceMeta?.subtitlePreviewForced ?? (selectedByRule && subtitleType === 'forced') ); const subtitlePreviewForcedOnly = Boolean( - sourceMeta?.subtitlePreviewForcedOnly - ?? track?.subtitlePreviewForcedOnly + track?.subtitlePreviewForcedOnly + ?? sourceMeta?.subtitlePreviewForcedOnly ); const isForcedOnly = Boolean( - sourceMeta?.isForcedOnly - ?? track?.isForcedOnly + track?.isForcedOnly + ?? sourceMeta?.isForcedOnly ?? sourceMeta?.forcedOnly ?? track?.forcedOnly ?? subtitlePreviewForcedOnly ?? subtitleType === 'forced' ); const fullHasForced = !isForcedOnly && Boolean( - sourceMeta?.fullHasForced - ?? track?.fullHasForced + track?.fullHasForced + ?? sourceMeta?.fullHasForced ?? sourceMeta?.subtitleFullHasForced ?? track?.subtitleFullHasForced ); const subtitlePreviewDefaultTrack = Boolean( - sourceMeta?.subtitlePreviewDefaultTrack - ?? track?.subtitlePreviewDefaultTrack + track?.subtitlePreviewDefaultTrack + ?? sourceMeta?.subtitlePreviewDefaultTrack ?? sourceMeta?.defaultFlag ?? track?.defaultFlag ); - const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags) - ? sourceMeta.subtitlePreviewFlags - : (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []); + const subtitlePreviewFlags = Array.isArray(track?.subtitlePreviewFlags) + ? track.subtitlePreviewFlags + : (Array.isArray(sourceMeta?.subtitlePreviewFlags) ? sourceMeta.subtitlePreviewFlags : []); const subtitlePreviewSummary = String( - sourceMeta?.subtitlePreviewSummary - || track?.subtitlePreviewSummary + track?.subtitlePreviewSummary + || sourceMeta?.subtitlePreviewSummary || (selectedByRule ? 'Übernehmen' : (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen')) @@ -20050,6 +20408,9 @@ class PipelineService extends EventEmitter { `Playlist-Empfehlung: ${recommendationFile}` ); } + for (const candidate of playlistCandidates) { + await historyService.appendLog(jobId, 'SYSTEM', buildPlaylistCandidateDiagnosticLine(candidate)); + } } const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId); @@ -24032,6 +24393,7 @@ class PipelineService extends EventEmitter { makemkv_min_length_minutes: 0 } : settings); + reviewSettings = resolveReviewTrackLanguageSettings(reviewSettings, mediaProfile, effectiveIsSeriesDvdReview); await historyService.appendLog( jobId, 'SYSTEM', @@ -24181,6 +24543,7 @@ class PipelineService extends EventEmitter { ...reviewSettings, makemkv_min_length_minutes: 0 }; + reviewSettings = resolveReviewTrackLanguageSettings(reviewSettings, mediaProfile, true); await historyService.appendLog( jobId, 'SYSTEM', diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index adf6bf3..474743e 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -150,6 +150,14 @@ const PROFILED_SETTINGS = { bluray: 'handbrake_extra_args_bluray', dvd: 'handbrake_extra_args_dvd' }, + handbrake_review_audio_languages: { + bluray: 'handbrake_review_audio_languages_bluray', + dvd: 'handbrake_review_audio_languages_dvd' + }, + handbrake_review_subtitle_languages: { + bluray: 'handbrake_review_subtitle_languages_bluray', + dvd: 'handbrake_review_subtitle_languages_dvd' + }, output_extension: { bluray: 'output_extension_bluray', dvd: 'output_extension_dvd' diff --git a/backend/src/utils/encodePlan.js b/backend/src/utils/encodePlan.js index b95b0c9..cf38ead 100644 --- a/backend/src/utils/encodePlan.js +++ b/backend/src/utils/encodePlan.js @@ -33,6 +33,7 @@ const ISO2_TO_3_LANGUAGE = { ro: 'ron', uk: 'ukr', ja: 'jpn', + jp: 'jpn', ko: 'kor', zh: 'zho', ar: 'ara' @@ -873,6 +874,29 @@ function normalizeBurnBehavior(raw) { return 'none'; } +function hasConfiguredLanguageSelection(rawValue) { + return parseList(rawValue, normalizeSelectionLanguage) + .filter((item) => item !== 'none' && item !== 'any') + .length > 0; +} + +function hasExplicitPresetTrackSelection(profile = {}, trackType = 'audio') { + const source = String(profile?.source || '').trim().toLowerCase(); + if (source !== 'preset-export') { + return false; + } + + if (trackType === 'audio') { + const behavior = String(profile?.audioTrackSelectionBehavior || '').trim().toLowerCase(); + const languages = Array.isArray(profile?.audioLanguages) ? profile.audioLanguages : []; + return behavior === 'all' || behavior === 'language' || behavior === 'none' || languages.length > 0; + } + + const behavior = String(profile?.subtitleTrackSelectionBehavior || '').trim().toLowerCase(); + const languages = Array.isArray(profile?.subtitleLanguages) ? profile.subtitleLanguages : []; + return behavior === 'all' || behavior === 'language' || behavior === 'first' || languages.length > 0; +} + function buildBaseTrackSelectors(settings, presetProfile = null) { const profile = presetProfile && typeof presetProfile === 'object' ? presetProfile : {}; const audioLanguages = Array.isArray(profile.audioLanguages) @@ -881,6 +905,14 @@ function buildBaseTrackSelectors(settings, presetProfile = null) { const subtitleLanguages = Array.isArray(profile.subtitleLanguages) ? profile.subtitleLanguages.map((item) => normalizeSelectionLanguage(item)).filter(Boolean) : []; + const configuredAudioLanguages = parseList(settings?.handbrake_review_audio_languages, normalizeSelectionLanguage) + .filter((item) => item !== 'none' && item !== 'any'); + const configuredSubtitleLanguages = parseList(settings?.handbrake_review_subtitle_languages, normalizeSelectionLanguage) + .filter((item) => item !== 'none' && item !== 'any'); + const useConfiguredAudioLanguages = configuredAudioLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'audio'); + const useConfiguredSubtitleLanguages = configuredSubtitleLanguages.length > 0 && !hasExplicitPresetTrackSelection(profile, 'subtitle'); + const effectiveAudioLanguages = useConfiguredAudioLanguages ? configuredAudioLanguages : audioLanguages; + const effectiveSubtitleLanguages = useConfiguredSubtitleLanguages ? configuredSubtitleLanguages : subtitleLanguages; const audioEncoders = Array.isArray(profile.audioEncoders) ? profile.audioEncoders.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; @@ -896,6 +928,18 @@ function buildBaseTrackSelectors(settings, presetProfile = null) { const baseAudioMode = normalizeTrackSelectionMode(profile.audioTrackSelectionBehavior, 'audio'); const baseSubtitleMode = normalizeTrackSelectionMode(profile.subtitleTrackSelectionBehavior, 'subtitle'); + const effectiveAudioMode = useConfiguredAudioLanguages && !hasConfiguredLanguageSelection(profile?.audioLanguages) + ? 'language' + : baseAudioMode; + const effectiveSubtitleMode = useConfiguredSubtitleLanguages && !hasConfiguredLanguageSelection(profile?.subtitleLanguages) + ? 'language' + : baseSubtitleMode; + const audioSelectionSource = useConfiguredAudioLanguages + ? 'settings' + : (profile.source === 'preset-export' ? 'preset' : 'default'); + const subtitleSelectionSource = useConfiguredSubtitleLanguages + ? 'settings' + : (profile.source === 'preset-export' ? 'preset' : 'default'); return { preset: settings?.handbrake_preset || null, @@ -903,11 +947,11 @@ function buildBaseTrackSelectors(settings, presetProfile = null) { presetProfileSource: profile.source || 'fallback', presetProfileMessage: profile.message || null, audio: { - mode: baseAudioMode, - languages: audioLanguages.filter((item) => item !== 'none'), + mode: effectiveAudioMode, + languages: effectiveAudioLanguages.filter((item) => item !== 'none'), explicitIds: [], - firstOnly: baseAudioMode === 'first', - selectionSource: profile.source === 'preset-export' ? 'preset' : 'default', + firstOnly: effectiveAudioMode === 'first', + selectionSource: audioSelectionSource, encoders: audioEncoders, encoderSource: audioEncoders.length > 0 ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default', copyMask: normalizedCopyMask.length > 0 ? normalizedCopyMask : [...DEFAULT_AUDIO_COPY_MASK], @@ -916,11 +960,11 @@ function buildBaseTrackSelectors(settings, presetProfile = null) { fallbackSource: profile.audioFallback ? (profile.source === 'preset-export' ? 'preset' : 'default') : 'default' }, subtitle: { - mode: baseSubtitleMode, - languages: subtitleLanguages.filter((item) => item !== 'none'), + mode: effectiveSubtitleMode, + languages: effectiveSubtitleLanguages.filter((item) => item !== 'none'), explicitIds: [], - firstOnly: baseSubtitleMode === 'first', - selectionSource: profile.source === 'preset-export' ? 'preset' : 'default', + firstOnly: effectiveSubtitleMode === 'first', + selectionSource: subtitleSelectionSource, // Do not auto-burn subtitle tracks from exported preset metadata. // Burn-in should only be activated via explicit CLI args/selection. burnBehavior: 'none', diff --git a/backend/src/utils/playlistAnalysis.js b/backend/src/utils/playlistAnalysis.js index 8093355..63c5092 100644 --- a/backend/src/utils/playlistAnalysis.js +++ b/backend/src/utils/playlistAnalysis.js @@ -2,6 +2,8 @@ const LARGE_JUMP_THRESHOLD = 20; const DEFAULT_DURATION_SIMILARITY_SECONDS = 90; const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2; const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024; +const BRANCHING_OVERLAP_THRESHOLD = 0.6; +const BRANCHING_AUDIO_SIMILARITY_THRESHOLD = 0.75; function parseDurationSeconds(raw) { const text = String(raw || '').trim(); @@ -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) ? 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) { if (!metrics || metrics.segmentCount === 0) { return 'Keine Segmentliste aus TINFO:26 verfügbar'; } + if (String(metrics?.coherenceMode || '') === 'cluster_relative' && Number(metrics?.clusterSize || 0) > 1) { + if (metrics.multiSegmentVariantSpans === 0 && metrics.variantSegmentCount <= Math.max(1, Number(metrics.variantSpanCount || 0))) { + return 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster'; + } + if (metrics.correctedCoherence >= 0.6) { + return 'nahe Branching-Variante mit plausibler Segmentstruktur'; + } + return 'komplexere Branching-Variante - Sichtprüfung empfohlen'; + } if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) { - return 'Fake-Struktur (alternierendes Sprungmuster)'; + return 'Auffällige Segmentstruktur - Sichtprüfung empfohlen'; } - if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) { - return 'Auffällige Segmentreihenfolge'; + if (metrics.correctedCoherence < 0.45) { + return 'unklare Segmentstruktur - Sichtprüfung empfohlen'; } - return 'wahrscheinlich korrekt (lineare Segmentfolge)'; + return 'wahrscheinlich konsistente Segmentstruktur'; } function normalizeRelativeScore(value, maxValue) { @@ -652,7 +1232,7 @@ function normalizeRelativeScore(value, maxValue) { return Math.min(1, Math.max(0, numericValue / numericMax)); } -function scoreCandidates(groupTitles) { +function scoreCandidates(groupTitles, options = {}) { const titles = Array.isArray(groupTitles) ? groupTitles : []; if (titles.length === 0) { return []; @@ -670,20 +1250,70 @@ function scoreCandidates(groupTitles) { (max, title) => Math.max(max, Number(title?.chapters || 0)), 0 ); + const clusterContexts = buildClusterContexts(titles, options); + const clusterMetricsByPlaylist = {}; + const clusterSummaryRows = []; + for (const clusterContext of clusterContexts) { + const playlistIds = []; + for (const metrics of clusterContext.titles || []) { + const playlistId = normalizePlaylistId(metrics?.playlistId); + if (playlistId) { + clusterMetricsByPlaylist[playlistId] = metrics; + playlistIds.push(playlistId); + } + } + clusterSummaryRows.push({ + id: clusterContext.id, + size: clusterContext.size, + playlistIds: uniqueOrdered(playlistIds), + commonRuns: clusterContext.commonRuns, + variantBoundaries: clusterContext.variantBoundaries + }); + } return titles .map((title) => { - const metrics = computeSegmentMetrics(title.segmentNumbers); + const metrics = computeSegmentMetrics(title, clusterMetricsByPlaylist); const structureScore = Number(metrics.score || 0); const durationScore = normalizeRelativeScore(title.durationSeconds, maxDurationSeconds) * 20; const audioScore = normalizeRelativeScore(title.audioTrackCount, maxAudioTrackCount) * 4; const chapterScore = normalizeRelativeScore(title.chapters, maxChapterCount) * 6; const totalScore = structureScore + durationScore + audioScore + chapterScore; + const overlapSummary = Array.isArray(metrics?.overlapEntries) + ? metrics.overlapEntries + .map((entry) => `${entry.otherPlaylistId}:${Number(entry.ratioToSmaller || 0).toFixed(3)}`) + .join(', ') + : ''; + const commonRunsSummary = Array.isArray(metrics?.commonRuns) + ? metrics.commonRuns + .map((run) => `${String(run.startSegment).padStart(5, '0')}-${String(run.endSegment).padStart(5, '0')}`) + .join(' | ') + : ''; + const variantSummary = Array.isArray(metrics?.variantSpans) + ? metrics.variantSpans + .map((span) => formatVariantSpanLabel(span)) + .filter(Boolean) + .join(' | ') + : ''; const reasons = [ `structure_score=${structureScore.toFixed(2)}`, `duration_score=${durationScore.toFixed(2)}`, `audio_score=${audioScore.toFixed(2)}`, `chapter_score=${chapterScore.toFixed(2)}`, + `legacy_structure_score=${Number(metrics.legacyStructureScore || 0).toFixed(2)}`, + `legacy_sequence_coherence=${Number(metrics.legacySequenceCoherence || 0).toFixed(3)}`, + `corrected_coherence=${Number(metrics.correctedCoherence || 0).toFixed(3)}`, + `cluster=${metrics.clusterId || 'standalone'}${metrics.clusterSize > 1 ? `(${metrics.clusterSize})` : ''}`, + `avg_segment_overlap=${Number(metrics.averageSegmentOverlap || 0).toFixed(3)}`, + `core_coverage=${Number(metrics.sharedCoreRatio || 0).toFixed(3)}`, + `variant_spans=${Number(metrics.variantSpanCount || 0)}`, + `variant_segments=${Number(metrics.variantSegmentCount || 0)}`, + `single_segment_spans=${Number(metrics.singleSegmentVariantSpans || 0)}`, + `multi_segment_spans=${Number(metrics.multiSegmentVariantSpans || 0)}`, + `common_runs=${commonRunsSummary || '-'}`, + `variant_boundaries=${variantSummary || '-'}`, + `segment_overlap_map=${overlapSummary || '-'}`, + `position_reason=${metrics.positionReason || '-'}`, `sequence_steps=${metrics.directSequenceSteps}`, `sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`, `backward_jumps=${metrics.backwardJumps}`, @@ -696,19 +1326,31 @@ function scoreCandidates(groupTitles) { score: Number(totalScore.toFixed(4)), reasons, 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) => b.score - a.score || b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence + || b.legacyStructureScore - a.legacyStructureScore || b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId ) .map((item, index) => ({ ...item, - recommended: index === 0 + 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 manualDecisionRequired = multipleCandidatesDetected; 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 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 playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitlesWithAliases); + const branchingClusters = uniqueOrdered( + evaluatedCandidates + .map((item) => item?.clusterSummary) + .filter((value) => value && typeof value === 'object') + .map((value) => JSON.stringify(value)) + ).map((value) => JSON.parse(value)); return { generatedAt: new Date().toISOString(), @@ -845,6 +1500,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) : null, evaluatedCandidates, playlistSegments, + branchingClusters, structuralAnalysis: { method: 'makemkv_tinfo_26', sourceCommand: 'makemkvcon -r info disc:0 --robot', diff --git a/backend/tests/playlistAnalysis.branching.test.js b/backend/tests/playlistAnalysis.branching.test.js new file mode 100644 index 0000000..b22b697 --- /dev/null +++ b/backend/tests/playlistAnalysis.branching.test.js @@ -0,0 +1,100 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { analyzePlaylistObfuscation } = require('../src/utils/playlistAnalysis'); + +function buildTitleLines({ + titleId, + playlistId, + duration = '01:34:41', + chapters = 20, + audioTrackCount = 3, + subtitleTrackCount = 2, + segmentNumbers = [] +}) { + const lines = [ + `MSG:3016,0,0,"${playlistId}.mpls","${titleId}"`, + `TINFO:${titleId},16,0,"${playlistId}.mpls"`, + `TINFO:${titleId},9,0,"${duration}"`, + `TINFO:${titleId},8,0,"${chapters}"`, + `TINFO:${titleId},26,0,"${segmentNumbers.join(', ')}"` + ]; + + for (let index = 0; index < audioTrackCount; index += 1) { + lines.push(`SINFO:${titleId},${index},1,0,"Audio"`); + lines.push(`SINFO:${titleId},${index},3,0,"eng"`); + lines.push(`SINFO:${titleId},${index},4,0,"English"`); + lines.push(`SINFO:${titleId},${index},6,0,"DTS-HD MA"`); + lines.push(`SINFO:${titleId},${index},40,0,"5.1 ch"`); + } + + for (let index = 0; index < subtitleTrackCount; index += 1) { + const streamIndex = audioTrackCount + index; + lines.push(`SINFO:${titleId},${streamIndex},1,0,"Subtitle"`); + lines.push(`SINFO:${titleId},${streamIndex},3,0,"eng"`); + lines.push(`SINFO:${titleId},${streamIndex},4,0,"English"`); + lines.push(`SINFO:${titleId},${streamIndex},6,0,"PGS"`); + } + + return lines; +} + +test('branching cluster prefers the structurally simplest reference playlist', () => { + const lines = [ + ...buildTitleLines({ + titleId: 0, + playlistId: '00802', + segmentNumbers: [ + 866, 868, 829, 848, 830, 849, 831, 850, 832, 851, + 833, 852, 834, 853, 835, 854, 869, 870, 872, 855, + 837, 856, 838, 857, 839, 858, 840, 859, 841, 860, + 842, 861, 843, 862, 844, 863, 845, 864, 846 + ] + }), + ...buildTitleLines({ + titleId: 1, + playlistId: '00800', + segmentNumbers: [ + 847, 829, 848, 830, 849, 831, 850, 832, 851, 833, + 852, 834, 853, 835, 854, 836, 855, 837, 856, 838, + 857, 839, 858, 840, 859, 841, 860, 842, 861, 843, + 862, 844, 863, 845, 864, 846 + ] + }), + ...buildTitleLines({ + titleId: 3, + playlistId: '00804', + segmentNumbers: [ + 867, 868, 829, 848, 830, 849, 831, 850, 832, 851, + 833, 852, 834, 853, 835, 854, 869, 871, 872, 855, + 837, 856, 838, 857, 839, 858, 840, 859, 841, 860, + 842, 861, 843, 862, 844, 863, 845, 864, 846 + ] + }) + ]; + + const analysis = analyzePlaylistObfuscation(lines, 60, { + durationSimilaritySeconds: 90 + }); + + assert.equal(analysis?.recommendation?.playlistId, '00800'); + assert.deepEqual( + (analysis?.evaluatedCandidates || []).map((item) => item.playlistId), + ['00800', '00804', '00802'] + ); + assert.deepEqual(analysis?.candidatePlaylists || [], ['00800', '00804', '00802']); + + const preferred = (analysis?.evaluatedCandidates || [])[0]; + assert.equal(preferred?.recommended, true); + assert.equal(preferred?.evaluationLabel, 'strukturell sauberster Basis-/Referenzpfad im Branching-Cluster'); + + const variantSummary = (preferred?.variantSpans || []).map((span) => ({ + before: span.beforeSegment, + after: span.afterSegment, + segments: span.segments + })); + assert.deepEqual(variantSummary, [ + { before: null, after: 829, segments: [847] }, + { before: 854, after: 855, segments: [836] } + ]); +}); diff --git a/db/schema.sql b/db/schema.sql index a1b0265..84fe6f3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_min_length_minutes', '60'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('playlist_tmdb_runtime_subtract_percent', 'Tools', 'TMDb Runtime-Abzug für Playlistfilter (%)', 'number', 1, 'Zieht optional einen Prozentwert von der TMDb-Laufzeit ab, um kürzere alternative Filmfassungen weiterhin als Playlist-Kandidaten zuzulassen. Mindesttoleranz nach unten bleibt immer 2 Minuten.', '0', '[]', '{"min":0,"max":100}', 211); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('playlist_tmdb_runtime_subtract_percent', '0'); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('handbrake_command', 'Tools', 'HandBrake Kommando', 'string', 1, 'Pfad oder Befehl für HandBrakeCLI.', 'HandBrakeCLI', '[]', '{"minLength":1}', 215); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_command', 'HandBrakeCLI'); @@ -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); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_bluray', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_audio_languages_bluray_movie', 'Tools', 'Review Audio-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 326); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_movie', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_subtitle_languages_bluray_movie', 'Tools', 'Review UT-Sprachen (Blu-ray Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 327); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_movie', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_audio_languages_bluray_series', 'Tools', 'Review Audio-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 328); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_bluray_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_subtitle_languages_bluray_series', 'Tools', 'Review UT-Sprachen (Blu-ray Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei Blu-ray-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 329); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_bluray_series', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('output_extension_bluray', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (Blu-ray).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 330); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_bluray', 'mkv'); +UPDATE settings_values +SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_bluray') +WHERE key IN ('handbrake_review_audio_languages_bluray_movie', 'handbrake_review_audio_languages_bluray_series') + AND (value IS NULL OR TRIM(value) = '') + AND EXISTS ( + SELECT 1 + FROM settings_values legacy + WHERE legacy.key = 'handbrake_review_audio_languages_bluray' + AND legacy.value IS NOT NULL + AND TRIM(legacy.value) <> '' + ); + +UPDATE settings_values +SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray') +WHERE key IN ('handbrake_review_subtitle_languages_bluray_movie', 'handbrake_review_subtitle_languages_bluray_series') + AND (value IS NULL OR TRIM(value) = '') + AND EXISTS ( + SELECT 1 + FROM settings_values legacy + WHERE legacy.key = 'handbrake_review_subtitle_languages_bluray' + AND legacy.value IS NOT NULL + AND TRIM(legacy.value) <> '' + ); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})'); @@ -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); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_extra_args_dvd', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_audio_languages_dvd_movie', 'Tools', 'Review Audio-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 526); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_movie', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_subtitle_languages_dvd_movie', 'Tools', 'Review UT-Sprachen (DVD Film)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Filmen. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 527); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_movie', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_audio_languages_dvd_series', 'Tools', 'Review Audio-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Audio-Spuren bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Audio-Auswahl vorgeben.', NULL, '[]', '{}', 528); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_audio_languages_dvd_series', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('handbrake_review_subtitle_languages_dvd_series', 'Tools', 'Review UT-Sprachen (DVD Serie)', 'string', 0, 'Kommagetrennte Sprachliste für die Review-Vorauswahl von Untertiteln bei DVD-Serien. Beispiel: de,en,jp. Greift nur, wenn Preset/Extra Args keine Subtitle-Auswahl vorgeben.', NULL, '[]', '{}', 529); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_review_subtitle_languages_dvd_series', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('output_extension_dvd', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Datei (DVD).', 'mkv', '[{"label":"MKV","value":"mkv"},{"label":"MP4","value":"mp4"}]', '{}', 530); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_dvd', 'mkv'); +UPDATE settings_values +SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_audio_languages_dvd') +WHERE key IN ('handbrake_review_audio_languages_dvd_movie', 'handbrake_review_audio_languages_dvd_series') + AND (value IS NULL OR TRIM(value) = '') + AND EXISTS ( + SELECT 1 + FROM settings_values legacy + WHERE legacy.key = 'handbrake_review_audio_languages_dvd' + AND legacy.value IS NOT NULL + AND TRIM(legacy.value) <> '' + ); + +UPDATE settings_values +SET value = (SELECT legacy.value FROM settings_values legacy WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd') +WHERE key IN ('handbrake_review_subtitle_languages_dvd_movie', 'handbrake_review_subtitle_languages_dvd_series') + AND (value IS NULL OR TRIM(value) = '') + AND EXISTS ( + SELECT 1 + FROM settings_values legacy + WHERE legacy.key = 'handbrake_review_subtitle_languages_dvd' + AND legacy.value IS NOT NULL + AND TRIM(legacy.value) <> '' + ); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})'); diff --git a/docs/configuration/settings-reference.md b/docs/configuration/settings-reference.md index 31e05d3..4cfd9f9 100644 --- a/docs/configuration/settings-reference.md +++ b/docs/configuration/settings-reference.md @@ -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). | | `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). | +| `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. | | `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. | @@ -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). | | `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). | +| `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. | | `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). | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4699aa1..184985c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "1.0.0-rc5", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "1.0.0-rc5", + "version": "1.0.0", "dependencies": { "chart.js": "^4.5.1", "primeicons": "^7.0.0", diff --git a/frontend/package.json b/frontend/package.json index e4f77aa..377a94e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "1.0.0-rc5", + "version": "1.0.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index db78468..1a3f712 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -22,6 +22,7 @@ const GENERAL_TOOL_KEYS = new Set([ 'makemkv_command', 'makemkv_registration_key', 'makemkv_min_length_minutes', + 'playlist_tmdb_runtime_subtract_percent', 'mediainfo_command', 'handbrake_command', 'ffmpeg_command', @@ -57,7 +58,11 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([ 'makemkv_rip_mode', 'makemkv_rip_mode_bluray', '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([ 'pushover_device', diff --git a/package-lock.json b/package-lock.json index d9af404..b76bd25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "1.0.0-rc5", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "1.0.0-rc5", + "version": "1.0.0", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index a941eea..77f61d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "1.0.0-rc5", + "version": "1.0.0", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",