Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84c5ee399 | ||
|
|
fbedb9946d | ||
|
|
eb54c59141 | ||
|
|
0088948b4f | ||
|
|
d8d34e8fbb | ||
|
|
5353b381ba | ||
|
|
9d16e82b5f | ||
|
|
d45760018e | ||
|
|
e7e33e9021 | ||
|
|
fc2d16ca38 | ||
|
|
0fc87aa022 | ||
|
|
6b6e5c3436 | ||
|
|
ae82d8c0fd | ||
|
|
3dacd7cb4f | ||
|
|
c1ee339446 | ||
|
|
783b8e4897 | ||
|
|
551f1d7b2c | ||
|
|
904e4b2ef6 | ||
|
|
8c4456d005 | ||
|
|
12b9720386 | ||
|
|
6f1add480e | ||
|
|
473eea7d4e | ||
|
|
687173430c | ||
|
|
8f671ff160 |
@@ -1,6 +1,345 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## ÜBERGABE an die nächste Session (03.09.2026 abends — 5.6.0 gebaut und lokal installiert, NICHT veröffentlicht)
|
## ÜBERGABE an die nächste Session (13.09.2026 nachts — 5.7.1 VERÖFFENTLICHT im Tag `aktuell`: Titel lesen wartet, solange MakeMKV arbeitet — Der Patriot)
|
||||||
|
|
||||||
|
### Stand
|
||||||
|
|
||||||
|
**5.7.1 ist gebaut, am ausgepackten Paket bewiesen, auf DIESEM PC
|
||||||
|
installiert (13.09.2026 01:06, Installer-Exit 0, SHA-256 vorher geprüft,
|
||||||
|
Rippy lief dabei nicht; `Rippy.exe` 5.7.1 auch über `\\localhost\C$`)
|
||||||
|
und seit 13.09.2026 ~01:15 IM KANAL `aktuell`.**
|
||||||
|
|
||||||
|
**Veröffentlicht (13.09.2026 nachts, Commander: „Update freigegeben"):**
|
||||||
|
Release 1, Assets 46 (`RippySetup-5.7.1.exe`, 131 576 689 Bytes, HTTP 201),
|
||||||
|
47 (Blockmap, 137 575 Bytes, 201), 48 (`latest.yml`, 14 146 Bytes, 201 —
|
||||||
|
das alte Asset 45 vorher gelöscht, 204); Release umbenannt in „Rippy 5.7.1
|
||||||
|
(Update-Kanal aktuell)" mit den 5.7.1-Notizen (PATCH 200). **Anonym
|
||||||
|
extern gegengemessen:** `latest.yml` HTTP 200, 14 146 Bytes, Byte für Byte
|
||||||
|
gleich dem Bau (`version: 5.7.1`), Setup HTTP 200 mit Content-Length
|
||||||
|
131 576 689, Blockmap 200 mit 137 575. 5.7.0 (43/44) und alle älteren
|
||||||
|
Setups liegen weiter im Release, `w0.yml` (36) unangetastet. Der andere
|
||||||
|
PC holt sich 5.7.1 über die Update-Funktion. Weg wie bei 5.7.0: die
|
||||||
|
API-Schritte einzeln als PowerShell-Befehle (Token nie ausgegeben).
|
||||||
|
|
||||||
|
* Setup: `RippySetup-5.7.1.exe`, **131 576 689 Bytes**, SHA-256
|
||||||
|
`7463711833721C2068270DE39B43F0ABE433891EA10030AD2ECB21BF8480F669`
|
||||||
|
(Bau in `dist-setup-571/`, Kopie in `dist-setup/`; Blockmap 137 575 B,
|
||||||
|
`latest.yml` 14 146 B).
|
||||||
|
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:6408,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.7.1`.
|
||||||
|
* **518 Tests** in 52 Dateien (5.7.0: 508), drei Typprüfungen, Bau, Klick-
|
||||||
|
Beweis (34 Schritte) und Ampel (`eb54c59`) grün.
|
||||||
|
* Commits: `eb54c59` (Fix, Tests, Notizen, Version 5.7.1, Klick-Bilder)
|
||||||
|
und dieser SAVEPOINT.
|
||||||
|
|
||||||
|
### Der Fund des Commanders (12.09.2026 spät): „Der Patriot" scheitert in 5.7.0 erneut
|
||||||
|
|
||||||
|
Bild: Dialog mit „✗ MakeMKV hat die Titel nach 15 Minuten nicht gelesen".
|
||||||
|
Daneben das MakeMKV-Programm (v1.18.4, LibreDrive), das dieselbe DVD
|
||||||
|
„The Patriot Extended Cut" fertig liest — sein Protokoll:
|
||||||
|
|
||||||
|
```
|
||||||
|
IFO-Datei für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden.
|
||||||
|
Das kann viel Zeit in Anspruch nehmen, bitte haben Sie Geduld.
|
||||||
|
CELLs 1-53 wurden vom Titelstart entfernt … Titel #1 wurde hinzugefügt (31 cell(s), 2:47:40)
|
||||||
|
… Titel #11 (0:09:46), #16 (0:09:58), #17 (0:02:01) · Die Aufgabe wurde erfolgreich abgeschlossen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ursache:** MakeMKV liest bei beschädigter IFO die ganze VOB-Datei
|
||||||
|
(5,8 GB) durch — so lange, wie das Laufwerk für die Disc braucht, im
|
||||||
|
Programm genauso. Rippys Grenze war eine feste GESAMTgrenze (5.6.1: 5 min,
|
||||||
|
5.7.0: 15 min) — für diese Disc immer zu kurz. Kandidat 2 der Durchsicht,
|
||||||
|
jetzt mit Beleg.
|
||||||
|
|
||||||
|
**Fix (5.7.1):**
|
||||||
|
* `buildInfoArgs` gibt `--progress=-same` mit; `titelInfoLesen` wertet die
|
||||||
|
Ausgabe zeilenweise aus (PRGV Prozent, PRGC Schritt, PRGT Aufgabe, MSG
|
||||||
|
Meldung) und beendet makemkvcon nur, wenn er so lange STILL ist, wie
|
||||||
|
die Einstellung erlaubt (`titelLesenMinuten`, Vorgabe 15) — jede Zeile
|
||||||
|
setzt die Uhr zurück. Kündigt MakeMKV den VOB-Scan an
|
||||||
|
(`istVobScanMeldung`: IFO + VOB im Text, deutsch wie englisch), gilt nur
|
||||||
|
noch die Obergrenze `VOB_SCAN_HOECHSTENS_MS` (4 h).
|
||||||
|
* Der Dialog zeigt währenddessen `titelLaufZwischenstand`: „MakeMKV liest
|
||||||
|
seit 17 min · Öffnen der DVD-Disc 43 % · letzte Meldung: „…" — beim
|
||||||
|
VOB-Scan mit der Erklärung (20–60 min, im Programm genauso). Neue
|
||||||
|
Meldung/Schritt sofort (ab 30 s), Prozente höchstens alle 5 s, sonst
|
||||||
|
jede Minute.
|
||||||
|
* Bricht Rippy ab, stehen die letzten acht Zeilen von makemkvcon im
|
||||||
|
Fehler und im Protokoll („Titel-Lauf G gescheitert: … [makemkvcon info:
|
||||||
|
kein Lebenszeichen seit 900 s — letzte Zeilen: …]").
|
||||||
|
* Einstellungen → Allgemein heißt jetzt „Titel lesen — ohne Lebenszeichen
|
||||||
|
höchstens" (Klick-Beweis angepasst).
|
||||||
|
* Tests `test/lebenszeichen-571.test.ts` mit nachgebautem makemkvcon
|
||||||
|
(Node-Skript über aufruf.ts): redselig über die Stille-Grenze hinaus →
|
||||||
|
Titel kommt; stumm → „kein Lebenszeichen seit 1 s" mit letzten Zeilen;
|
||||||
|
VOB-Scan mit Obergrenze.
|
||||||
|
|
||||||
|
**Nicht gemessen (kein Patriot hier):** ob makemkvcon während des
|
||||||
|
VOB-Scans PRGV-Zeilen schickt. Deshalb die doppelte Absicherung: schickt
|
||||||
|
er Fortschritt, hält die Stille-Uhr ihn am Leben; schickt er nichts, hält
|
||||||
|
ihn die VOB-Ankündigung am Leben (Obergrenze 4 h). Der Commander sieht im
|
||||||
|
Dialog, was passiert — und das Protokoll trägt die letzten Zeilen.
|
||||||
|
|
||||||
|
### Was am Gerät zu prüfen ist
|
||||||
|
|
||||||
|
* **Der Patriot noch einmal:** Der Dialog muss nach ~1 min „MakeMKV liest
|
||||||
|
seit … · … letzte Meldung: „IFO-Datei … beschädigt …"" zeigen und
|
||||||
|
irgendwann die vier Titel (2:47:40, 9:46, 9:58, 2:01). **Wie lange das
|
||||||
|
MakeMKV-Programm braucht, bitte stoppen** — steht die Zeit über 15 min,
|
||||||
|
ist die Ursache bestätigt. Danach der Rip: MakeMKV scannt die VOB beim
|
||||||
|
Rippen noch einmal (gleiche Dauer), die Rip-Phase hat keine Zeitgrenze.
|
||||||
|
* Die Punkte aus der 5.7.0-Übergabe darunter (Spuren einzeln an der
|
||||||
|
Kizuna-Disc, Ton-Codec statt AAC, Schlüssel-Karte, anderer PC).
|
||||||
|
|
||||||
|
## Übergabe vom 12.09.2026 spät (5.7.0 — veröffentlicht im Tag `aktuell`; überholt durch 5.7.1)
|
||||||
|
|
||||||
|
### Wo die nächste Session startet
|
||||||
|
|
||||||
|
* **Arbeitsort:** `F:\Coding Stuff\rippy`, Branch `worktree-windows-electron`
|
||||||
|
(Spitze `9d16e82` plus dieser SAVEPOINT). v5-Code in `rippy-windows/`.
|
||||||
|
* **Die drei Läufe:** `npm test` (508 Tests in 51 Dateien, 13 übersprungen),
|
||||||
|
`npm run smoke`, `npm run klick` (34 Klick-Schritte, Bilder
|
||||||
|
`beweise/klick/*.png` — `02-dialog-vorwahl.png` zeigt die Spur-Haken).
|
||||||
|
Alle drei in dieser Session grün; dazu die Messung
|
||||||
|
`RIPPY_MESSUNG=1 npx vitest run test/messung.spuren.test.ts` am
|
||||||
|
mitgelieferten HandBrake (braucht ffmpeg im PATH).
|
||||||
|
* **Release veröffentlichen — für 5.7.0 ERLEDIGT (12.09. spät, Commander:
|
||||||
|
„Update freigegeben"). Der Weg fürs nächste Mal, wieder NUR nach seinem
|
||||||
|
Wort:** Dateien im Gitea-Tag `aktuell` ERSETZEN (Setup, `.blockmap`,
|
||||||
|
`latest.yml`; `w0.yml` bleibt liegen), ERST Setup und Blockmap
|
||||||
|
hochladen, DANN `latest.yml` löschen (204) und neu hochladen (201),
|
||||||
|
Release PATCHen, anonym über
|
||||||
|
`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell/`
|
||||||
|
gegenmessen — Token `F:\Coding Stuff\Gittea Token\rippy.txt` über das
|
||||||
|
PowerShell-Werkzeug lesen (nie ausgeben), LAN-Adresse
|
||||||
|
`http://192.168.178.153:3000/api/v1/repos/Hitonabi/rippy/releases/1`,
|
||||||
|
Upload per `Invoke-WebRequest -Form @{ attachment = Get-Item … }`. Ein
|
||||||
|
Skript mit allen Schritten blockt der Auto-Modus-Wächter — die Schritte
|
||||||
|
einzeln als Befehle gehen. Dateien in `rippy-windows/dist-setup-570/`
|
||||||
|
(Original) und `rippy-windows/dist-setup/` (Kopie). **Im Kanal liegt
|
||||||
|
5.7.0** (5.6.1 wurde nie hochgeladen).
|
||||||
|
|
||||||
|
### Stand
|
||||||
|
|
||||||
|
**5.7.0 ist gebaut, am ausgepackten Paket bewiesen und auf DIESEM PC
|
||||||
|
installiert (12.09.2026 22:41, Installer-Exit 0, SHA-256 vorher geprüft,
|
||||||
|
Rippy lief dabei nicht; `Rippy.exe` Version 5.7.0 auch über
|
||||||
|
`\\localhost\C$` gesehen) und seit 12.09.2026 ~22:55 IM KANAL `aktuell`.**
|
||||||
|
|
||||||
|
**Veröffentlicht (12.09.2026 spät, Commander: „Update freigegeben"):**
|
||||||
|
Release 1, Assets 43 (`RippySetup-5.7.0.exe`, 131 575 882 Bytes, HTTP 201),
|
||||||
|
44 (Blockmap, 137 533 Bytes, 201), 45 (`latest.yml`, 13 433 Bytes, 201 —
|
||||||
|
das alte Asset 42 vorher gelöscht, 204); Release umbenannt in „Rippy 5.7.0
|
||||||
|
(Update-Kanal aktuell)" mit den 5.7.0-Notizen (PATCH 200, 4 403 Zeichen).
|
||||||
|
**Anonym extern gegengemessen:** `latest.yml` HTTP 200, 13 433 Bytes, Byte
|
||||||
|
für Byte gleich dem Bau (`version: 5.7.0`), Setup HTTP 200 mit
|
||||||
|
Content-Length 131 575 882, Blockmap 200 mit 137 533. Die alten Setups
|
||||||
|
5.1.0–5.6.0 liegen weiter im Release, `w0.yml` (36) unangetastet. Der
|
||||||
|
zweite PC holt sich 5.7.0 über die Update-Funktion (5.6.0 → 5.7.0). Der
|
||||||
|
Auto-Modus-Wächter blockte den Start des Upload-Skripts; die API-Schritte
|
||||||
|
liefen einzeln als PowerShell-Befehle (Token nie ausgegeben).
|
||||||
|
|
||||||
|
* Setup: `RippySetup-5.7.0.exe`, **131 575 882 Bytes**, SHA-256
|
||||||
|
`006B761EC2552637A4D1294C066B62B594A3B8FF08BB658115FFCD429D4AC185`
|
||||||
|
(Blockmap 137 533 Bytes, `latest.yml` 13 433 Bytes mit den 5.7.0-Notizen).
|
||||||
|
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:37052,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.7.0`.
|
||||||
|
* **508 Tests** in 51 Dateien (5.6.1: 450), drei Typprüfungen, Bau und
|
||||||
|
Klick-Beweis (34 Schritte) grün.
|
||||||
|
* **Ampel GRÜN** für `9d16e82` (Lauf 442) und `5353b38` (SAVEPOINT) —
|
||||||
|
nachdem der Commander den hängenden Lauf 279 abgebrochen hatte (22:38).
|
||||||
|
* **Update vorbereitet:** idempotentes Skript
|
||||||
|
`<Scratchpad>\veroeffentlichen-570.ps1` (Probe ohne `-Los`, Upload mit
|
||||||
|
`-Los`: ERST Setup + Blockmap, DANN latest.yml löschen/hochladen, Release
|
||||||
|
PATCHen, anonym gegenmessen; kein Remove-Item; Token nie ausgegeben —
|
||||||
|
der Auto-Modus-Wächter blockt den Start des Skripts, die Schritte
|
||||||
|
einzeln als PowerShell-Befehle sind der Weg). Kanal-Stand gemessen 22:45:
|
||||||
|
Release 1 „Rippy 5.6.0 (Update-Kanal aktuell)", 24 Assets, zuletzt 40
|
||||||
|
(`RippySetup-5.6.0.exe`), 41 (Blockmap), 42 (`latest.yml`, 8 273 B);
|
||||||
|
extern `latest.yml` HTTP 200 mit `version: 5.6.0`. Hochzuladen:
|
||||||
|
`dist-setup-570\RippySetup-5.7.0.exe` (131 575 882 B), `.blockmap`
|
||||||
|
(137 533 B), `latest.yml` (13 433 B, sha512 `VL9GcKxP…XdeXDg==`).
|
||||||
|
* Der frühere Hänger: Lauf 279 (`d457600`) blieb in `test/pipeline.test.ts` HÄNGEN —
|
||||||
|
der neue F2-Test nahm unter Linux `/proc/rippy-nicht-da` als tote
|
||||||
|
Ablage; auf procfs antwortet mkdir mit ENOENT, obwohl `/proc` da ist,
|
||||||
|
und Nodes `mkdirSync({recursive:true})` pendelt endlos (synchron, kein
|
||||||
|
Test-Timeout greift). Fix `9d16e82` (Ordner unter einer Datei → ENOTDIR,
|
||||||
|
sofort) ist gepusht; sein Lauf wartet, bis der hängende Lauf endet
|
||||||
|
(Gitea-Zeitlimit 3 h, ab 22:14) oder jemand ihn in Gitea abbricht
|
||||||
|
(Actions → #279 → Abbrechen; per API gibt es kein Cancel in 1.27).
|
||||||
|
Seit 22:41 erledigt (siehe oben).
|
||||||
|
* Commits dieser Session (alle auf `worktree-windows-electron`):
|
||||||
|
`6f1add4` Bericht der Durchsicht · `12b9720` F1/F5/F12/F13/A2 Schlüssel ·
|
||||||
|
`8c4456d` F2/F4/F6/F8/F10 kein stilles Scheitern · `904e4b2` F3/F9
|
||||||
|
Serien und Zwischenname · `551f1d7` F7 Fortschritt · `783b8e4` F11
|
||||||
|
Laufwerks-Gesundheit · `c1ee339` Kleinkram F15–F30/A5 · `3dacd7c` F14
|
||||||
|
Erkennung · `ae82d8c` F16 Installer-Rückfrage · `6b6e5c3` Titel lesen
|
||||||
|
15 min · `0fc87aa` 4K nach Leistung · `fc2d16c` Disc-Typ aus dem
|
||||||
|
Dateisystem · `e7e33e9` Spuren einzeln + F31 · `d457600` Version 5.7.0 ·
|
||||||
|
`9d16e82` F2-Test ohne /proc.
|
||||||
|
|
||||||
|
### Was 5.7.0 enthält — die Durchsicht vom 12.09.2026, abgearbeitet
|
||||||
|
|
||||||
|
Der Bericht (`beweise/berichte/2026-09-12-durchsicht.md`, 31 Funde, 8
|
||||||
|
Auffälligkeiten, 5 Kandidaten für „Der Patriot / Déjà Vu / Training Day"
|
||||||
|
auf dem anderen PC) ist komplett umgesetzt — nach den Entscheiden des
|
||||||
|
Commanders vom selben Abend:
|
||||||
|
|
||||||
|
1. **F1 Beta-Key mit `@`** (wahrscheinlichste Ursache der drei Filme):
|
||||||
|
`KEY_MUSTER = /T-[^\s<>"'&;]{50,}/`, „end of September 2026" wird
|
||||||
|
gelesen, ein angenommener Schlüssel bleibt stehen, Ablehnung ROT,
|
||||||
|
„nicht prüfbar" (grau) ohne Laufwerk. **Vom anderen PC noch zu holen:**
|
||||||
|
Protokoll und Stand der Schlüssel-Karte NACH dem Update — dann ist der
|
||||||
|
Kandidat bestätigt oder verworfen.
|
||||||
|
2. **Kandidat 2 → Titel lesen 15 Minuten + Regler** (Einstellungen →
|
||||||
|
Allgemein, 5–60), Zwischenstand je Minute im Dialog, „Abbrechen"
|
||||||
|
beendet den Lauf im Kern, Meldung nennt Kopierschutz/Playlisten.
|
||||||
|
3. **Jede Spur einzeln anhaken** („Jede Spur einzeln anhaken
|
||||||
|
(Recommended)"): Dialog mit Ton- und Untertitel-Zeilen je Titel
|
||||||
|
(`gemeinsam/spuren.ts`, `SpurZeilen` im Dialog), Vorwahl je Extra als
|
||||||
|
Spur, Parser liest SINFO 2/22, abgeleitete Spuren (0x800) — DTS-Kerne
|
||||||
|
versteckt, „nur erzwungene" sichtbar; Ablage findet die Spuren im
|
||||||
|
HandBrake-Scan der Roh-Datei wieder (`spurenZuordnen`, gestuft) und
|
||||||
|
gibt `--audio n,n --subtitle n,n --subtitle-default=1`; MakeMKV-Regel
|
||||||
|
jetzt `-sel:all,+sel:all,-sel:havecore,=100:all` (Stereo-Doppel und
|
||||||
|
Kommentare bleiben), alte Rippy-Regel wird ersetzt, fremde bleibt.
|
||||||
|
4. **4K nach der Leistung** („Ja, genau so"): GPU mit 4K-Preset →
|
||||||
|
komprimieren (8 Bit), nur CPU → verlustfrei; AV1 10 Bit als eigene Wahl.
|
||||||
|
5. **F10 Disc-Typ aus dem Dateisystem**: `INDX0300` = UHD (am Akira-Abbild
|
||||||
|
gemessen), `0100/0200` = Blu-ray, `VIDEO_TS` = DVD.
|
||||||
|
6. **F16 Installer aus dem Archiv nur nach Rückfrage** („Archiv nur nach
|
||||||
|
Rückfrage"): Frage in der MakeMKV-Karte, Größe + SHA-256 im Bericht.
|
||||||
|
7. **F31 (NEU, beim Bau gemessen): Ton war seit 5.0 nie verlustfrei.**
|
||||||
|
`--aencoder copy` ohne `--audio-copy-mask` → HandBrakeCLI 1.11.2 kopiert
|
||||||
|
nur AAC, rechnet AC3/DTS/TrueHD um. Jetzt Maske
|
||||||
|
`aac,ac3,eac3,dtshd,dts,mp3,truehd,flac` auf beiden Wegen. **Dem
|
||||||
|
Commander sagen:** Filme, deren Roh-Dateien nach der Kompression
|
||||||
|
gelöscht wurden, tragen AAC — bei wichtigem HD-Ton neu rippen.
|
||||||
|
8. Alle übrigen Funde F2–F30 und A5 (siehe Commit-Liste oben; jeder mit
|
||||||
|
Test).
|
||||||
|
|
||||||
|
### Messungen dieser Session (Regel D)
|
||||||
|
|
||||||
|
* HandBrakeCLI 1.11.2 (vendor), Quelle per ffmpeg (jpn 5.1 / deu 2.0 /
|
||||||
|
deu 2.0 „Kommentar" AC3, deu/eng SRT), Preset „H.265 MKV 1080p30":
|
||||||
|
`--audio 1,3` → jpn + deu-Kommentar; `--subtitle 2,1 --subtitle-default=1`
|
||||||
|
→ eng (default) vor deu; `--audio none --subtitle none` → nur Video;
|
||||||
|
ohne `--audio-copy-mask` Passthru-Zeile „allowed codecs are AAC" und
|
||||||
|
Ausgabe AAC; mit Maske „AAC, AC3, E-AC3, TrueHD, DTS, DTS-HD, MP3, FLAC"
|
||||||
|
und Ausgabe AC3. Presets-Export: alle drei Produktions-Presets tragen die
|
||||||
|
volle CopyMask — die CLI verengt sie trotzdem.
|
||||||
|
* HandBrake-Scan-JSON: `AudioList[].CodecName` („dtshd", „dca", „ac3"),
|
||||||
|
`ChannelCount`, `ChannelLayout`; `SubtitleList[].Source` (Zahl),
|
||||||
|
`SourceName` („UTF-8", „PGS"), `Format`.
|
||||||
|
* HandBrake 4K (RX 9070 XT): `vce_h265` 8 Bit läuft, `vce_h265_10bit`
|
||||||
|
Segfault (Profil auto und main10), `vce_av1_10bit` läuft.
|
||||||
|
* `BDMV/index.bdmv` am Akira-UHD-Abbild: `INDX0300`.
|
||||||
|
* `QueryDosDeviceW`: C: → `\Device\HarddiskVolume7`.
|
||||||
|
* Electron 44 utilityProcess überlebt unbehandelte Promise-Ablehnung.
|
||||||
|
* Forum t=1053: Key 68 Zeichen mit `@`, „valid until end of September 2026".
|
||||||
|
* Echte `rippy.db` über `\\localhost\C$`: Schema 4 — der Datenbank-Befund
|
||||||
|
vom 01.09. war die MSIX-Schattensicht dieses Terminals (A1).
|
||||||
|
* Nodes `mkdirSync({recursive:true})` auf procfs: Endlosschleife (Ampel-Lauf 279).
|
||||||
|
|
||||||
|
### ⚠ Am Gerät noch zu messen
|
||||||
|
|
||||||
|
* **Spuren einzeln an einer echten Disc** (Kizuna: DTS-HD MA deu/jpn,
|
||||||
|
drei PGS deu): Dialog-Zeilen, DTS-Kern unsichtbar, Roh-Datei enthält
|
||||||
|
die Stereo-Fassung (neue MakeMKV-Regel — beim ersten Start von 5.7.0
|
||||||
|
meldet die Werkzeug-Karte „Auswahlregel erneuert"), HandBrake-Ausgabe
|
||||||
|
mit genau den gewählten Spuren, Ton-Codec in der fertigen Datei DTS-HD
|
||||||
|
MA statt AAC (F31).
|
||||||
|
* Gewöhnliche Blu-ray: `index.bdmv` muss `INDX0200` (oder 0100) tragen.
|
||||||
|
* 4K-Disc am Laufwerk: Typ `uhd`, Preset „H.265 VCN 2160p 4K", Encode
|
||||||
|
läuft durch (Messung nur am Abbild).
|
||||||
|
* Titel lesen > 5 min an einer zähen DVD; Abbrechen gibt das Laufwerk frei.
|
||||||
|
* Rettungs-Abbild an einer AACS-Disc (nie gemessen, siehe 5.5.0).
|
||||||
|
* Der andere PC: Protokoll + Schlüssel-Karte nach dem Update (Kandidat 1).
|
||||||
|
|
||||||
|
### Offene Fragen an den Commander
|
||||||
|
|
||||||
|
* **4K-Vorgabe:** 8-Bit-HEVC-Hardware (läuft) oder AV1 10 Bit (läuft,
|
||||||
|
braucht AV1-fähige Abspielgeräte)? Heute: HEVC 8 Bit vorgegeben, AV1
|
||||||
|
wählbar.
|
||||||
|
* **Alte Filme mit AAC-Ton (F31):** neu rippen oder so lassen?
|
||||||
|
|
||||||
|
### Bekannte Schwächen, bewusst in Kauf genommen
|
||||||
|
|
||||||
|
* Die Spur-Zuordnung Roh-Datei ↔ Dialog läuft über Sprache + Codec-Familie
|
||||||
|
+ Kanäle (MakeMKV nummeriert anders als HandBrake); bei zwei identischen
|
||||||
|
Spuren gleicher Sprache, gleichen Codecs und gleicher Kanalzahl
|
||||||
|
entscheidet die Reihenfolge. Was nicht gefunden wird, steht als Hinweis.
|
||||||
|
* Keine gewählte Tonspur → HandBrakes erste Spur (kein stummer Film); der
|
||||||
|
Dialog sagt es.
|
||||||
|
* Die Automatik (ohne Dialog) rippt weiter nach Sprachlisten, nicht nach
|
||||||
|
Spuren.
|
||||||
|
* 10-Bit-HEVC per VCE stürzt in HandBrake 1.11.2 ab — 4K bleibt 8 Bit,
|
||||||
|
bis ein neueres HandBrake mitgeliefert wird.
|
||||||
|
* Der Kanal trägt 5.7.0; der andere PC (5.6.0) holt sie über das Update,
|
||||||
|
dieser PC hat sie per Installer.
|
||||||
|
|
||||||
|
## Übergabe vom 03.09.2026 spät (5.6.1 — gebaut und lokal installiert, nie veröffentlicht; überholt durch 5.7.0)
|
||||||
|
|
||||||
|
### Stand
|
||||||
|
|
||||||
|
**5.6.1 ist gebaut, am ausgepackten Paket bewiesen und auf DIESEM PC
|
||||||
|
installiert (03.09.2026 19:40, Installer-Exit 0 — Rippy 5.6.0 lief dabei mit fünf Prozessen ohne Rip oder Encode und wurde vom Installer beendet), NICHT im Kanal — dort liegt 5.6.0.** Der
|
||||||
|
Commander testet erst; auf sein Wort geht 5.6.1 in den Kanal (Weg wie bei
|
||||||
|
5.6.0, siehe die Übergabe darunter: ERST Setup und Blockmap, DANN
|
||||||
|
`latest.yml`, PATCH, anonym gegenmessen).
|
||||||
|
|
||||||
|
* Setup: `RippySetup-5.6.1.exe`, **131 566 691 Bytes**, SHA-256 `04806C8DF53A799FB38EE185E3FE49A8CA139D61CF6BAD041C39A4F6CAEF6ED4`
|
||||||
|
(Bau in `dist-setup-561/`, Kopie in `dist-setup/`).
|
||||||
|
* Paket-Smoke: `SMOKE: OK — fenster=geladen kern=pid:27472,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.6.1`.
|
||||||
|
* **450 Tests** in 45 Dateien, drei Typprüfungen, Bau
|
||||||
|
und Klick-Beweis (33 Schritte) grün.
|
||||||
|
* Commits: `6871734` (Fix, Test, Messung, Aenderungsnotizen, Version 5.6.1 — 17 Dateien) und dieser SAVEPOINT.
|
||||||
|
|
||||||
|
### Der Fund des Commanders (03.09.2026, 19:35): „die auswahl der untertitel ist weiterhin nicht drin"
|
||||||
|
|
||||||
|
Bild aus 5.6.0: Blu-ray „Digimon Adventure: Last Evolution Kizuna" (2020),
|
||||||
|
Titel 1 mit TON deu×2 und jpn×2, aber ohne UNTERTITEL-Chips. Gemessen
|
||||||
|
(Regel D) mit `makemkvcon64 -r --minlength=120 info disc:0` an der Disc in
|
||||||
|
G: — die volle Ausgabe liegt unter
|
||||||
|
`beweise/messungen/2026-09-03-makemkvcon-info-kizuna.txt` (375 Zeilen):
|
||||||
|
|
||||||
|
```
|
||||||
|
SINFO:1,1,1,6202,"Audio" SINFO:1,1,3,0,"deu" DTS-HD MA Surround 5.1 German
|
||||||
|
SINFO:1,3,1,6202,"Audio" SINFO:1,3,3,0,"jpn" DTS-HD MA Stereo Japanese
|
||||||
|
SINFO:1,5,1,6203,"Untertitel" SINFO:1,5,3,0,"deu" PGS German
|
||||||
|
SINFO:1,6,1,6203,"Untertitel" SINFO:1,6,3,0,"deu" PGS German (nur erzwungene) Attribut 22 = 6144
|
||||||
|
SINFO:1,7,1,6203,"Untertitel" SINFO:1,7,3,0,"deu"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ursache:** MakeMKV schreibt den Stream-Typ (Attribut 1) in seiner
|
||||||
|
Anzeigesprache. Deutsch eingestellt heißt es „Untertitel"; der Parser
|
||||||
|
(`kern/rip/parser.ts parseStreamInfo`) prüfte seit 26.07. auf das
|
||||||
|
englische „Subtitles" (damals an einem englisch eingestellten MakeMKV
|
||||||
|
gemessen). „Audio" heißt zufällig in beiden Sprachen gleich — deshalb
|
||||||
|
kamen die Tonspuren an, die Untertitel nicht. Die MakeMKV-Auswahlregel
|
||||||
|
(`spurauswahl.ts`) war nicht betroffen: MakeMKV nimmt alle Spuren mit, nur
|
||||||
|
der Dialog konnte sie nicht zeigen.
|
||||||
|
|
||||||
|
**Fix (5.6.1):** Der Typ-CODE im vierten Feld zählt — 6201 Video, 6202
|
||||||
|
Audio, 6203 Untertitel (`STREAM_CODE_*`, `StreamInfo.typCode`); das Wort
|
||||||
|
bleibt nur Notnagel für Ausgaben ohne Code. Test
|
||||||
|
`test/rip-parser-561.test.ts` mit den gemessenen Zeilen (deu×2/jpn×2 Ton,
|
||||||
|
deu×3 Untertitel; französisch „Sous-titres" per Code erkannt).
|
||||||
|
|
||||||
|
**Nebenbefund für später:** Attribut 22 trägt Flags — 6144 bei der Spur
|
||||||
|
„nur erzwungene", 0 bei der vollen; Attribut 30 die Beschreibung („PGS
|
||||||
|
German (nur erzwungene)"). Damit ließe sich die Option „einzelne Spuren
|
||||||
|
(voll / nur erzwungen)" bauen, die der Commander am 03.09. NICHT gewählt
|
||||||
|
hat („nur Extras, immer optional"). Heute: Chip „deu×3" = alle drei
|
||||||
|
kommen mit, die Vorwahl gilt je Sprache.
|
||||||
|
|
||||||
|
### Was am Gerät zu prüfen ist
|
||||||
|
|
||||||
|
* Dieselbe Disc noch einmal in den Dialog: UNTERTITEL-Chip „deu×3" muss
|
||||||
|
erscheinen; bei Rolle Extra dazu „BEIM ABSPIELEN AN".
|
||||||
|
* Alles aus der 5.6.0-Übergabe darunter (zwei Filme je Disc, Vorwahl im
|
||||||
|
Spieler, Restzeit, Hell-Modus mit Postern, Rettungs-Abbild, Automatik,
|
||||||
|
Benachrichtigung, Datenbank-Befund).
|
||||||
|
|
||||||
|
## Übergabe vom 03.09.2026 abends (5.6.0 — veröffentlicht im Tag `aktuell`; überholt durch 5.6.1)
|
||||||
|
|
||||||
### Wo die nächste Session startet
|
### Wo die nächste Session startet
|
||||||
|
|
||||||
@@ -14,7 +353,9 @@
|
|||||||
`npm run smoke` (Gerüst), `npm run klick` (33 Klick-Schritte, Bilder
|
`npm run smoke` (Gerüst), `npm run klick` (33 Klick-Schritte, Bilder
|
||||||
`beweise/klick/*.png`, darunter `07-hell-modus.png` und `02-dialog-vorwahl.png`).
|
`beweise/klick/*.png`, darunter `07-hell-modus.png` und `02-dialog-vorwahl.png`).
|
||||||
Alle drei in dieser Session grün.
|
Alle drei in dieser Session grün.
|
||||||
* **Release veröffentlichen — NUR nach dem Wort des Commanders:** die
|
* **Release veröffentlichen — für 5.6.0 ERLEDIGT (03.09. abends, Commander:
|
||||||
|
„Dann Deploy und aktualisierung bei mir aufm lokalen pc“). Der Weg fürs
|
||||||
|
nächste Mal, wieder NUR nach seinem Wort:** die
|
||||||
Dateien im Gitea-Tag `aktuell` ERSETZEN (`RippySetup-5.6.0.exe`,
|
Dateien im Gitea-Tag `aktuell` ERSETZEN (`RippySetup-5.6.0.exe`,
|
||||||
`.blockmap`, `latest.yml`; `w0.yml` bleibt liegen). Dateien liegen in
|
`.blockmap`, `latest.yml`; `w0.yml` bleibt liegen). Dateien liegen in
|
||||||
`rippy-windows/dist-setup/` (Kopie) und `rippy-windows/dist-setup-560/`
|
`rippy-windows/dist-setup/` (Kopie) und `rippy-windows/dist-setup-560/`
|
||||||
@@ -26,12 +367,25 @@
|
|||||||
neu hochladen (201), Release-Name/Notizen PATCHen, anonym über
|
neu hochladen (201), Release-Name/Notizen PATCHen, anonym über
|
||||||
`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell/`
|
`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell/`
|
||||||
gegenmessen. Das PowerShell-Werkzeug blockt Befehle mit `Remove-Item` —
|
gegenmessen. Das PowerShell-Werkzeug blockt Befehle mit `Remove-Item` —
|
||||||
Skript ohne schreiben. **Im Kanal liegt 5.5.0.**
|
Skript ohne schreiben. **Im Kanal liegt 5.6.0.**
|
||||||
|
|
||||||
### Stand
|
### Stand
|
||||||
|
|
||||||
**5.6.0 ist gebaut, am ausgepackten Paket bewiesen und auf DIESEM PC
|
**5.6.0 ist gebaut, am ausgepackten Paket bewiesen, auf DIESEM PC
|
||||||
installiert (03.09.2026 19:17, Installer-Exit 0, Rippy lief dabei nicht), NICHT im Kanal.** Der Commander testet erst.
|
installiert (03.09.2026 19:17, Installer-Exit 0, Rippy lief dabei nicht) und
|
||||||
|
seit dem Abend des 03.09.2026 IM KANAL `aktuell`.**
|
||||||
|
|
||||||
|
**Veröffentlicht (03.09.2026 abends, Commander: „perfekt, danke. Dann
|
||||||
|
Deploy und aktualisierung bei mir aufm lokalen pc“):** Release 1, Assets 40
|
||||||
|
(`RippySetup-5.6.0.exe`, 131 566 582 Bytes, HTTP 201), 41 (Blockmap,
|
||||||
|
137 537 Bytes, 201), 42 (`latest.yml`, 8 273 Bytes, 201 — das alte Asset 39
|
||||||
|
vorher gelöscht, 204); Release umbenannt in „Rippy 5.6.0 (Update-Kanal
|
||||||
|
aktuell)“ mit den 5.6.0-Notizen (PATCH 200). **Anonym extern
|
||||||
|
gegengemessen:** `latest.yml` HTTP 200 und Byte für Byte gleich dem Bau
|
||||||
|
(`cmp`), Setup HTTP 200 mit Content-Length 131 566 582, Blockmap 200 mit
|
||||||
|
137 537. Lokal noch einmal geprüft: `%LOCALAPPDATA%\Programs\Rippy\Rippy.exe`
|
||||||
|
Version 5.6.0.0 (Stand 19:16), kein Rippy-Prozess lief. Der zweite PC holt
|
||||||
|
sich 5.6.0 über die Update-Funktion (5.5.0 → 5.6.0).
|
||||||
|
|
||||||
* Setup: `RippySetup-5.6.0.exe`, **131 566 582 Bytes**, SHA-256
|
* Setup: `RippySetup-5.6.0.exe`, **131 566 582 Bytes**, SHA-256
|
||||||
`4392CA5D36E165491085810D4181B22594BB29D62334ED1CDD0D2171264DA1FD` (Bau in `dist-setup-560/`).
|
`4392CA5D36E165491085810D4181B22594BB29D62334ED1CDD0D2171264DA1FD` (Bau in `dist-setup-560/`).
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# Durchsicht Rippy v5 (5.6.1) — Bericht vom 12.09.2026
|
||||||
|
|
||||||
|
> Anlass: Drei Filme (Der Patriot, Déjà Vu, Training Day) lassen sich auf
|
||||||
|
> einem ANDEREN PC mit Rippy nicht bearbeiten, während MakeMKV allein sie
|
||||||
|
> lesen und öffnen kann. Dazu die Bitte um einen kompletten Check von
|
||||||
|
> Kern, Haupt und Fenster.
|
||||||
|
>
|
||||||
|
> Was hier steht, ist GEMESSEN oder im Quelltext mit Zeile belegt — nichts
|
||||||
|
> ist geraten. Wo ich etwas nicht messen konnte, steht das dabei.
|
||||||
|
|
||||||
|
Stand des Repos: Branch `worktree-windows-electron`, Spitze `473eea7`,
|
||||||
|
Arbeitsbaum sauber. `npm run typecheck` grün, `npm test` grün
|
||||||
|
(450 Tests bestanden, 12 übersprungen — die Messungen, die ein Laufwerk
|
||||||
|
brauchen). Gelesen wurden ALLE 92 Quelldateien unter `rippy-windows/src`
|
||||||
|
(16 081 Zeilen), die Tests, die Bau-Konfiguration und die Protokolle des
|
||||||
|
echten Rippy auf diesem PC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Die drei Filme — was am anderen PC dahintersteckt
|
||||||
|
|
||||||
|
Ich habe die Disc-Ausgaben des anderen PCs nicht; ich habe aber ALLE Wege
|
||||||
|
gefunden, auf denen Rippy scheitert, während MakeMKV allein weiterkommt.
|
||||||
|
Sie stehen in der Reihenfolge, in der ich sie für wahrscheinlich halte —
|
||||||
|
mit der Zeile, an der man sie am anderen PC in einer Minute erkennt.
|
||||||
|
|
||||||
|
**Die Protokoll-Datei liegt dort unter `%APPDATA%\Rippy\logs\rippy-JJJJ-MM-TT.log`**
|
||||||
|
(im Programm: Einstellungen → Allgemein → „Protokoll öffnen"). Die Zeilen
|
||||||
|
des Tages, an dem einer der drei Filme versucht wurde, entscheiden.
|
||||||
|
|
||||||
|
### Kandidat 1 — MakeMKV läuft dort ohne gültigen Schlüssel (Beta-Key-Fehler in Rippy)
|
||||||
|
|
||||||
|
Der frei veröffentlichte Beta-Key vom September 2026 enthält ein `@`:
|
||||||
|
`T-vwvn…6Ox4BI@N5rPp` (68 Zeichen, heute von der Herstellerseite geladen,
|
||||||
|
„valid until end of September 2026"). Rippys Muster
|
||||||
|
`KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/` (`kern/werkzeuge/schluessel.ts:19`)
|
||||||
|
kennt kein `@` und schneidet den Schlüssel auf 62 Zeichen ab (Sonde 9,
|
||||||
|
gemessen an der echten Seite). Den verstümmelten Schlüssel lehnt MakeMKV
|
||||||
|
ab (5020), Rippy nimmt ihn „damit MakeMKV im Beta-Modus weiterläuft" wieder
|
||||||
|
zurück (`schluessel.ts:214-219`) — und MakeMKV läuft ohne Schlüssel.
|
||||||
|
Für DVDs ist das egal (laut Hersteller dauerhaft frei), **für Blu-rays
|
||||||
|
schaltet MakeMKV nach Ablauf des Testzeitraums ab** — nach der
|
||||||
|
Gnadenfrist („noch einige Tage", MSG 5051) beim Speichern, je nach
|
||||||
|
Fassung auch beim Öffnen. Ob das GUI am anderen PC noch in der
|
||||||
|
Gnadenfrist ist, sagt dessen Startmeldung; Rippy dagegen meldet den
|
||||||
|
Zustand nicht als Ausfall (siehe F1/F5).
|
||||||
|
|
||||||
|
Warum es bei DIR läuft: In der echten Datenbank dieses PCs steht unter
|
||||||
|
`makemkvAppKey` ein 68-Zeichen-Schlüssel MIT `@` — du hast den Key von
|
||||||
|
Hand als „Eigener Schlüssel" eingetragen. Der andere PC verlässt sich
|
||||||
|
vermutlich auf die Automatik, und die liefert den kaputten Key.
|
||||||
|
|
||||||
|
*Erkennen:* Einstellungen → MakeMKV zeigt dort gelb „BETA-MODUS — RIPS
|
||||||
|
LAUFEN" oder rot; im Protokoll steht `Schlüssel: Beta-Key abgelehnt … —
|
||||||
|
wieder entfernt`; ein gescheiterter Rip trägt `Ursache: MakeMKV ist nicht
|
||||||
|
freigeschaltet (Testzeitraum abgelaufen)`; MakeMKV-GUI zeigt beim Start
|
||||||
|
„Der Testzeitraum ist abgelaufen".
|
||||||
|
*Sofort-Abhilfe ohne neue Version:* den vollständigen Key (mit `@`) von
|
||||||
|
Hand unter Einstellungen → MakeMKV → „Eigener Schlüssel" eintragen,
|
||||||
|
„Schlüssel neu prüfen". Gilt bis 30.09.2026 — danach braucht es den
|
||||||
|
Rippy-Fix (Fund 1 unten) oder wieder Handarbeit.
|
||||||
|
|
||||||
|
### Kandidat 2 — Titel lesen bricht nach fünf Minuten ab (der Timeout, nicht die Disc)
|
||||||
|
|
||||||
|
`erkennung.titelLesen` tötet `makemkvcon info` nach 300 s
|
||||||
|
(`kern/dienste/erkennung.ts:315`, `kern/rip/makemkv.ts:170-173`) und
|
||||||
|
meldet „MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer
|
||||||
|
beschädigten Disc kann das passieren". Ältere DVDs mit Kopierschutz
|
||||||
|
(Sony-ARccOS, Disney-99-Titel-Struktur) und Discs mit vielen Playlisten
|
||||||
|
brauchen im MakeMKV-GUI regelmäßig länger als fünf Minuten zum Öffnen —
|
||||||
|
das GUI wartet, Rippy nicht. Ein hart beendetes makemkvcon hinterlässt
|
||||||
|
außerdem gern den 5042-Zustand („findet kein Laufwerk"), sodass der
|
||||||
|
NÄCHSTE Versuch sofort scheitert.
|
||||||
|
*Erkennen:* `Titel-Lauf X gescheitert: MakeMKV hat die Titel nach fünf
|
||||||
|
Minuten nicht gelesen`; im GUI dauert „Disc wird geöffnet" länger als 5 min.
|
||||||
|
|
||||||
|
### Kandidat 3 — der Rip startet still nicht (Ablage oder Arbeitsordner nicht erreichbar)
|
||||||
|
|
||||||
|
`Pipeline.starten` legt den Roh-Ordner mit `mkdirSync` an
|
||||||
|
(`kern/ablauf/pipeline.ts:258`) — ohne Fangnetz. Ist die Ablage oder der
|
||||||
|
Arbeitsordner gerade nicht da (NAS aus, Netzlaufwerk noch nicht
|
||||||
|
verbunden, Buchstabe weg), fliegt eine Ausnahme aus `starten()`, der
|
||||||
|
Aufruf `void laufwerke.bereit.then(() => ripVerwaltung.starten(…))`
|
||||||
|
(`kern/index.ts:198`) verschluckt sie: **keine Meldung im Fenster, keine
|
||||||
|
Zeile im Protokoll, der Dialog schließt sich, nichts passiert** (Sonde 10:
|
||||||
|
`ENOENT … mkdir 'Q:\rippy-nicht-da\roh\…'`, gemeldet: nichts). Der Kern
|
||||||
|
stirbt dabei nicht (in Electron 44 gemessen: ein utilityProcess überlebt
|
||||||
|
eine unbehandelte Promise-Ablehnung) — er schweigt nur.
|
||||||
|
*Erkennen:* Nach `Titel-Lauf X: … s, N Titel` folgt KEINE Zeile
|
||||||
|
`Vorgang … beginnt`; die Kachel bleibt ohne Balken.
|
||||||
|
|
||||||
|
### Kandidat 4 — Disc-Typ „unbekannt" → Rippy sichert ein ISO statt zu rippen
|
||||||
|
|
||||||
|
Der Typ kommt aus `IOCTL_CDROM_DISK_TYPE` + Größe (`kern/laufwerk/disc.ts:52-59`).
|
||||||
|
Antwortet das Laufwerk darauf nicht (bei manchen USB-Brücken
|
||||||
|
`ERROR_INVALID_FUNCTION`) oder ohne Bits, steht `unknown` — die Kachel
|
||||||
|
zeigt „unbekannt" und PRESET „ISO-Abbild", der Knopf „Rippen" öffnet
|
||||||
|
trotzdem den Titel-Dialog (`LaufwerkKachel.tsx:157-166`), und nach der
|
||||||
|
Auswahl legt die Pipeline ein 1:1-Abbild an statt der gewählten Titel
|
||||||
|
(`pipeline.ts:236-241`).
|
||||||
|
*Erkennen:* `Disc eingelegt in X: unknown, … Bytes`, Kachel MEDIUM
|
||||||
|
„unbekannt", Statuszeile „Sichere 1:1-Abbild …".
|
||||||
|
|
||||||
|
### Kandidat 5 — Erkennung bleibt bei 60 %, die Automatik wartet (kein Fehler, aber „tut nichts")
|
||||||
|
|
||||||
|
Alle drei Discs tragen vermutlich englische Labels (`THE_PATRIOT`,
|
||||||
|
`DEJA_VU`, `TRAINING_DAY`). TMDb antwortet auf Deutsch („Der Patriot",
|
||||||
|
„Déjà Vu – Wettlauf gegen die Zeit"); der wörtliche Vergleich
|
||||||
|
`gleichBedeutend` (`kern/metadaten/zuordnung.ts:184-195`) vergleicht nur
|
||||||
|
den deutschen `title`, nie `original_title`, und kennt keine Akzente
|
||||||
|
(Sonde 1: „Déjà Vu" ≠ „Deja Vu", „Der Patriot" ≠ „The Patriot"). Ergebnis:
|
||||||
|
Vorschlag mit 60 % statt 95 % — die Automatik startet nicht
|
||||||
|
(`automatik.ts:79-82`), im Kasten steht „Automatik wartet: Die Erkennung
|
||||||
|
ist nicht sicher genug". Wer auf Zero-Click wartet, sieht: nichts.
|
||||||
|
*Erkennen:* Kachel „Vorschlag" (gelb) statt „sehr wahrscheinlich"; die
|
||||||
|
Automatik-Zeile.
|
||||||
|
|
||||||
|
### Was ich vom anderen PC brauche, um es festzunageln
|
||||||
|
|
||||||
|
1. Die Protokoll-Zeilen des Tages (siehe oben) — am besten die ganze Datei.
|
||||||
|
2. Einstellungen → MakeMKV: der Text der Schlüssel-Karte, und MakeMKV-Version.
|
||||||
|
3. Ob die drei Discs DVD, Blu-ray oder 4K sind.
|
||||||
|
4. Was genau „nicht bearbeiten" war: kein Start, roter Text, ISO, oder ewig „liest die Titel".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fehler — nach Schwere, mit Beleg
|
||||||
|
|
||||||
|
Schwere: **HOCH** = Datenverlust, Ausfall oder stilles Scheitern ·
|
||||||
|
**MITTEL** = falsches Verhalten, das der Nutzer bemerkt · **NIEDRIG** =
|
||||||
|
Schönheit, Bedienung, Nebensache. Jede Nummer verweist auf Datei:Zeile im
|
||||||
|
Stand `473eea7`.
|
||||||
|
|
||||||
|
### HOCH
|
||||||
|
|
||||||
|
**F1 · Beta-Key mit `@` wird abgeschnitten → MakeMKV unlizenziert**
|
||||||
|
`kern/werkzeuge/schluessel.ts:19` (`KEY_MUSTER`), `:24-27`, `:212-219`.
|
||||||
|
Beleg: Sonde 9 an der heute geladenen Forum-Seite: Seite 68 Zeichen mit
|
||||||
|
`@`, Rippy zieht 62. Wirkung: siehe Kandidat 1 — auf jedem PC ohne
|
||||||
|
Hand-Key laufen Blu-ray-Rips nach dem Testzeitraum nicht mehr, und Rippy
|
||||||
|
zeigt dazu GELB „BETA-MODUS — RIPS LAUFEN" (`Karten.tsx:409-413`,
|
||||||
|
`App.tsx:115`). Dazu: `extractGueltigBis` (`:32-37`) erkennt „end of
|
||||||
|
September 2026" nicht → kein Ablaufdatum im Fenster (gemessen: leer).
|
||||||
|
Fix: Muster auf alles bis Leerzeichen/`<` (z. B. `/T-[^\s<"']{50,}/`),
|
||||||
|
Ablehnung ROT mit Klartext „Blu-ray-Rips laufen nicht", und ein Test mit
|
||||||
|
dem echten September-Schlüssel.
|
||||||
|
|
||||||
|
**F2 · Rip-Start ohne erreichbare Ablage/Arbeitsordner scheitert STILL**
|
||||||
|
`kern/ablauf/pipeline.ts:258` (`mkdirSync` ohne try), ebenso `:646`
|
||||||
|
(Audio-CD) und `:721` (ISO); Aufrufer `kern/index.ts:198` und `:236`.
|
||||||
|
Beleg: Sonde 10 + Electron-Messung (utilityProcess lebt weiter, Fehler
|
||||||
|
unsichtbar). Verstößt gegen R4. Fix: `starten()` in try/catch, Fehler als
|
||||||
|
`rip-status fehler` melden UND ins Protokoll; zusätzlich vor dem Start
|
||||||
|
eine Schreibprobe am Roh-Ordner, damit die Kachel sagt „Ablage nicht
|
||||||
|
erreichbar" statt zu schweigen; im Kern ein `process.on('unhandledRejection')`,
|
||||||
|
das ins Protokoll schreibt.
|
||||||
|
|
||||||
|
**F3 · Serien: zweite Disc derselben Staffel — Folgen werden nicht benannt, Überschreiben möglich**
|
||||||
|
`kern/ablage/struktur.ts:224-246`: `episodenUmbenennen` zählt ALLE `.mkv`
|
||||||
|
im Season-Ordner; liegen dort schon `S01E01`/`S01E02` der ersten Disc,
|
||||||
|
heißt es „Umbenennen übersprungen: 4 Dateien, 2 Zuordnungen" (Sonde 2,
|
||||||
|
real ausgeführt). Der Serien-Merker (5.5.0) und „ab Folge" laufen damit ab
|
||||||
|
Disc 2 ins Leere. Schlimmer: Folgen behalten bis dahin MakeMKVs Namen
|
||||||
|
(`ablegen.ts:198-209`, `dateinamen.ts:121`), und `title_t00.mkv` der
|
||||||
|
zweiten Disc landet im SELBEN Ordner (`ablegen.ts:152-156`) — HandBrake
|
||||||
|
bzw. `renameSync` (`ablegen.ts:95-104`) überschreiben eine gleichnamige
|
||||||
|
Datei der ersten Disc ohne Warnung, wenn deren Umbenennung ausgefallen
|
||||||
|
war (kein TMDb). Der bestehende Test `test/ablage.test.ts:106-116`
|
||||||
|
schreibt das Verhalten sogar fest. Fix: nur die NEUEN Dateien dieser
|
||||||
|
Disc umbenennen (Liste übergeben statt Ordner lesen); Zielnamen vor dem
|
||||||
|
Encode auf Kollision prüfen und nie überschreiben.
|
||||||
|
|
||||||
|
**F4 · Kern-Neustart: das Fenster bleibt taub**
|
||||||
|
`haupt/kernstart.ts:59-74` startet den Kern bis zu 3× je Minute neu, aber
|
||||||
|
`fensterVerbinden` läuft nur bei `did-finish-load` (`haupt/index.ts:245-253`);
|
||||||
|
das Fenster hält den toten Port (`kernverbindung.ts:268-282`), `anKern`
|
||||||
|
schickt ins Leere, und `App.tsx:92` zeigt keinen Fehler, weil
|
||||||
|
`verbindung.kern` noch die alte Bereit-Meldung ist. Wirkung: Laufwerke
|
||||||
|
frieren ein, Knöpfe tun nichts — bis Rippy komplett neu gestartet wird.
|
||||||
|
Das KONZEPT verspricht „Der Kern ist abgestürzt, ich starte ihn neu".
|
||||||
|
Fix: im `spawn`-Ereignis des neuen Kerns `fensterVerbinden` erneut
|
||||||
|
aufrufen und eine Zeile im Fenster zeigen.
|
||||||
|
|
||||||
|
**F31 · Nachtrag (12.09. abends, beim Bau von 5.7.0 gemessen): jede
|
||||||
|
Tonspur wurde seit 5.0 in AAC umgerechnet — nicht verlustfrei durchgereicht**
|
||||||
|
`kern/komprimieren/handbrake.ts` gibt `--aencoder copy --audio-fallback
|
||||||
|
av_aac` mit, aber KEIN `--audio-copy-mask`. HandBrakeCLI 1.11.2 verengt
|
||||||
|
die Kopiermaske dann auf „copy:aac" — obwohl die Presets („H.265 MKV
|
||||||
|
1080p30", „H.265 VCN 2160p 4K" …) alle Codecs erlauben. Beleg: Encode-
|
||||||
|
Protokoll `Auto Passthru: allowed codecs are AAC … passthru not possible
|
||||||
|
for track 1, using fallback`; Ausgabe per ffprobe `aac`. Mit
|
||||||
|
ausdrücklicher Maske (`aac,ac3,eac3,dtshd,dts,mp3,truehd,flac`) bleibt
|
||||||
|
AC3 AC3 und die Passthru-Zeile nennt alle acht Codecs (Messung in
|
||||||
|
`test/messung.spuren.test.ts`, `RIPPY_MESSUNG=1`). Wirkung: Jede seit
|
||||||
|
5.0 komprimierte Datei trägt AAC statt DTS-HD MA/TrueHD/AC3 — wo die
|
||||||
|
Roh-Dateien nach der Kompression gelöscht wurden, ist der Original-Ton
|
||||||
|
weg. Fix in 5.7.0: Maske auf beiden Wegen (Sprachlisten und Spuren
|
||||||
|
einzeln). Filme, bei denen der HD-Ton wichtig ist, noch einmal rippen.
|
||||||
|
|
||||||
|
### MITTEL
|
||||||
|
|
||||||
|
**F5 · Schlüssel-Prüfung urteilt ohne Grundlage**
|
||||||
|
`schluessel.ts:108-131`: `info disc:9999` liefert ohne angeschlossenes
|
||||||
|
Laufwerk NUR 5042/5010 (heute gemessen) — keine Lizenzzeile, also
|
||||||
|
`ok: true` und „angenommen", egal was im Register steht. `:179`:
|
||||||
|
das Ergebnis von `keyAblegen` (reg add) wird ignoriert — „hinterlegt"
|
||||||
|
steht auch dann im Protokoll, wenn nichts geschrieben wurde. `:194-237`:
|
||||||
|
Ablehnung liefert `weiterBetrieb: true` (gelb) statt rot.
|
||||||
|
|
||||||
|
**F6 · Jeder Auswurf legt eine weitere Takt-Kette der Disc-Wache an**
|
||||||
|
`kern/laufwerk/wache.ts:285-303`: `runde()` plant IMMER einen neuen
|
||||||
|
Timer, ohne den laufenden zu löschen; `laufwerke.ts:175` ruft `runde()`
|
||||||
|
nach jedem Auswurf. Beleg: Sonde 3 — drei Ketten, neunmal statt fünfmal
|
||||||
|
gelesen. Wirkung: nach 20 Discs fragt Rippy die Laufwerke alle 150 ms
|
||||||
|
statt alle 3 s (CreateFileW + IOCTL je Runde). Fix: vorhandenen Timer
|
||||||
|
in `runde()` erst löschen.
|
||||||
|
|
||||||
|
**F7 · Während der Kompression wird jede Sekunde die ganze Welt neu vermessen**
|
||||||
|
Kette: HandBrake-Fortschritt (mit ETA ≈ 1×/s, `handbrake.ts:152-158`) →
|
||||||
|
`warteschlange.ts:141-146` → `vorgaenge.ts:118-124` `senden()` →
|
||||||
|
`stand()` (`:217-260`) läuft mit `ordnerGroesse` rekursiv durch JEDEN
|
||||||
|
Roh-Ordner plus `fremdeOrdner`, danach `speicher.senden()` mit zwei
|
||||||
|
`statfsSync` (`speicher.ts:237-247`) — alles synchron im Kern; dann geht
|
||||||
|
die volle Vorgangsliste an Haupt und Fenster, das komplett neu zeichnet
|
||||||
|
(`kernverbindung.ts:123-125`). Auf einer NAS als Arbeitsordner ist das
|
||||||
|
ein Netz-Ordnerlauf pro Sekunde. Fix: Fortschritt als kleine Nachricht
|
||||||
|
getrennt vom Vorgangs-Stand; Roh-Größen nur bei Phasenwechsel oder
|
||||||
|
gedrosselt (z. B. alle 30 s) messen.
|
||||||
|
|
||||||
|
**F8 · Das Rettungs-Abbild wird gelöscht, wenn der Rip daraus leer ausgeht**
|
||||||
|
`pipeline.ts:279-300`: `beenden()` entfernt den ganzen Roh-Ordner, wenn
|
||||||
|
keine MKV entstand — inklusive `rettung.iso`, für das die Disc stundenlang
|
||||||
|
Sektor für Sektor gelesen wurde (`halbeDateienEntfernen` schont es,
|
||||||
|
`beenden` nicht). Fix: `rettung.iso` + Bericht vor dem `rmSync` verschonen.
|
||||||
|
|
||||||
|
**F9 · Halbe HandBrake-Ausgabe bleibt nach Abbruch/Fehler liegen**
|
||||||
|
`handbrake.ts:282-285,300-308`, `ablegen.ts:279-286`: nach `kill()` oder
|
||||||
|
Fehler wird die angefangene Datei nicht entfernt. Ohne Arbeitsordner
|
||||||
|
liegt sie direkt in `Filme\<Titel>\` — Jellyfin zeigt einen kaputten
|
||||||
|
Film. Fix: nach `.part`/Zwischenname encodieren, erst am Ende umbenennen;
|
||||||
|
bei Abbruch löschen.
|
||||||
|
|
||||||
|
**F10 · Typ „unbekannt" heißt stumm ISO, obwohl der Nutzer Titel gewählt hat**
|
||||||
|
`pipeline.ts:236-241` gegen `LaufwerkKachel.tsx:157-166`/`Uebersicht.tsx:114-120`
|
||||||
|
(siehe Kandidat 4). Dazu `disc.ts:49`: UHD wird allein an ≥ 55 GiB
|
||||||
|
erkannt — eine 4K-Disc mit 48 GB Daten wird „bluray" (Sonde 4) und
|
||||||
|
bekommt das Blu-ray-Preset. Fix: liegt ein Titel-Auftrag vor, immer
|
||||||
|
MakeMKV; UHD-Merkmal am Gerät nachmessen (Regel D) statt Größe.
|
||||||
|
|
||||||
|
**F11 · Laufwerks-Gesundheit beschuldigt das falsche Gerät**
|
||||||
|
`kern/laufwerk/ereignisse.ts:41-44`: der XPath nimmt Provider `cdrom` UND
|
||||||
|
`disk` für ALLE Geräte; `:67-76` filtert nicht nach `\Device\CdRomN`.
|
||||||
|
Eine hustende Festplatte oder ein zweites Laufwerk erzeugt „VERDACHT:
|
||||||
|
DIE HARDWARE" für die Kachel des Blu-ray-Laufwerks. Fix: Gerät des
|
||||||
|
Laufwerksbuchstabens ermitteln und nur dessen Ereignisse zählen.
|
||||||
|
|
||||||
|
**F12 · Zeiten stehen in UTC, nicht in deiner Uhrzeit**
|
||||||
|
`bausteine.tsx:35-38` (`datumKurz`), `Karten.tsx:269,276,418,574`,
|
||||||
|
`VerlaufKarte.tsx:295`: ISO-Strings werden abgeschnitten — 21:30 Uhr
|
||||||
|
erscheint als 19:30 (Sonde 5). Fix: lokal formatieren.
|
||||||
|
|
||||||
|
**F13 · Eigener Werkzeug-Pfad eingetragen → Anzeige bleibt „NICHT GEFUNDEN"**
|
||||||
|
`kern/index.ts:245-254`: nach `werkzeug.*` wird `werkzeuge.senden()` nicht
|
||||||
|
angestoßen (auch `makemkvAppKey` löst keinen Schlüssel-Lauf aus). In der
|
||||||
|
Erst-Einrichtung (`ErstEinrichtung.tsx:112-121`) bleibt die rote Zeile
|
||||||
|
stehen, obwohl der Pfad stimmt. Fix: nach diesen Schlüsseln messen bzw.
|
||||||
|
prüfen.
|
||||||
|
|
||||||
|
**F14 · Erkennung kennt weder Originaltitel noch Akzente**
|
||||||
|
`zuordnung.ts:184-195,302-309`, `tmdb.ts:24-34` (kein `original_title`).
|
||||||
|
Siehe Kandidat 5. Fix: `original_title`/`original_name` mitvergleichen,
|
||||||
|
Akzente falten (NFD + Kombinationszeichen entfernen).
|
||||||
|
|
||||||
|
**F15 · Tray „Beenden" tötet einen laufenden Rip ohne Rückfrage**
|
||||||
|
`haupt/index.ts:278-281,383-387`, `tray.ts:291-297`: `app.quit()` → Kern
|
||||||
|
`kill()` → die Leine reißt makemkvcon/HandBrake mit — 40 Minuten Rip weg.
|
||||||
|
Fix: bei aktiven Vorgängen nachfragen.
|
||||||
|
|
||||||
|
**F16 · MakeMKV-Installer aus dem Internet Archive mit Adminrechten**
|
||||||
|
`beschaffen.ts:143-155,193-232`, `haupt/index.ts:206-216`: fällt der
|
||||||
|
Hersteller aus, wird eine EXE vom Archiv geladen und per UAC gestartet;
|
||||||
|
geprüft wird nur die Versions-Ressource (fälschbar). Für ein Programm im
|
||||||
|
Bekanntenkreis ein Risiko. Fix: SHA-256 einer bekannten Fassung
|
||||||
|
mitführen oder Archiv-Quelle nur nach Bestätigung.
|
||||||
|
|
||||||
|
### NIEDRIG
|
||||||
|
|
||||||
|
**F17** „Roh-Dateien löschen" und „Ordner löschen" ohne Rückfrage — ein Klick, 30 GB weg (`Karten.tsx:315-319,351-353`; `vorgaenge.ts:305-349`).
|
||||||
|
**F18** Eingabefelder speichern nur beim Verlassen; Enter und Bereichswechsel verlieren Tipparbeit (`bausteine.tsx:356-389`).
|
||||||
|
**F19** Dialoge ohne Escape, Fokus-Fang, `role="dialog"` (`bausteine.tsx:453-473`).
|
||||||
|
**F20** Toast „Disc erkannt" bis zu dreimal je Disc — Erkennung, Nachprüfung, „Ändern" (`haupt/index.ts:126-134`).
|
||||||
|
**F21** Poster-Fehler wird verschluckt: Rückgabe von `posterSpeichern` ignoriert (`ablegen.ts:385-387`, R4).
|
||||||
|
**F22** `finden()` ruft bei fehlendem MakeMKV dreimal `reg query … /s` synchron (bis 45 s) — bei jedem Aufruf, auch alle 15 s im Update-Lauf (`katalog.ts:168-203`).
|
||||||
|
**F23** NFO ohne `<uniqueid type="tmdb">` — Jellyfin muss über Titel/Jahr raten (`nfo.ts:187-209`).
|
||||||
|
**F24** Doppelter Zielordner bekommt den Zusatz „[bluray-2]" (`struktur.ts:158-170`, `vorgangsName.slice(0,8)`).
|
||||||
|
**F25** ASCII-Umlaute in Knopftexten: „auswaehlen", „Titel ueberspringen", „naechsten" (`LaufwerkKachel.tsx:161,413,415`).
|
||||||
|
**F26** Phase `rettet` fehlt in der Haupt-Liste aktiver Vorgänge (`haupt/index.ts:184`) — Update-Sperre und Taskleiste greifen im Rettungs-Abbild nicht.
|
||||||
|
**F27** „Neu komprimieren" wird auch für verschobene (unkomprimierte) Vorgänge angeboten (`vorgaenge.ts:245`) — Klick endet in „Roh-Dateien sind nicht mehr da".
|
||||||
|
**F28** Automatik schweigt bei Typ `unknown`/`cd` (`automatik.ts:77`) — kein Grund im Kasten.
|
||||||
|
**F29** Ton: `--first-audio` nimmt je Sprache genau EINE Spur (`handbrake.ts:56`) — Kommentar- oder Stereo-Spuren derselben Sprache fallen weg. Bewusst? Dann ins Fenster schreiben.
|
||||||
|
**F30** Audio-CD: `audioSpurLesen` blockiert den Kern synchron je Spur (`disc.ts:200-220`) — Abbrechen wirkt erst zwischen zwei Spuren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Auffälligkeiten (keine Fehler, aber wissenswert)
|
||||||
|
|
||||||
|
**A1 · Der „Datenbank-Befund vom 01.09." ist gelöst — ein Messartefakt.**
|
||||||
|
Das Terminal der Claude-Desktop-App läuft in einem MSIX-Paket; alles,
|
||||||
|
was daraus startet, sieht `%APPDATA%`, `%LOCALAPPDATA%` und `HKCU`
|
||||||
|
virtualisiert (Schattenkopie unter
|
||||||
|
`%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\…`). Deshalb
|
||||||
|
zeigte `%APPDATA%\Rippy\rippy.db` hier Schema 3 vom 01.09. — die ECHTE
|
||||||
|
Datei (über `\\localhost\C$\…` gelesen) ist Schema 4, 36 864 Bytes, Stand
|
||||||
|
04.09. 22:02, mit allen Einstellungen. Rippy ist unschuldig. Folge: die
|
||||||
|
Lizenzzeilen 5054/5051 in `beweise/messungen/2026-09-03-makemkvcon-info-kizuna.txt`
|
||||||
|
kamen aus dem Paket-Register ohne Schlüssel — kein Beleg für deinen
|
||||||
|
echten Zustand. (Als Gedächtnis-Notiz für künftige Sitzungen hinterlegt.)
|
||||||
|
|
||||||
|
**A2 · Rippy schreibt in MakeMKVs eigene Einstellungen** (`HKCU\Software\MakeMKV`:
|
||||||
|
`app_Key`, `app_DefaultSelectionString`, `app_UpdateEnable`). Damit
|
||||||
|
nimmt auch das MakeMKV-GUI seitdem ALLE Spuren mit (größere Dateien beim
|
||||||
|
Handbetrieb) — beabsichtigt, aber nirgends im Fenster gesagt.
|
||||||
|
|
||||||
|
**A3 · Das Rettungs-Abbild ist bei AACS-Discs (Blu-ray/4K) ungeprüft.**
|
||||||
|
Ein Sektor-Abbild bleibt verschlüsselt; ob makemkvcon es als `iso:` ohne
|
||||||
|
Laufwerk öffnet, hängt an MakeMKVs Schlüsseldaten. Bei DVDs (CSS) sollte
|
||||||
|
es gehen. Am Gerät messen, bevor es jemand im Ernstfall braucht.
|
||||||
|
|
||||||
|
**A4 · 4K wird standardmäßig komprimiert** — mit dem 8-Bit-Preset
|
||||||
|
„H.265 … 2160p 4K" bzw. auf CPU mit „H.265 MKV 1080p30" (`presets.ts:23-49`,
|
||||||
|
`preset-empfehlung.ts:321-335`). HDR-Metadaten und 10 Bit gehen dabei
|
||||||
|
verloren, der Encode dauert Stunden. Das Docker-Rippy hatte „4K
|
||||||
|
verlustfrei" als Regel. Entscheid nötig: Standard „keine" für UHD oder
|
||||||
|
ein 10-Bit-Preset.
|
||||||
|
|
||||||
|
**A5 · Toter Schlüssel `transcodePreset`** in `presets.ts:47-48` — seit
|
||||||
|
5.5.0 nicht mehr in `PIPELINE_EINSTELLUNGEN`, kann nie greifen.
|
||||||
|
|
||||||
|
**A6 · Titel-Lesen: 300 s fest verdrahtet**, kein Regler, Meldung nennt
|
||||||
|
die Disc „beschädigt" (`erkennung.ts:354-356`) — siehe Kandidat 2.
|
||||||
|
|
||||||
|
**A7 · Der Klick-Beweis und die 450 Tests sind gut, aber blind für genau
|
||||||
|
die Funde oben:** kein Test mit Bestandsdateien im Season-Ordner, keiner
|
||||||
|
für mehrfaches `runde()`, keiner mit einem Key mit `@`, keiner für den
|
||||||
|
Rip-Start ohne Ablage, keiner für den Kern-Neustart.
|
||||||
|
|
||||||
|
**A8 · Sicherheit/Härtung ist sonst gut:** contextIsolation + sandbox im
|
||||||
|
Fenster, CSP im Bau, keine Shell-Aufrufe mit Nutzerdaten, PowerShell-
|
||||||
|
Pfad wird korrekt gequotet, Update nur über HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vorschläge zur Verbesserung — in dieser Reihenfolge
|
||||||
|
|
||||||
|
1. **F1 sofort** (eine Zeile plus Test, plus rote Ampel) — vor dem 30.09.
|
||||||
|
ist das der einzige Fund, der beide PCs betrifft.
|
||||||
|
2. **F2 + F4 + F6** — die drei „stillen" Fehler; jeder ist klein, alle
|
||||||
|
drei kosten Vertrauen („tut nichts, sagt nichts").
|
||||||
|
3. **F3** — Serien mit mehr als einer Disc gehen sonst kaputt; Test mit
|
||||||
|
vorhandenen Dateien im Ordner dazu.
|
||||||
|
4. **F8 + F9** — Aufräumen, das nicht zu viel und nicht zu wenig löscht.
|
||||||
|
5. **Kandidat 2** — Titel-Lesen-Grenze auf 10–15 min, Meldung ehrlich
|
||||||
|
(„die Disc braucht länger — läuft weiter / abbrechen"), Grenze in den
|
||||||
|
Einstellungen.
|
||||||
|
6. **F7** — Fortschritt von Vorgangs-Stand trennen; spürbar auf NAS.
|
||||||
|
7. **F14** — Originaltitel + Akzente: schlägt sich direkt in der
|
||||||
|
Trefferquote der Automatik nieder.
|
||||||
|
8. **F10/F11/F12/F13** — sichtbare Kleinigkeiten, je ein halber Nachmittag.
|
||||||
|
9. **F15/F17** — zwei Rückfragen, die Daten retten.
|
||||||
|
10. **A4** — dein Entscheid zu 4K.
|
||||||
|
11. **F31** (Nachtrag) — ist in 5.7.0 behoben; Filme mit wichtigem
|
||||||
|
HD-Ton, deren Roh-Dateien schon weg sind, noch einmal rippen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Was gemessen wurde (nachvollziehbar)
|
||||||
|
|
||||||
|
* Typprüfung und Tests: `npm run typecheck`, `npx vitest run` — grün
|
||||||
|
(450 bestanden, 12 übersprungen).
|
||||||
|
* Sonden gegen den echten Code (Vitest, außerhalb des Repos):
|
||||||
|
S1 Akzente/Originaltitel · S2 Episoden-Umbenennung mit Bestand ·
|
||||||
|
S3 Wache-Takt-Ketten (9 statt ~5 Runden) · S4 Typ-Zuordnung ·
|
||||||
|
S5 UTC-Anzeige · S6 Labels der drei Filme · S7 99-Titel-Muster ·
|
||||||
|
S8 `halbeDateienEntfernen` · S9 Beta-Key von der echten Forum-Seite ·
|
||||||
|
S10 Rip-Start mit unerreichbarer Ablage.
|
||||||
|
* Electron 44: utilityProcess überlebt unbehandelte Promise-Ablehnung
|
||||||
|
(zwei Läufe, mit und ohne Horcher).
|
||||||
|
* Forum-Seite t=1053 heute geladen: Key mit `@`, „valid until end of
|
||||||
|
September 2026".
|
||||||
|
* Echte `rippy.db` dieses PCs über `\\localhost\C$`: Schema 4,
|
||||||
|
`makemkvAppKey` 68 Zeichen mit `@`, `ablage E:\Rippy`, `automatik aus`,
|
||||||
|
Jellyfin, TMDb- und OMDb-Schlüssel gesetzt, keine Vorgänge (Verlauf
|
||||||
|
leer), Bibliothek leer.
|
||||||
|
* Protokolle dieses PCs (02.–05.09.): Rippy 5.5.0 → 5.6.0 → 5.6.1,
|
||||||
|
Kizuna erkannt (95 %), Titel-Lauf 22–92 s, Laufwerk G am 02.09. mit
|
||||||
|
„2 Controllerfehler, 112 fehlerhafte Blöcke" (siehe F11 — ob das dein
|
||||||
|
BU40N war oder eine Platte, weiß der Bericht nicht).
|
||||||
|
* `makemkvcon64 -r --noscan info disc:9999` heute: nur 5042/5010 — ohne
|
||||||
|
Laufwerk keine Lizenzauskunft (F5).
|
||||||
|
|
||||||
|
Nichts davon hat etwas am Repo, an deinen Einstellungen oder am Register
|
||||||
|
verändert; die Sonden liegen im Sitzungs-Scratchpad.
|
||||||
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 711 KiB After Width: | Height: | Size: 759 KiB |
|
Before Width: | Height: | Size: 712 KiB After Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 749 KiB After Width: | Height: | Size: 791 KiB |
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 465 KiB After Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 447 KiB After Width: | Height: | Size: 445 KiB |
@@ -0,0 +1,375 @@
|
|||||||
|
MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
|
||||||
|
DRV:0,2,999,12,"BD-RE HL-DT-ST BD-RE BU40N 1.03 MOBPBDF1840","DA_LAST EVOLUTION KIZUNA","G:"
|
||||||
|
DRV:1,256,999,0,"","",""
|
||||||
|
DRV:2,256,999,0,"","",""
|
||||||
|
DRV:3,256,999,0,"","",""
|
||||||
|
DRV:4,256,999,0,"","",""
|
||||||
|
DRV:5,256,999,0,"","",""
|
||||||
|
DRV:6,256,999,0,"","",""
|
||||||
|
DRV:7,256,999,0,"","",""
|
||||||
|
DRV:8,256,999,0,"","",""
|
||||||
|
DRV:9,256,999,0,"","",""
|
||||||
|
DRV:10,256,999,0,"","",""
|
||||||
|
DRV:11,256,999,0,"","",""
|
||||||
|
DRV:12,256,999,0,"","",""
|
||||||
|
DRV:13,256,999,0,"","",""
|
||||||
|
DRV:14,256,999,0,"","",""
|
||||||
|
DRV:15,256,999,0,"","",""
|
||||||
|
MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"
|
||||||
|
MSG:3007,0,0,"Direkter Zugriffsmodus wird verwendet","Direkter Zugriffsmodus wird verwendet"
|
||||||
|
MSG:5054,0,1,"Evaluierungsversion. Der Testzeitraum lief vor 10 Tagen ab.","Evaluierungsversion. Der Testzeitraum lief vor %1 Tagen ab.","10"
|
||||||
|
MSG:5051,1028,0,"Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen.","Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen Aktivierungsschlüssel, wenn Sie das Programm nützlich fanden. Sie können den vollen Funktionsumfang noch einige Tage nutzen."
|
||||||
|
MSG:5085,0,0,"Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien.","Inhalte-Hashtabelle geladen, überprüfe Integrität der M2TS-Dateien."
|
||||||
|
MSG:3307,16777216,2,"Datei 00011.mpls wurde als Titel 0 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00011.mpls","0"
|
||||||
|
MSG:3025,0,3,"Titel #00000.mpls hat eine Länge von 41 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.mpls","41","120"
|
||||||
|
MSG:3307,16777216,2,"Datei 00003.mpls wurde als Titel 1 hinzugefügt ","Datei %1 wurde als Titel %2 hinzugefügt ","00003.mpls","1"
|
||||||
|
MSG:3025,16777216,3,"Titel #00005.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00005.m2ts","93","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00004.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00004.m2ts","98","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00015.m2ts hat eine Länge von 10 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00015.m2ts","10","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00003.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00003.m2ts","7","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00002.m2ts hat eine Länge von 18 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00002.m2ts","18","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00001.m2ts hat eine Länge von 7 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00001.m2ts","7","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00000.m2ts hat eine Länge von 8 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00000.m2ts","8","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00014.m2ts hat eine Länge von 107 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00014.m2ts","107","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00013.m2ts hat eine Länge von 63 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00013.m2ts","63","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00012.m2ts hat eine Länge von 93 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00012.m2ts","93","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00011.m2ts hat eine Länge von 78 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00011.m2ts","78","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00010.m2ts hat eine Länge von 89 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00010.m2ts","89","120"
|
||||||
|
MSG:3025,16777216,3,"Titel #00009.m2ts hat eine Länge von 98 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00009.m2ts","98","120"
|
||||||
|
MSG:3025,0,3,"Titel #00008.m2ts hat eine Länge von 95 Sekunden und wurde übersprungen, da die minimale Titellänge 120 Sekunden beträgt","Titel #%1 hat eine Länge von %2 Sekunden und wurde übersprungen, da die minimale Titellänge %3 Sekunden beträgt","00008.m2ts","95","120"
|
||||||
|
MSG:5011,0,0,"Die Aufgabe wurde erfolgreich abgeschlossen","Die Aufgabe wurde erfolgreich abgeschlossen"
|
||||||
|
TCOUNT:2
|
||||||
|
CINFO:1,6209,"Blu-ray-Disk"
|
||||||
|
CINFO:2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
CINFO:28,0,"eng"
|
||||||
|
CINFO:29,0,"English"
|
||||||
|
CINFO:30,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
CINFO:31,6119,"<b>Quelleninformationen</b><br>"
|
||||||
|
CINFO:32,0,"DA_LAST EVOLUTION KIZUNA"
|
||||||
|
CINFO:33,0,"0"
|
||||||
|
TINFO:0,2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
TINFO:0,8,0,"6"
|
||||||
|
TINFO:0,9,0,"0:08:51"
|
||||||
|
TINFO:0,10,0,"1.1 GB"
|
||||||
|
TINFO:0,11,0,"1219086336"
|
||||||
|
TINFO:0,16,0,"00011.mpls"
|
||||||
|
TINFO:0,25,0,"6"
|
||||||
|
TINFO:0,26,0,"9,10,11,12,13,14"
|
||||||
|
TINFO:0,27,0,"Digimon Adventure- Last Evolution Kizuna_t00.mkv"
|
||||||
|
TINFO:0,28,0,"eng"
|
||||||
|
TINFO:0,29,0,"English"
|
||||||
|
TINFO:0,30,0,"Digimon Adventure: Last Evolution Kizuna - 6 Kapitel , 1.1 GB"
|
||||||
|
TINFO:0,31,6120,"<b>Titelinformationen</b><br>"
|
||||||
|
TINFO:0,33,0,"0"
|
||||||
|
SINFO:0,0,1,6201,"Video"
|
||||||
|
SINFO:0,0,5,0,"V_MPEG4/ISO/AVC"
|
||||||
|
SINFO:0,0,6,0,"Mpeg4"
|
||||||
|
SINFO:0,0,7,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:0,0,19,0,"1920x1080"
|
||||||
|
SINFO:0,0,20,0,"16:9"
|
||||||
|
SINFO:0,0,21,0,"23.976 (24000/1001)"
|
||||||
|
SINFO:0,0,22,0,"0"
|
||||||
|
SINFO:0,0,28,0,"eng"
|
||||||
|
SINFO:0,0,29,0,"English"
|
||||||
|
SINFO:0,0,30,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:0,0,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,0,33,0,"0"
|
||||||
|
SINFO:0,0,38,0,""
|
||||||
|
SINFO:0,0,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,1,1,6202,"Audio"
|
||||||
|
SINFO:0,1,2,5091,"Stereo"
|
||||||
|
SINFO:0,1,3,0,"deu"
|
||||||
|
SINFO:0,1,4,0,"German"
|
||||||
|
SINFO:0,1,5,0,"A_DTS"
|
||||||
|
SINFO:0,1,6,0,"DTS-HD MA"
|
||||||
|
SINFO:0,1,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:0,1,14,0,"2"
|
||||||
|
SINFO:0,1,17,0,"48000"
|
||||||
|
SINFO:0,1,18,0,"16"
|
||||||
|
SINFO:0,1,22,0,"1024"
|
||||||
|
SINFO:0,1,28,0,"eng"
|
||||||
|
SINFO:0,1,29,0,"English"
|
||||||
|
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
|
||||||
|
SINFO:0,1,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,1,33,0,"100"
|
||||||
|
SINFO:0,1,38,0,""
|
||||||
|
SINFO:0,1,40,0,"stereo"
|
||||||
|
SINFO:0,1,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,2,1,6202,"Audio"
|
||||||
|
SINFO:0,2,2,5091,"Stereo"
|
||||||
|
SINFO:0,2,3,0,"deu"
|
||||||
|
SINFO:0,2,4,0,"German"
|
||||||
|
SINFO:0,2,5,0,"A_DTS"
|
||||||
|
SINFO:0,2,6,0,"DTS"
|
||||||
|
SINFO:0,2,7,0,"DTS"
|
||||||
|
SINFO:0,2,13,0,"768 Kb/s"
|
||||||
|
SINFO:0,2,14,0,"2"
|
||||||
|
SINFO:0,2,17,0,"48000"
|
||||||
|
SINFO:0,2,22,0,"2304"
|
||||||
|
SINFO:0,2,28,0,"eng"
|
||||||
|
SINFO:0,2,29,0,"English"
|
||||||
|
SINFO:0,2,30,0,"DTS Stereo German"
|
||||||
|
SINFO:0,2,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,2,33,0,"100"
|
||||||
|
SINFO:0,2,38,0,"d"
|
||||||
|
SINFO:0,2,39,0,"Default"
|
||||||
|
SINFO:0,2,40,0,"stereo"
|
||||||
|
SINFO:0,2,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,3,1,6202,"Audio"
|
||||||
|
SINFO:0,3,2,5091,"Stereo"
|
||||||
|
SINFO:0,3,3,0,"deu"
|
||||||
|
SINFO:0,3,4,0,"German"
|
||||||
|
SINFO:0,3,5,0,"A_DTS"
|
||||||
|
SINFO:0,3,6,0,"DTS-HD MA"
|
||||||
|
SINFO:0,3,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:0,3,14,0,"2"
|
||||||
|
SINFO:0,3,17,0,"48000"
|
||||||
|
SINFO:0,3,18,0,"16"
|
||||||
|
SINFO:0,3,22,0,"1024"
|
||||||
|
SINFO:0,3,28,0,"eng"
|
||||||
|
SINFO:0,3,29,0,"English"
|
||||||
|
SINFO:0,3,30,0,"DTS-HD MA Stereo German"
|
||||||
|
SINFO:0,3,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,3,33,0,"100"
|
||||||
|
SINFO:0,3,38,0,""
|
||||||
|
SINFO:0,3,40,0,"stereo"
|
||||||
|
SINFO:0,3,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,4,1,6202,"Audio"
|
||||||
|
SINFO:0,4,2,5091,"Stereo"
|
||||||
|
SINFO:0,4,3,0,"deu"
|
||||||
|
SINFO:0,4,4,0,"German"
|
||||||
|
SINFO:0,4,5,0,"A_DTS"
|
||||||
|
SINFO:0,4,6,0,"DTS"
|
||||||
|
SINFO:0,4,7,0,"DTS"
|
||||||
|
SINFO:0,4,13,0,"768 Kb/s"
|
||||||
|
SINFO:0,4,14,0,"2"
|
||||||
|
SINFO:0,4,17,0,"48000"
|
||||||
|
SINFO:0,4,22,0,"2304"
|
||||||
|
SINFO:0,4,28,0,"eng"
|
||||||
|
SINFO:0,4,29,0,"English"
|
||||||
|
SINFO:0,4,30,0,"DTS Stereo German"
|
||||||
|
SINFO:0,4,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,4,33,0,"100"
|
||||||
|
SINFO:0,4,38,0,""
|
||||||
|
SINFO:0,4,40,0,"stereo"
|
||||||
|
SINFO:0,4,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,5,1,6203,"Untertitel"
|
||||||
|
SINFO:0,5,3,0,"deu"
|
||||||
|
SINFO:0,5,4,0,"German"
|
||||||
|
SINFO:0,5,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,5,6,0,"PGS"
|
||||||
|
SINFO:0,5,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,5,22,0,"0"
|
||||||
|
SINFO:0,5,28,0,"eng"
|
||||||
|
SINFO:0,5,29,0,"English"
|
||||||
|
SINFO:0,5,30,0,"PGS German"
|
||||||
|
SINFO:0,5,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,5,33,0,"100"
|
||||||
|
SINFO:0,5,38,0,""
|
||||||
|
SINFO:0,5,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,6,1,6203,"Untertitel"
|
||||||
|
SINFO:0,6,3,0,"deu"
|
||||||
|
SINFO:0,6,4,0,"German"
|
||||||
|
SINFO:0,6,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,6,6,0,"PGS"
|
||||||
|
SINFO:0,6,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,6,22,0,"6144"
|
||||||
|
SINFO:0,6,28,0,"eng"
|
||||||
|
SINFO:0,6,29,0,"English"
|
||||||
|
SINFO:0,6,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:0,6,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,6,33,0,"100"
|
||||||
|
SINFO:0,6,38,0,"d"
|
||||||
|
SINFO:0,6,39,0,"Default"
|
||||||
|
SINFO:0,6,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,7,1,6203,"Untertitel"
|
||||||
|
SINFO:0,7,3,0,"deu"
|
||||||
|
SINFO:0,7,4,0,"German"
|
||||||
|
SINFO:0,7,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,7,6,0,"PGS"
|
||||||
|
SINFO:0,7,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,7,22,0,"0"
|
||||||
|
SINFO:0,7,28,0,"eng"
|
||||||
|
SINFO:0,7,29,0,"English"
|
||||||
|
SINFO:0,7,30,0,"PGS German"
|
||||||
|
SINFO:0,7,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,7,33,0,"100"
|
||||||
|
SINFO:0,7,38,0,""
|
||||||
|
SINFO:0,7,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:0,8,1,6203,"Untertitel"
|
||||||
|
SINFO:0,8,3,0,"deu"
|
||||||
|
SINFO:0,8,4,0,"German"
|
||||||
|
SINFO:0,8,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:0,8,6,0,"PGS"
|
||||||
|
SINFO:0,8,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:0,8,22,0,"6144"
|
||||||
|
SINFO:0,8,28,0,"eng"
|
||||||
|
SINFO:0,8,29,0,"English"
|
||||||
|
SINFO:0,8,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:0,8,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:0,8,33,0,"100"
|
||||||
|
SINFO:0,8,38,0,""
|
||||||
|
SINFO:0,8,42,5088,"( Lossless conversion )"
|
||||||
|
TINFO:1,2,0,"Digimon Adventure: Last Evolution Kizuna"
|
||||||
|
TINFO:1,8,0,"13"
|
||||||
|
TINFO:1,9,0,"1:34:45"
|
||||||
|
TINFO:1,10,0,"16.4 GB"
|
||||||
|
TINFO:1,11,0,"17668571136"
|
||||||
|
TINFO:1,16,0,"00003.mpls"
|
||||||
|
TINFO:1,25,0,"1"
|
||||||
|
TINFO:1,26,0,"7"
|
||||||
|
TINFO:1,27,0,"Digimon Adventure- Last Evolution Kizuna_t01.mkv"
|
||||||
|
TINFO:1,28,0,"eng"
|
||||||
|
TINFO:1,29,0,"English"
|
||||||
|
TINFO:1,30,0,"Digimon Adventure: Last Evolution Kizuna - 13 Kapitel , 16.4 GB"
|
||||||
|
TINFO:1,31,6120,"<b>Titelinformationen</b><br>"
|
||||||
|
TINFO:1,33,0,"0"
|
||||||
|
SINFO:1,0,1,6201,"Video"
|
||||||
|
SINFO:1,0,5,0,"V_MPEG4/ISO/AVC"
|
||||||
|
SINFO:1,0,6,0,"Mpeg4"
|
||||||
|
SINFO:1,0,7,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:1,0,19,0,"1920x1080"
|
||||||
|
SINFO:1,0,20,0,"16:9"
|
||||||
|
SINFO:1,0,21,0,"23.976 (24000/1001)"
|
||||||
|
SINFO:1,0,22,0,"0"
|
||||||
|
SINFO:1,0,28,0,"eng"
|
||||||
|
SINFO:1,0,29,0,"English"
|
||||||
|
SINFO:1,0,30,0,"Mpeg4 AVC High@L4.1"
|
||||||
|
SINFO:1,0,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,0,33,0,"0"
|
||||||
|
SINFO:1,0,38,0,""
|
||||||
|
SINFO:1,0,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,1,1,6202,"Audio"
|
||||||
|
SINFO:1,1,2,0,"Surround 5.1"
|
||||||
|
SINFO:1,1,3,0,"deu"
|
||||||
|
SINFO:1,1,4,0,"German"
|
||||||
|
SINFO:1,1,5,0,"A_DTS"
|
||||||
|
SINFO:1,1,6,0,"DTS-HD MA"
|
||||||
|
SINFO:1,1,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:1,1,14,0,"6"
|
||||||
|
SINFO:1,1,17,0,"48000"
|
||||||
|
SINFO:1,1,18,0,"16"
|
||||||
|
SINFO:1,1,22,0,"1024"
|
||||||
|
SINFO:1,1,28,0,"eng"
|
||||||
|
SINFO:1,1,29,0,"English"
|
||||||
|
SINFO:1,1,30,0,"DTS-HD MA Surround 5.1 German"
|
||||||
|
SINFO:1,1,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,1,33,0,"100"
|
||||||
|
SINFO:1,1,38,0,""
|
||||||
|
SINFO:1,1,40,0,"5.1(side)"
|
||||||
|
SINFO:1,1,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,2,1,6202,"Audio"
|
||||||
|
SINFO:1,2,2,0,"Surround 5.1"
|
||||||
|
SINFO:1,2,3,0,"deu"
|
||||||
|
SINFO:1,2,4,0,"German"
|
||||||
|
SINFO:1,2,5,0,"A_DTS"
|
||||||
|
SINFO:1,2,6,0,"DTS"
|
||||||
|
SINFO:1,2,7,0,"DTS"
|
||||||
|
SINFO:1,2,13,0,"1.5 Mb/s"
|
||||||
|
SINFO:1,2,14,0,"6"
|
||||||
|
SINFO:1,2,17,0,"48000"
|
||||||
|
SINFO:1,2,22,0,"2304"
|
||||||
|
SINFO:1,2,28,0,"eng"
|
||||||
|
SINFO:1,2,29,0,"English"
|
||||||
|
SINFO:1,2,30,0,"DTS Surround 5.1 German"
|
||||||
|
SINFO:1,2,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,2,33,0,"100"
|
||||||
|
SINFO:1,2,38,0,"d"
|
||||||
|
SINFO:1,2,39,0,"Default"
|
||||||
|
SINFO:1,2,40,0,"5.1(side)"
|
||||||
|
SINFO:1,2,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,3,1,6202,"Audio"
|
||||||
|
SINFO:1,3,2,5091,"Stereo"
|
||||||
|
SINFO:1,3,3,0,"jpn"
|
||||||
|
SINFO:1,3,4,0,"Japanese"
|
||||||
|
SINFO:1,3,5,0,"A_DTS"
|
||||||
|
SINFO:1,3,6,0,"DTS-HD MA"
|
||||||
|
SINFO:1,3,7,0,"DTS-HD Master Audio"
|
||||||
|
SINFO:1,3,14,0,"2"
|
||||||
|
SINFO:1,3,17,0,"48000"
|
||||||
|
SINFO:1,3,18,0,"16"
|
||||||
|
SINFO:1,3,22,0,"1024"
|
||||||
|
SINFO:1,3,28,0,"eng"
|
||||||
|
SINFO:1,3,29,0,"English"
|
||||||
|
SINFO:1,3,30,0,"DTS-HD MA Stereo Japanese"
|
||||||
|
SINFO:1,3,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,3,33,0,"100"
|
||||||
|
SINFO:1,3,38,0,""
|
||||||
|
SINFO:1,3,40,0,"stereo"
|
||||||
|
SINFO:1,3,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,4,1,6202,"Audio"
|
||||||
|
SINFO:1,4,2,5091,"Stereo"
|
||||||
|
SINFO:1,4,3,0,"jpn"
|
||||||
|
SINFO:1,4,4,0,"Japanese"
|
||||||
|
SINFO:1,4,5,0,"A_DTS"
|
||||||
|
SINFO:1,4,6,0,"DTS"
|
||||||
|
SINFO:1,4,7,0,"DTS"
|
||||||
|
SINFO:1,4,13,0,"768 Kb/s"
|
||||||
|
SINFO:1,4,14,0,"2"
|
||||||
|
SINFO:1,4,17,0,"48000"
|
||||||
|
SINFO:1,4,22,0,"2304"
|
||||||
|
SINFO:1,4,28,0,"eng"
|
||||||
|
SINFO:1,4,29,0,"English"
|
||||||
|
SINFO:1,4,30,0,"DTS Stereo Japanese"
|
||||||
|
SINFO:1,4,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,4,33,0,"100"
|
||||||
|
SINFO:1,4,38,0,""
|
||||||
|
SINFO:1,4,40,0,"stereo"
|
||||||
|
SINFO:1,4,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,5,1,6203,"Untertitel"
|
||||||
|
SINFO:1,5,3,0,"deu"
|
||||||
|
SINFO:1,5,4,0,"German"
|
||||||
|
SINFO:1,5,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,5,6,0,"PGS"
|
||||||
|
SINFO:1,5,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,5,22,0,"0"
|
||||||
|
SINFO:1,5,28,0,"eng"
|
||||||
|
SINFO:1,5,29,0,"English"
|
||||||
|
SINFO:1,5,30,0,"PGS German"
|
||||||
|
SINFO:1,5,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,5,33,0,"100"
|
||||||
|
SINFO:1,5,38,0,""
|
||||||
|
SINFO:1,5,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,6,1,6203,"Untertitel"
|
||||||
|
SINFO:1,6,3,0,"deu"
|
||||||
|
SINFO:1,6,4,0,"German"
|
||||||
|
SINFO:1,6,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,6,6,0,"PGS"
|
||||||
|
SINFO:1,6,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,6,22,0,"6144"
|
||||||
|
SINFO:1,6,28,0,"eng"
|
||||||
|
SINFO:1,6,29,0,"English"
|
||||||
|
SINFO:1,6,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:1,6,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,6,33,0,"100"
|
||||||
|
SINFO:1,6,38,0,"d"
|
||||||
|
SINFO:1,6,39,0,"Default"
|
||||||
|
SINFO:1,6,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,7,1,6203,"Untertitel"
|
||||||
|
SINFO:1,7,3,0,"deu"
|
||||||
|
SINFO:1,7,4,0,"German"
|
||||||
|
SINFO:1,7,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,7,6,0,"PGS"
|
||||||
|
SINFO:1,7,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,7,22,0,"0"
|
||||||
|
SINFO:1,7,28,0,"eng"
|
||||||
|
SINFO:1,7,29,0,"English"
|
||||||
|
SINFO:1,7,30,0,"PGS German"
|
||||||
|
SINFO:1,7,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,7,33,0,"100"
|
||||||
|
SINFO:1,7,38,0,""
|
||||||
|
SINFO:1,7,42,5088,"( Lossless conversion )"
|
||||||
|
SINFO:1,8,1,6203,"Untertitel"
|
||||||
|
SINFO:1,8,3,0,"deu"
|
||||||
|
SINFO:1,8,4,0,"German"
|
||||||
|
SINFO:1,8,5,0,"S_HDMV/PGS"
|
||||||
|
SINFO:1,8,6,0,"PGS"
|
||||||
|
SINFO:1,8,7,0,"HDMV PGS Subtitles"
|
||||||
|
SINFO:1,8,22,0,"6144"
|
||||||
|
SINFO:1,8,28,0,"eng"
|
||||||
|
SINFO:1,8,29,0,"English"
|
||||||
|
SINFO:1,8,30,0,"PGS German (nur erzwungene)"
|
||||||
|
SINFO:1,8,31,6121,"<b>Spurinformationen</b><br>"
|
||||||
|
SINFO:1,8,33,0,"100"
|
||||||
|
SINFO:1,8,38,0,""
|
||||||
|
SINFO:1,8,42,5088,"( Lossless conversion )"
|
||||||
@@ -124,16 +124,26 @@ await schritt('Titel-Lauf liefert drei Titel', async () => {
|
|||||||
await dialog().getByText('Titel 2', { exact: true }).waitFor()
|
await dialog().getByText('Titel 2', { exact: true }).waitFor()
|
||||||
})
|
})
|
||||||
|
|
||||||
await schritt('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => {
|
await schritt('5.7.0: Spuren je Titel einzeln — Codec und Kanäle, der DTS-Kern bleibt unsichtbar', async () => {
|
||||||
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
|
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().waitFor()
|
||||||
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
|
await dialog().getByLabel('German · DD · Surround 5.1').first().waitFor()
|
||||||
|
await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().waitFor()
|
||||||
|
await dialog().getByLabel('German · PGS (nur erzwungene)').first().waitFor()
|
||||||
|
if ((await dialog().getByLabel('Japanese · DTS · Surround 5.1').count()) !== 0) throw new Error('der abgeleitete DTS-Kern wird angeboten')
|
||||||
|
// Vorbelegt: je Wunschsprache (deu,eng) die erste Tonspur → nur German DD 5.1;
|
||||||
|
// Untertitel: alle deutschen (auch „nur erzwungene"), englisch nicht.
|
||||||
|
if (!(await dialog().getByLabel('German · DD · Surround 5.1').first().isChecked())) throw new Error('German 5.1 nicht vorbelegt')
|
||||||
|
if (await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().isChecked()) throw new Error('Kommentar-Spur vorbelegt')
|
||||||
|
if (await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().isChecked()) throw new Error('Japanisch vorbelegt, obwohl Deutsch da ist')
|
||||||
|
if (!(await dialog().getByLabel('German · PGS (nur erzwungene)').first().isChecked())) throw new Error('erzwungene Untertitel nicht vorbelegt')
|
||||||
|
if (await dialog().getByLabel('English · PGS').first().isChecked()) throw new Error('englische Untertitel vorbelegt')
|
||||||
})
|
})
|
||||||
|
|
||||||
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
|
await schritt('Titel 0 bekommt japanischen Ton (German 5.1 abhaken, Japanese anhaken)', async () => {
|
||||||
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
|
await dialog().getByLabel('German · DD · Surround 5.1').first().uncheck()
|
||||||
await chips.first().click()
|
await dialog().getByText('keine Tonspur gewählt — HandBrake nimmt die erste Spur').waitFor()
|
||||||
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
|
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().check()
|
||||||
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
|
if ((await dialog().getByText('keine Tonspur gewählt').count()) !== 0) throw new Error('Warnung bleibt trotz japanischer Spur')
|
||||||
})
|
})
|
||||||
|
|
||||||
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
|
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
|
||||||
@@ -153,7 +163,9 @@ await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Fi
|
|||||||
await rollen.nth(1).selectOption('extra')
|
await rollen.nth(1).selectOption('extra')
|
||||||
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
|
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
|
||||||
const vorwahl = dialog().locator('select').nth(2)
|
const vorwahl = dialog().locator('select').nth(2)
|
||||||
await vorwahl.selectOption('deu')
|
// 5.7.0: Rippys Regel nennt die Spur (japanischer Ton → deutsche
|
||||||
|
// Untertitel), die Optionen sind Spuren — gewählt wird per Nummer (5).
|
||||||
|
await vorwahl.selectOption('5')
|
||||||
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
|
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
|
||||||
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
|
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
|
||||||
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
|
||||||
@@ -279,22 +291,32 @@ await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Kar
|
|||||||
})
|
})
|
||||||
await bild('einstellungen')
|
await bild('einstellungen')
|
||||||
|
|
||||||
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
|
await schritt('Roh-Dateien löschen → Rückfrage → „Ja, löschen" → „gelöscht"', async () => {
|
||||||
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
|
await seite.getByRole('button', { name: 'Roh-Dateien löschen …' }).first().click()
|
||||||
|
await seite.getByRole('button', { name: 'Ja, löschen' }).first().click()
|
||||||
await seite.getByText(/Roh: gelöscht/).first().waitFor()
|
await seite.getByText(/Roh: gelöscht/).first().waitFor()
|
||||||
})
|
})
|
||||||
|
|
||||||
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
|
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
|
||||||
await seite.locator('#allgemein').locator('select').first().selectOption('hell')
|
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('hell')
|
||||||
await seite.locator('html[data-design="hell"]').waitFor()
|
await seite.locator('html[data-design="hell"]').waitFor()
|
||||||
})
|
})
|
||||||
await bild('hell-modus')
|
await bild('hell-modus')
|
||||||
|
|
||||||
await schritt('zurück in den Kinosaal (dunkel)', async () => {
|
await schritt('zurück in den Kinosaal (dunkel)', async () => {
|
||||||
await seite.locator('#allgemein').locator('select').first().selectOption('dunkel')
|
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('dunkel')
|
||||||
await seite.locator('html[data-design="dunkel"]').waitFor()
|
await seite.locator('html[data-design="dunkel"]').waitFor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await schritt('5.7.0: „Titel lesen — ohne Lebenszeichen höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
|
||||||
|
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — ohne Lebenszeichen höchstens')
|
||||||
|
if ((await regler.inputValue()) !== '15') throw new Error(`Vorgabe ist ${await regler.inputValue()}, nicht 15`)
|
||||||
|
await regler.selectOption('30')
|
||||||
|
// Der Wert geht zum Kern und kommt als Einstellung zurück — erst dann
|
||||||
|
// zeigt das (kontrollierte) Feld 30.
|
||||||
|
await regler.locator('option[value="30"]:checked').waitFor({ state: 'attached' })
|
||||||
|
})
|
||||||
|
|
||||||
await schritt('Ereignisse nennen die Kompression', async () => {
|
await schritt('Ereignisse nennen die Kompression', async () => {
|
||||||
await seite.getByRole('button', { name: 'Übersicht' }).click()
|
await seite.getByRole('button', { name: 'Übersicht' }).click()
|
||||||
await seite.getByText(/Kompression „/).first().waitFor()
|
await seite.getByText(/Kompression „/).first().waitFor()
|
||||||
@@ -357,6 +379,6 @@ await schritt('Fertig: der Knopf Los geht’s', async () => {
|
|||||||
})
|
})
|
||||||
await bild('einrichtung-fertig')
|
await bild('einrichtung-fertig')
|
||||||
|
|
||||||
console.log('KLICK: OK — Speicher, Rippen, Dialog, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
|
console.log('KLICK: OK — Speicher, Rippen, Dialog, Spuren einzeln, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
|
||||||
await app.close().catch(() => {})
|
await app.close().catch(() => {})
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@@ -47,16 +47,21 @@ if (args.includes('--scan')) {
|
|||||||
out('"MainFeature": 0,')
|
out('"MainFeature": 0,')
|
||||||
out('"TitleList": [')
|
out('"TitleList": [')
|
||||||
out('{')
|
out('{')
|
||||||
|
// 5.7.0: CodecName und ChannelCount wie das echte 1.11.2 (gemessen
|
||||||
|
// 12.09.2026) — dieselben Spuren, die fake-makemkvcon meldet, ohne den
|
||||||
|
// DTS-Kern (den nimmt MakeMKVs Regel nicht mit).
|
||||||
out('"AudioList": [')
|
out('"AudioList": [')
|
||||||
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
|
||||||
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
|
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "ac3", "Description": "Deutsch (AC3, 5.1 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },')
|
||||||
|
out('{ "ChannelCount": 2, "ChannelLayout": 3, "CodecName": "ac3", "Description": "Deutsch (AC3, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 3 }')
|
||||||
out('],')
|
out('],')
|
||||||
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
|
||||||
out('"Index": 1,')
|
out('"Index": 1,')
|
||||||
out(`"Name": ${JSON.stringify(eingabe)},`)
|
out(`"Name": ${JSON.stringify(eingabe)},`)
|
||||||
out('"SubtitleList": [')
|
out('"SubtitleList": [')
|
||||||
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
|
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },')
|
||||||
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
|
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 2 },')
|
||||||
|
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "Source": 5, "SourceName": "PGS", "TrackNumber": 3 }')
|
||||||
out(']')
|
out(']')
|
||||||
out('}')
|
out('}')
|
||||||
out(']')
|
out(']')
|
||||||
|
|||||||
@@ -31,18 +31,27 @@ if (befehl === 'info') {
|
|||||||
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
|
||||||
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
out(`SINFO:${t.nr},0,1,6201,"Video"`)
|
||||||
if (t.nr < 2) {
|
if (t.nr < 2) {
|
||||||
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
|
// 5.7.0: jede Spur mit Codec (6), Kanälen (14), Anordnung (2), Flags
|
||||||
out(`SINFO:${t.nr},1,3,0,"jpn"`)
|
// (22) und Beschreibung (30), wie an der Kizuna-Disc gemessen — samt
|
||||||
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
|
// dem abgeleiteten DTS-Kern (Flag 0x800), den der Dialog NICHT zeigt,
|
||||||
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
|
// und der „nur erzwungene"-Fassung eines Untertitels, die er zeigt.
|
||||||
out(`SINFO:${t.nr},2,3,0,"deu"`)
|
const spur = (nr, code, typ, lang, name, codec, kanaele, layout, flags, text) => {
|
||||||
out(`SINFO:${t.nr},2,4,0,"German"`)
|
out(`SINFO:${t.nr},${nr},1,${code},"${typ}"`)
|
||||||
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
|
if (layout) out(`SINFO:${t.nr},${nr},2,0,"${layout}"`)
|
||||||
out(`SINFO:${t.nr},3,3,0,"deu"`)
|
out(`SINFO:${t.nr},${nr},3,0,"${lang}"`)
|
||||||
out(`SINFO:${t.nr},3,4,0,"German"`)
|
out(`SINFO:${t.nr},${nr},4,0,"${name}"`)
|
||||||
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
|
out(`SINFO:${t.nr},${nr},6,0,"${codec}"`)
|
||||||
out(`SINFO:${t.nr},4,3,0,"eng"`)
|
if (kanaele) out(`SINFO:${t.nr},${nr},14,0,"${kanaele}"`)
|
||||||
out(`SINFO:${t.nr},4,4,0,"English"`)
|
out(`SINFO:${t.nr},${nr},22,0,"${flags}"`)
|
||||||
|
out(`SINFO:${t.nr},${nr},30,0,"${text}"`)
|
||||||
|
}
|
||||||
|
spur(1, 6202, 'Audio', 'jpn', 'Japanese', 'DTS-HD MA', '6', 'Surround 5.1', 1024, 'DTS-HD MA Surround 5.1 Japanese')
|
||||||
|
spur(2, 6202, 'Audio', 'jpn', 'Japanese', 'DTS', '6', 'Surround 5.1', 2304, 'DTS Surround 5.1 Japanese')
|
||||||
|
spur(3, 6202, 'Audio', 'deu', 'German', 'DD', '6', 'Surround 5.1', 0, 'DD Surround 5.1 German')
|
||||||
|
spur(4, 6202, 'Audio', 'deu', 'German', 'DD', '2', 'Stereo', 0, 'DD Stereo German (Kommentar)')
|
||||||
|
spur(5, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 0, 'PGS German')
|
||||||
|
spur(6, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 6144, 'PGS German (nur erzwungene)')
|
||||||
|
spur(7, 6203, 'Untertitel', 'eng', 'English', 'PGS', '', '', 0, 'PGS English')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@@ -1,3 +1,113 @@
|
|||||||
|
Version 5.7.1
|
||||||
|
|
||||||
|
Titel lesen: Rippy wartet, solange MakeMKV arbeitet.
|
||||||
|
"Der Patriot" (DVD mit beschaedigter IFO-Datei) scheiterte in 5.7.0 nach
|
||||||
|
15 Minuten, waehrend das MakeMKV-Programm die Disc fertig las: MakeMKV
|
||||||
|
liest bei so einer Disc die ganze VOB-Datei durch, und das dauert so lange
|
||||||
|
wie das Lesen der Disc. Die Grenze aus den Einstellungen gilt jetzt fuer
|
||||||
|
Stille - solange MakeMKV Fortschritt oder Meldungen schickt, wartet Rippy
|
||||||
|
(kuendigt MakeMKV den VOB-Scan an, bis zu vier Stunden). Der Dialog zeigt
|
||||||
|
dabei, was MakeMKV gerade tut: Schritt, Prozent und letzte Meldung.
|
||||||
|
"Abbrechen" beendet den Lauf jederzeit. Bricht Rippy doch ab, stehen die
|
||||||
|
letzten Zeilen von MakeMKV im Protokoll.
|
||||||
|
|
||||||
|
Version 5.7.0
|
||||||
|
|
||||||
|
Der Beta-Schluessel kommt ganz an.
|
||||||
|
Der aktuelle MakeMKV-Beta-Schluessel enthaelt ein "@" - Rippy nahm nur
|
||||||
|
62 der 68 Zeichen, MakeMKV lief danach ohne Schluessel, und Blu-ray-Rips
|
||||||
|
scheiterten nach der Gnadenfrist. Das ist die wahrscheinlichste Ursache
|
||||||
|
fuer "Der Patriot", "Deja Vu" und "Training Day" auf dem anderen PC. Die
|
||||||
|
Schluessel-Karte sagt jetzt ehrlich, was Sache ist: gruen (angenommen),
|
||||||
|
grau (nicht pruefbar - ohne eingelegte Disc gibt MakeMKV keine Auskunft),
|
||||||
|
rot (abgelehnt - Blu-rays laufen dann nur noch in der Gnadenfrist). Ein
|
||||||
|
vorhandener, angenommener Schluessel wird nie ueberschrieben.
|
||||||
|
|
||||||
|
Jede Spur einzeln anhaken.
|
||||||
|
Der Auswahl-Dialog zeigt je Titel jede Tonspur mit Codec und Kanaelen
|
||||||
|
("German - DTS-HD MA - Surround 5.1", "German - DD - Stereo (Kommentar)")
|
||||||
|
und jede Untertitel-Spur ("German - PGS", "German - PGS (nur
|
||||||
|
erzwungene)"). Vorbelegt wie bisher - je Wunschsprache die erste Tonspur,
|
||||||
|
alle Untertitel der Wunschsprachen - aber angehakt wird einzeln. Die
|
||||||
|
Untertitel-Vorwahl bei Extras ist jetzt eine Spur. MakeMKV nimmt dafuer
|
||||||
|
auch Stereo-Fassungen und Kommentare in die Roh-Datei (nur die
|
||||||
|
abgeleiteten DTS-Kerne nicht). Was in der Roh-Datei fehlt, steht im
|
||||||
|
Hinweis.
|
||||||
|
|
||||||
|
Ton bleibt verlustfrei.
|
||||||
|
HandBrake hat seit 5.0 jede Tonspur in AAC umgerechnet - die Kopiermaske
|
||||||
|
war nicht ausdruecklich gesetzt, und die Kommandozeile verengt sie dann
|
||||||
|
auf AAC. Jetzt werden AC3, E-AC3, DTS, DTS-HD, TrueHD, FLAC, MP3 und AAC
|
||||||
|
durchgereicht. Filme, bei denen der HD-Ton wichtig ist und deren
|
||||||
|
Roh-Dateien schon weg sind, noch einmal rippen.
|
||||||
|
|
||||||
|
Titel lesen bis 15 Minuten - einstellbar.
|
||||||
|
Einstellungen -> Allgemein -> "Titel lesen - hoechstens": 5 bis 60
|
||||||
|
Minuten, Vorgabe 15 (vorher fest 5). Ab der zweiten Minute steht im
|
||||||
|
Dialog, wie lange MakeMKV schon liest; "Abbrechen" beendet den Lauf auch
|
||||||
|
im Kern, das Laufwerk wird frei. Laeuft die Zeit ab, nennt die Meldung
|
||||||
|
Kopierschutz und viele Playlisten als moegliche Gruende.
|
||||||
|
|
||||||
|
4K-UHD nach der Leistung des PCs.
|
||||||
|
Ist eine Grafikkarte mit 4K-Preset da (VCN, NVENC, QSV), wird 4K damit
|
||||||
|
komprimiert (8 Bit); nur mit CPU bleibt der Rip verlustfrei, statt
|
||||||
|
tagelang auf 1080p zu rechnen. Eigene Wahl schlaegt beides. AV1 10 Bit
|
||||||
|
steht als eigene Wahl in der Liste (nur fuer AV1-faehige Abspielgeraete).
|
||||||
|
|
||||||
|
Disc-Typ aus dem Dateisystem.
|
||||||
|
BDMV/index.bdmv mit "INDX0300" ist eine 4K-UHD, "0200"/"0100" eine
|
||||||
|
Blu-ray, VIDEO_TS eine DVD - auch wenn der Windows-Steuercode "unbekannt"
|
||||||
|
sagt. Vorher wurde eine 4K-Disc mit 48 GB als Blu-ray behandelt
|
||||||
|
(falsches Preset) und "unbekannt" direkt zum Rettungs-Abbild.
|
||||||
|
|
||||||
|
Kein stilles Scheitern mehr.
|
||||||
|
Der Rip-Start prueft, ob die Ablage schreibbar ist, und sagt es im
|
||||||
|
Fenster, wenn nicht ("NAS verbunden?"); Fehler im Kern landen im
|
||||||
|
Protokoll und im Fenster; nach einem Kern-Neustart bleibt das Fenster
|
||||||
|
nicht mehr taub; jedes Laufwerk hat genau eine Wache; das Rettungs-Abbild
|
||||||
|
ueberlebt das Aufraeumen; ein Titel-Auftrag bei Typ "unbekannt" rippt
|
||||||
|
statt ein ISO zu sichern.
|
||||||
|
|
||||||
|
Serien ueber mehrere Discs.
|
||||||
|
Disc 2 benennt nur ihre eigenen Folgen (S01E03, S01E04) und ueberschreibt
|
||||||
|
nie, was Disc 1 abgelegt hat; eine vorhandene Datei gleichen Namens
|
||||||
|
bekommt "(2)". HandBrake schreibt erst auf einen Zwischennamen - eine
|
||||||
|
halbe Datei liegt nie unter dem endgueltigen Namen in der Ablage.
|
||||||
|
|
||||||
|
Erkennung: Originaltitel und Akzente.
|
||||||
|
"THE_PATRIOT" trifft "Der Patriot", "DEJA_VU" trifft "Deja Vu" - TMDb
|
||||||
|
antwortet auf Deutsch, die Disc traegt den Originaltitel; verglichen
|
||||||
|
werden jetzt beide, Akzente zaehlen nicht.
|
||||||
|
|
||||||
|
MakeMKV-Installer nur nach Rueckfrage.
|
||||||
|
Ist MakeMKV nicht da und die Herstellerseite nicht erreichbar, fragt
|
||||||
|
Rippy, bevor eine Kopie aus dem Internet Archive geladen wird - mit
|
||||||
|
Groesse und SHA-256 im Bericht.
|
||||||
|
|
||||||
|
Dazu: Tray "Beenden" fragt nach, wenn ein Rip oder eine Kompression
|
||||||
|
laeuft; "Roh-Dateien loeschen" und "Ordner loeschen" fragen im Fenster
|
||||||
|
nach; Eingabefelder speichern auch bei Enter und beim Bereichswechsel;
|
||||||
|
Escape schliesst Dialoge; "Disc erkannt" einmal je Disc statt dreimal;
|
||||||
|
Laufwerks-Gesundheit zaehlt nur das eigene Laufwerk, nie eine Festplatte;
|
||||||
|
der Kompressions-Fortschritt belastet NAS und Fenster nicht mehr jede
|
||||||
|
Sekunde; Zeiten in Ortszeit; NFO mit TMDb/IMDb-Kennung; doppelter
|
||||||
|
Zielordner heisst "(2)"; Umlaute in drei Knopftexten; "Neu komprimieren"
|
||||||
|
nur, wo es Roh-Dateien gibt; die Automatik sagt bei Audio-CD und
|
||||||
|
unbekannter Disc, warum sie nichts tut; Audio-CDs lassen sich mitten in
|
||||||
|
der Spur abbrechen.
|
||||||
|
|
||||||
|
Version 5.6.1
|
||||||
|
|
||||||
|
Untertitel-Spuren werden wieder erkannt.
|
||||||
|
MakeMKV schreibt den Typ eines Streams in seiner Anzeigesprache - auf einem
|
||||||
|
deutsch eingestellten MakeMKV "Untertitel" statt "Subtitles". Rippy prueft
|
||||||
|
seit 5.4.0 auf das englische Wort und fand deshalb keine Untertitel mehr
|
||||||
|
(Ton heisst in beiden Sprachen "Audio", darum fiel es dort nicht auf).
|
||||||
|
Jetzt zaehlt der Typ-Code (6202 Ton, 6203 Untertitel), der ist
|
||||||
|
sprachunabhaengig. Gemessen an der Blu-ray "Digimon Adventure: Last
|
||||||
|
Evolution Kizuna": drei deutsche PGS-Spuren, darunter eine nur fuer
|
||||||
|
erzwungene Untertitel.
|
||||||
|
|
||||||
Version 5.6.0
|
Version 5.6.0
|
||||||
|
|
||||||
Mehrere Filme auf einer Disc.
|
Mehrere Filme auf einer Disc.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"version": "5.6.0",
|
"version": "5.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"version": "5.6.0",
|
"version": "5.7.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"productName": "Rippy",
|
"productName": "Rippy",
|
||||||
"version": "5.6.0",
|
"version": "5.7.1",
|
||||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
"main": "out/haupt/index.js",
|
"main": "out/haupt/index.js",
|
||||||
"author": "KrBrZ",
|
"author": "KrBrZ",
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ export default function App() {
|
|||||||
const w = verbindung.werkzeuge
|
const w = verbindung.werkzeuge
|
||||||
const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot'
|
const werkzeugTon = w === null ? 'grau' : w.makemkvcon.length > 0 && w.handbrake.length > 0 ? 'gruen' : w.makemkvcon.length > 0 ? 'warn' : 'rot'
|
||||||
const s = verbindung.schluessel
|
const s = verbindung.schluessel
|
||||||
const schluesselTon = s === null ? 'grau' : s.angenommen === true ? 'gruen' : s.weiterBetrieb ? 'warn' : 'rot'
|
// 5.7.0 (F1/F5): abgelehnt oder fehlend = ROT (Blu-rays laufen nicht),
|
||||||
|
// nicht prüfbar (kein Laufwerk) = grau — nie mehr ein gelbes „läuft".
|
||||||
|
const schluesselTon = s === null ? 'grau' : s.angenommen === true ? 'gruen' : s.angenommen === null ? 'grau' : 'rot'
|
||||||
const arbeit = verbindung.speicher?.arbeit ?? null
|
const arbeit = verbindung.speicher?.arbeit ?? null
|
||||||
const speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen'
|
const speicherTon = arbeit === null ? 'grau' : !arbeit.ok ? 'rot' : arbeit.freiBytes < SEHR_WENIG_FREI_BYTES ? 'rot' : arbeit.freiBytes < WENIG_FREI_BYTES ? 'warn' : 'gruen'
|
||||||
const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau'
|
const updateTon = haupt === null ? 'grau' : haupt.update.neueVersion.length > 0 ? 'warn' : haupt.update.letzteErfolgreichePruefung.length > 0 ? 'gruen' : 'grau'
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ export function dauerGrob(s: number): string {
|
|||||||
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
|
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ISO-Zeit → „2026-09-12 21:30" in DEINER Uhrzeit (5.7.0, Durchsicht F12):
|
||||||
|
* vorher wurde der UTC-Text abgeschnitten — 21:30 Uhr stand als 19:30 da. */
|
||||||
export function datumKurz(iso: string | null): string {
|
export function datumKurz(iso: string | null): string {
|
||||||
if (iso === null || iso.length < 16) return '—'
|
if (iso === null || iso.length < 16) return '—'
|
||||||
return iso.slice(0, 16).replace('T', ' ')
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso.slice(0, 16).replace('T', ' ')
|
||||||
|
const z = (n: number): string => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restzeit in Sekunden als Klartext (5.6.0, HandBrakes ETA): -1/fehlend =
|
/** Restzeit in Sekunden als Klartext (5.6.0, HandBrakes ETA): -1/fehlend =
|
||||||
@@ -370,6 +375,23 @@ export function Feld({
|
|||||||
}) {
|
}) {
|
||||||
const [entwurf, setEntwurf] = useState(wert)
|
const [entwurf, setEntwurf] = useState(wert)
|
||||||
useEffect(() => setEntwurf(wert), [wert])
|
useEffect(() => setEntwurf(wert), [wert])
|
||||||
|
// 5.7.0 (Durchsicht F18): Gespeichert wird beim Verlassen, bei Enter UND
|
||||||
|
// wenn das Feld verschwindet (Bereichswechsel, Dialog zu) — vorher ging
|
||||||
|
// Tipparbeit verloren, wenn man statt zu klicken Enter drückte oder in
|
||||||
|
// einen anderen Bereich wechselte. Die Refs tragen den jüngsten Stand in
|
||||||
|
// die Aufräum-Funktion des Effekts.
|
||||||
|
const juengster = useRef({ entwurf, wert, aufAendern })
|
||||||
|
juengster.current = { entwurf, wert, aufAendern }
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
const j = juengster.current
|
||||||
|
if (j.entwurf !== j.wert) j.aufAendern(j.entwurf)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const speichern = (): void => {
|
||||||
|
if (entwurf !== wert) aufAendern(entwurf)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs font-semibold text-dunst">{name}</span>
|
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||||
@@ -379,8 +401,9 @@ export function Feld({
|
|||||||
value={entwurf}
|
value={entwurf}
|
||||||
placeholder={platzhalter}
|
placeholder={platzhalter}
|
||||||
onChange={(e) => setEntwurf(e.target.value)}
|
onChange={(e) => setEntwurf(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={speichern}
|
||||||
if (entwurf !== wert) aufAendern(entwurf)
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') speichern()
|
||||||
}}
|
}}
|
||||||
className={FELD_KLASSE}
|
className={FELD_KLASSE}
|
||||||
/>
|
/>
|
||||||
@@ -460,10 +483,28 @@ export function DialogRahmen({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const hintergrund = useHintergrundKlick(schliessen)
|
const hintergrund = useHintergrundKlick(schliessen)
|
||||||
|
// 5.7.0 (Durchsicht F19): Escape schließt, der Dialog ist als solcher
|
||||||
|
// ausgezeichnet (role/aria-modal) und bekommt den Fokus — ohne das blieb
|
||||||
|
// die Tastatur auf der Seite dahinter, und Escape tat nichts.
|
||||||
|
const karte = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const taste = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') schliessen()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', taste)
|
||||||
|
// Fokus in den Dialog — aber nicht aus einem Eingabefeld heraus, das
|
||||||
|
// ein Kind schon per autoFocus genommen hat.
|
||||||
|
if (document.activeElement === null || document.activeElement === document.body) karte.current?.focus()
|
||||||
|
return () => window.removeEventListener('keydown', taste)
|
||||||
|
}, [schliessen])
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm" {...hintergrund}>
|
||||||
<div
|
<div
|
||||||
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60`}
|
ref={karte}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`max-h-[85vh] w-full ${breite} overflow-y-auto rounded-2xl border border-tinte/12 bg-buehne p-5 shadow-2xl shadow-black/60 focus:outline-none`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
|
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
|
||||||
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
|
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
|
||||||
// eigenen Roh-Ordner, Vorgang und Zielordner.
|
// eigenen Roh-Ordner, Vorgang und Zielordner.
|
||||||
|
// 5.7.0 (Commander, 12.09.2026): JEDE Spur einzeln anhaken — „Deutsch ·
|
||||||
|
// DTS-HD MA · 5.1" und „Deutsch · DD · 2.0 (Kommentar)" getrennt, Untertitel
|
||||||
|
// genauso. Die Sprach-Chips bleiben nur als Rückfall, wenn ein Titel keine
|
||||||
|
// einzelnen Spuren meldet.
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
|
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
|
||||||
import { platzUrteil } from '../../gemeinsam/platz'
|
import { platzUrteil } from '../../gemeinsam/platz'
|
||||||
@@ -19,11 +23,13 @@ import type {
|
|||||||
MetaVorschlag,
|
MetaVorschlag,
|
||||||
SpeicherStand,
|
SpeicherStand,
|
||||||
SprachEintrag,
|
SprachEintrag,
|
||||||
|
SpurAngabe,
|
||||||
TitelRolle,
|
TitelRolle,
|
||||||
TitelStand,
|
TitelStand,
|
||||||
} from '../../gemeinsam/nachrichten'
|
} from '../../gemeinsam/nachrichten'
|
||||||
import { DialogRahmen, IconDisc, KNOPF_GOLD, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
|
import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren'
|
||||||
import { metadatenSuchen, ripStarten } from '../kernverbindung'
|
import { DialogRahmen, IconDisc, KNOPF_GOLD, datumKurz, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
|
||||||
|
import { metadatenSuchen, ripStarten, titelAbbrechen } from '../kernverbindung'
|
||||||
|
|
||||||
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
|
/** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels
|
||||||
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
|
* (ein Knopf IM Label würde die Checkbox mit umschalten). */
|
||||||
@@ -64,6 +70,53 @@ function SprachChips({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Spur-Zeilen eines Titels (5.7.0): jede Ton- bzw. Untertitelspur einzeln
|
||||||
|
* anhaken, mit Codec und Kanälen, wie MakeMKV sie gemessen hat. Außerhalb
|
||||||
|
* des Auswahl-Labels (ein Haken IM Label würde den Titel mit umschalten). */
|
||||||
|
function SpurZeilen({
|
||||||
|
name,
|
||||||
|
art,
|
||||||
|
vorhanden,
|
||||||
|
gewaehlt,
|
||||||
|
aufWechsel,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
art: 'audio' | 'untertitel'
|
||||||
|
vorhanden: readonly SpurAngabe[]
|
||||||
|
gewaehlt: readonly number[]
|
||||||
|
aufWechsel: (nr: number) => void
|
||||||
|
}) {
|
||||||
|
const zeilen = vorhanden.filter((s) => angeboten(s, art))
|
||||||
|
if (zeilen.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5" data-spuren={art}>
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">{name}</span>
|
||||||
|
{zeilen.map((s) => {
|
||||||
|
const an = gewaehlt.includes(s.nr)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={s.nr}
|
||||||
|
className={`inline-flex cursor-pointer items-center gap-1.5 font-mono text-[10.5px] ${an ? 'text-gold-hell' : 'text-nebel hover:text-dunst'}`}
|
||||||
|
title={s.beschreibung.length > 0 ? s.beschreibung : spurLabel(s)}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={an} onChange={() => aufWechsel(s.nr)} className="accent-[oklch(0.83_0.11_75)]" />
|
||||||
|
{spurLabel(s)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
|
||||||
|
* kern/ablauf/auftrag.ts spurwahl, 5.7.0): Spielt die erste gewählte
|
||||||
|
* Tonspur nicht in der Wunschsprache, die erste gewählte Untertitelspur
|
||||||
|
* der Wunschsprache — sonst nichts. */
|
||||||
|
function regelVorwahlSpur(audio: readonly SpurAngabe[], untertitel: readonly SpurAngabe[], wunsch: string): SpurAngabe | undefined {
|
||||||
|
if (wunsch.length === 0 || audio.length === 0 || audio[0].lang.toLowerCase() === wunsch) return undefined
|
||||||
|
return untertitel.find((s) => s.lang.toLowerCase() === wunsch)
|
||||||
|
}
|
||||||
|
|
||||||
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
|
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
|
||||||
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
|
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
|
||||||
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
|
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
|
||||||
@@ -105,6 +158,12 @@ export function TitelWahlDialog({
|
|||||||
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
|
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
|
||||||
// sonst die Sprache. Immer optional, nie eingebrannt.
|
// sonst die Sprache. Immer optional, nie eingebrannt.
|
||||||
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
|
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
|
||||||
|
// 5.7.0: Spuren je Titel EINZELN (MakeMKV-Stream-Nummern). Was hier nicht
|
||||||
|
// drinsteht, ist vorbelegt: je Wunschsprache die erste Tonspur, alle
|
||||||
|
// Untertitel der Wunschsprachen (gemeinsam/spuren.ts). Dazu die
|
||||||
|
// Untertitel-Vorwahl je Extra als SPUR — fehlt: Regel; null: keine.
|
||||||
|
const [spuren, setSpuren] = useState<Record<number, { audio: number[]; untertitel: number[] }>>({})
|
||||||
|
const [vorwahlSpur, setVorwahlSpur] = useState<Record<number, number | null>>({})
|
||||||
// 5.6.0: Titel, die zu einem anderen Film gehören.
|
// 5.6.0: Titel, die zu einem anderen Film gehören.
|
||||||
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
|
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
|
||||||
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
|
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
|
||||||
@@ -130,6 +189,31 @@ export function TitelWahlDialog({
|
|||||||
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
|
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
|
||||||
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
|
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
|
||||||
}
|
}
|
||||||
|
const wunschListe = (schluessel: string): string[] =>
|
||||||
|
(einstellungen[schluessel] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
/** Meldet dieser Titel einzelne Spuren? Sonst gelten die Sprach-Chips. */
|
||||||
|
const spurenDa = (t: DiscTitelVorschlag): boolean => t.spuren !== undefined && (t.spuren.audio.length > 0 || t.spuren.untertitel.length > 0)
|
||||||
|
const spurenVon = (t: DiscTitelVorschlag): { audio: number[]; untertitel: number[] } =>
|
||||||
|
spuren[t.nr] ?? {
|
||||||
|
audio: spurenVorbelegt(t.spuren?.audio ?? [], wunschListe('audioSprachen'), 'audio'),
|
||||||
|
untertitel: spurenVorbelegt(t.spuren?.untertitel ?? [], wunschListe('untertitelSprachen'), 'untertitel'),
|
||||||
|
}
|
||||||
|
const spurWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', nr: number): void => {
|
||||||
|
const jetzt = spurenVon(t)
|
||||||
|
const liste = jetzt[art].includes(nr) ? jetzt[art].filter((n) => n !== nr) : [...jetzt[art], nr].sort((a, b) => a - b)
|
||||||
|
setSpuren({ ...spuren, [t.nr]: { ...jetzt, [art]: liste } })
|
||||||
|
}
|
||||||
|
/** Die gewählten Spuren als Angaben, in Disc-Reihenfolge. */
|
||||||
|
const gewaehlteSpuren = (t: DiscTitelVorschlag): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } => {
|
||||||
|
const nrn = spurenVon(t)
|
||||||
|
return {
|
||||||
|
audio: (t.spuren?.audio ?? []).filter((s) => nrn.audio.includes(s.nr)),
|
||||||
|
untertitel: (t.spuren?.untertitel ?? []).filter((s) => nrn.untertitel.includes(s.nr)),
|
||||||
|
}
|
||||||
|
}
|
||||||
const umschalten = (nr: number): void => {
|
const umschalten = (nr: number): void => {
|
||||||
const neu = new Set(aktiv)
|
const neu = new Set(aktiv)
|
||||||
if (neu.has(nr)) neu.delete(nr)
|
if (neu.has(nr)) neu.delete(nr)
|
||||||
@@ -181,9 +265,12 @@ export function TitelWahlDialog({
|
|||||||
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
||||||
|
|
||||||
{stand === undefined || stand.laeuft ? (
|
{stand === undefined || stand.laeuft ? (
|
||||||
<p className="mt-4 font-mono text-xs text-nebel">
|
<div className="mt-4">
|
||||||
Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
|
<p className="font-mono text-xs text-nebel">Rippy liest die Titel der Disc … meist dauert das ein bis zwei Minuten. Solange dreht das Laufwerk.</p>
|
||||||
</p>
|
{/* 5.7.0 (Kandidat 2): Ab der zweiten Minute sagt der Kern, wie lange
|
||||||
|
es schon läuft und wo die Grenze liegt. */}
|
||||||
|
{stand !== undefined && stand.einordnung.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{stand.einordnung}</p>}
|
||||||
|
</div>
|
||||||
) : stand.fehler.length > 0 ? (
|
) : stand.fehler.length > 0 ? (
|
||||||
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -228,6 +315,10 @@ export function TitelWahlDialog({
|
|||||||
const film = filme[t.nr]
|
const film = filme[t.nr]
|
||||||
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
|
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
|
||||||
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
|
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
|
||||||
|
const spurNrn = spurenVon(t)
|
||||||
|
const spurWahl = gewaehlteSpuren(t)
|
||||||
|
const regelSpur = regelVorwahlSpur(spurWahl.audio, spurWahl.untertitel, wunschSprache)
|
||||||
|
const vorwahlSpurWert = t.nr in vorwahlSpur ? (vorwahlSpur[t.nr] === null ? 'keine' : String(vorwahlSpur[t.nr])) : 'regel'
|
||||||
return (
|
return (
|
||||||
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
|
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
|
||||||
<label className="flex cursor-pointer items-center gap-3 p-2">
|
<label className="flex cursor-pointer items-center gap-3 p-2">
|
||||||
@@ -252,10 +343,56 @@ export function TitelWahlDialog({
|
|||||||
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
{/* Spuren je Titel (5.7.0): jede Ton- und Untertitelspur, die
|
||||||
|
der Info-Lauf an DIESEM Titel gemessen hat — einzeln
|
||||||
|
anhaken, was in die Datei soll. */}
|
||||||
|
{an && spurenDa(t) && (
|
||||||
|
<div className="flex flex-wrap items-start gap-x-6 gap-y-1.5 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||||||
|
<SpurZeilen name="TON" art="audio" vorhanden={t.spuren.audio} gewaehlt={spurNrn.audio} aufWechsel={(nr) => spurWechsel(t, 'audio', nr)} />
|
||||||
|
<SpurZeilen
|
||||||
|
name="UNTERTITEL"
|
||||||
|
art="untertitel"
|
||||||
|
vorhanden={t.spuren.untertitel}
|
||||||
|
gewaehlt={spurNrn.untertitel}
|
||||||
|
aufWechsel={(nr) => spurWechsel(t, 'untertitel', nr)}
|
||||||
|
/>
|
||||||
|
{spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
|
||||||
|
<span className="font-mono text-[10.5px] text-warn">keine Tonspur gewählt — HandBrake nimmt die erste Spur</span>
|
||||||
|
)}
|
||||||
|
{/* 5.6.0/5.7.0: Vorwahl NUR bei Extras, IMMER optional — eine
|
||||||
|
weiche Spur, die der Spieler von selbst einblendet;
|
||||||
|
eingebrannt wird nie. Hier als SPUR. */}
|
||||||
|
{rolle === 'extra' && spurWahl.untertitel.length > 0 && (
|
||||||
|
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
|
||||||
|
<select
|
||||||
|
value={vorwahlSpurWert}
|
||||||
|
onChange={(e) => {
|
||||||
|
const w = e.target.value
|
||||||
|
const neu = { ...vorwahlSpur }
|
||||||
|
if (w === 'regel') delete neu[t.nr]
|
||||||
|
else neu[t.nr] = w === 'keine' ? null : Number(w)
|
||||||
|
setVorwahlSpur(neu)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
|
||||||
|
>
|
||||||
|
<option value="regel">{regelSpur !== undefined ? `Rippys Regel (${spurLabel(regelSpur)})` : 'Rippys Regel (keine)'}</option>
|
||||||
|
<option value="keine">keine</option>
|
||||||
|
{spurWahl.untertitel.map((u) => (
|
||||||
|
<option key={u.nr} value={String(u.nr)}>
|
||||||
|
{spurLabel(u)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
|
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
|
||||||
Titel gemessen hat — anhaken, was in die Datei soll.
|
Titel gemessen hat — anhaken, was in die Datei soll.
|
||||||
Der Commander-Fall: japanischer Ton, deutsche Untertitel. */}
|
Der Commander-Fall: japanischer Ton, deutsche Untertitel.
|
||||||
{an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
|
Seit 5.7.0 nur noch der Rückfall ohne einzelne Spuren. */}
|
||||||
|
{an && !spurenDa(t) && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||||||
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
|
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
|
||||||
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
|
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
|
||||||
@@ -410,7 +547,7 @@ export function TitelWahlDialog({
|
|||||||
</label>
|
</label>
|
||||||
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
|
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
|
||||||
{merkerGilt && merker !== null
|
{merkerGilt && merker !== null
|
||||||
? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${merker.wann.slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.`
|
? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${datumKurz(merker.wann).slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.`
|
||||||
: 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'}
|
: 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'}
|
||||||
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
|
{info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}
|
||||||
</p>
|
</p>
|
||||||
@@ -463,7 +600,15 @@ export function TitelWahlDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-5 flex justify-between gap-2">
|
<div className="mt-5 flex justify-between gap-2">
|
||||||
<button onClick={schliessen} className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Läuft der Titel-Lauf noch, beendet „Abbrechen" ihn auch im Kern —
|
||||||
|
// sonst dreht das Laufwerk weiter, obwohl der Dialog zu ist.
|
||||||
|
if (stand !== undefined && stand.laeuft) titelAbbrechen(id)
|
||||||
|
schliessen()
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
|
||||||
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -473,6 +618,24 @@ export function TitelWahlDialog({
|
|||||||
gewaehlteTitel.map((t) => {
|
gewaehlteTitel.map((t) => {
|
||||||
const wahl = sprachenVon(t)
|
const wahl = sprachenVon(t)
|
||||||
const rolle = rolleVon(t)
|
const rolle = rolleVon(t)
|
||||||
|
// 5.7.0: Spuren einzeln — die Sprachlisten reisen abgeleitet
|
||||||
|
// mit (Rückfall des Kerns, wenn der Scan der Roh-Datei
|
||||||
|
// scheitert; und die alten Hinweise bleiben stimmig).
|
||||||
|
if (spurenDa(t)) {
|
||||||
|
const spurWahl = gewaehlteSpuren(t)
|
||||||
|
return {
|
||||||
|
nr: t.nr,
|
||||||
|
rolle,
|
||||||
|
dauerS: t.dauerS,
|
||||||
|
groesseBytes: t.groesseBytes,
|
||||||
|
audioSprachen: sprachenDerSpuren(spurWahl.audio),
|
||||||
|
untertitelSprachen: sprachenDerSpuren(spurWahl.untertitel),
|
||||||
|
audioSpuren: spurWahl.audio,
|
||||||
|
untertitelSpuren: spurWahl.untertitel,
|
||||||
|
...(rolle === 'extra' && t.nr in vorwahlSpur ? { untertitelVorwahlSpur: vorwahlSpur[t.nr] } : {}),
|
||||||
|
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
nr: t.nr,
|
nr: t.nr,
|
||||||
rolle,
|
rolle,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
|
// Werkzeuge mit Durchsuchen, Kompression mit Sprach-Auswahl, TMDb,
|
||||||
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
|
// Benachrichtigung, Automatik und Roh-Regel. Dazu der Poster-Hintergrund.
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
import { DEFAULT_HB_PRESET, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
|
import { Badge, FELD_KLASSE, Feld, KNOPF_GOLD, KNOPF_LEISE, PfadFeld, Schalter, gbKurz } from '../bausteine'
|
||||||
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
|
import { SprachAuswahl } from '../einstellungen/SprachAuswahl'
|
||||||
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
|
import { benachrichtigungTesten, einstellungSetzen, posterLaden, speicherLaden, type Verbindung } from '../kernverbindung'
|
||||||
@@ -167,7 +167,7 @@ export function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
{EMPFEHLUNGEN.map(([name, typ]) => (
|
{EMPFEHLUNGEN.map(([name, typ]) => (
|
||||||
<div key={typ} className="rounded-lg border border-tinte/8 bg-buehne/60 p-2">
|
<div key={typ} className="rounded-lg border border-tinte/8 bg-buehne/60 p-2">
|
||||||
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
|
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
|
||||||
<p className="mt-0.5 font-mono text-xs text-gold-hell">{presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET}</p>
|
<p className="mt-0.5 font-mono text-xs text-gold-hell">{empfehlungText(typ, presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// einstellungSetzen in die EINE Quelle (§ 6.7).
|
// einstellungSetzen in die EINE Quelle (§ 6.7).
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
||||||
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
import { empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
FELD_KLASSE,
|
FELD_KLASSE,
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '../bausteine'
|
} from '../bausteine'
|
||||||
import {
|
import {
|
||||||
benachrichtigungTesten,
|
benachrichtigungTesten,
|
||||||
|
beschaffungAntworten,
|
||||||
einstellungSetzen,
|
einstellungSetzen,
|
||||||
fremdenOrdnerLoeschen,
|
fremdenOrdnerLoeschen,
|
||||||
kompressionAbbrechen,
|
kompressionAbbrechen,
|
||||||
@@ -126,7 +127,7 @@ export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
const werte = verbindung.einstellungen
|
const werte = verbindung.einstellungen
|
||||||
const presets = verbindung.werkzeuge?.presets ?? []
|
const presets = verbindung.werkzeuge?.presets ?? []
|
||||||
const backends = verbindung.werkzeuge?.backends ?? []
|
const backends = verbindung.werkzeuge?.backends ?? []
|
||||||
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
|
const empfehlungFuer = (typ: string): string => empfehlungText(typ, presetEmpfehlung(typ, backends, presets))
|
||||||
return (
|
return (
|
||||||
<SektionsKarte
|
<SektionsKarte
|
||||||
id="kompression"
|
id="kompression"
|
||||||
@@ -152,7 +153,8 @@ export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] leading-4 text-nebel">
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
|
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
|
||||||
schlägt die Empfehlung immer.
|
schlägt die Empfehlung immer. 4K-UHD wird nur mit Grafikkarten-Encoder komprimiert (8 Bit); ohne bleibt es verlustfrei. Wer HDR in 10 Bit will, wählt
|
||||||
|
„AV1 VCN 2160p 4K" — die Abspielgeräte müssen dann AV1 können.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<SprachAuswahl
|
<SprachAuswahl
|
||||||
@@ -198,6 +200,9 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
|
|||||||
const v = verbindung.vorgaenge
|
const v = verbindung.vorgaenge
|
||||||
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
|
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
|
||||||
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
|
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
|
||||||
|
// 5.7.0 (Durchsicht F17): Löschen fragt nach — ein Klick war vorher 30 GB.
|
||||||
|
// Der erste Klick macht aus dem Knopf die Rückfrage, der zweite löscht.
|
||||||
|
const [loeschfrage, setLoeschfrage] = useState<string | null>(null)
|
||||||
const presets = verbindung.werkzeuge?.presets ?? []
|
const presets = verbindung.werkzeuge?.presets ?? []
|
||||||
const regelWert = werte['rohAufbewahrung'] ?? '7'
|
const regelWert = werte['rohAufbewahrung'] ?? '7'
|
||||||
const phasenText: Record<VorgangEintrag['phase'], string> = {
|
const phasenText: Record<VorgangEintrag['phase'], string> = {
|
||||||
@@ -312,11 +317,28 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!e.rohGeloescht && ruhig && (
|
{!e.rohGeloescht && ruhig && loeschfrage !== e.id && (
|
||||||
<button onClick={() => vorgangRohLoeschen(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs jetzt löschen">
|
<button onClick={() => setLoeschfrage(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs löschen — fragt erst nach">
|
||||||
Roh-Dateien löschen
|
Roh-Dateien löschen …
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!e.rohGeloescht && ruhig && loeschfrage === e.id && (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-rot/50 bg-rot/10 px-2 py-1 text-[11px] text-rot">
|
||||||
|
{gb(e.rohBytes)} unter {e.rohOrdner} unwiderruflich löschen?
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLoeschfrage(null)
|
||||||
|
vorgangRohLoeschen(e.id)
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
|
||||||
|
>
|
||||||
|
Ja, löschen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
|
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
|
||||||
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||||
Ordner öffnen
|
Ordner öffnen
|
||||||
@@ -348,9 +370,27 @@ export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbind
|
|||||||
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
|
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
|
||||||
{f.ordner} · {gb(f.bytes)}
|
{f.ordner} · {gb(f.bytes)}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => fremdenOrdnerLoeschen(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
|
{loeschfrage !== f.ordner ? (
|
||||||
Löschen
|
<button onClick={() => setLoeschfrage(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
|
||||||
</button>
|
Löschen …
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex flex-none items-center gap-1.5 text-[10.5px] text-rot">
|
||||||
|
wirklich?
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLoeschfrage(null)
|
||||||
|
fremdenOrdnerLoeschen(f.ordner)
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-rot px-2 py-0.5 font-semibold text-buehne hover:brightness-110"
|
||||||
|
>
|
||||||
|
Ja
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setLoeschfrage(null)} className="rounded-md border border-tinte/20 px-2 py-0.5 text-dunst hover:text-schnee">
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -406,16 +446,19 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
|
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* 5.7.0 (F1/F5): Ein abgelehnter oder fehlender Schlüssel ist
|
||||||
|
ROT — Blu-rays laufen dann nur noch in MakeMKVs Gnadenfrist.
|
||||||
|
„Nicht prüfbar" (kein Laufwerk) ist grau, kein Erfolg. */}
|
||||||
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
|
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
|
||||||
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.weiterBetrieb ? 'bg-warn' : 'bg-rot'}`} />
|
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.angenommen === null ? 'bg-tinte/25' : 'bg-rot'}`} />
|
||||||
<span className={s.angenommen === true ? 'text-gruen' : s.weiterBetrieb ? 'text-warn' : 'text-rot'}>
|
<span className={s.angenommen === true ? 'text-gruen' : s.angenommen === null ? 'text-nebel' : 'text-rot'}>
|
||||||
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'}
|
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.angenommen === null ? 'NICHT PRÜFBAR — KEIN LAUFWERK' : 'KEIN GÜLTIGER SCHLÜSSEL — BLU-RAY-RIPS LAUFEN NICHT'}
|
||||||
</span>
|
</span>
|
||||||
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
|
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
|
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
|
||||||
<p className="mt-1 font-mono text-[10px] text-nebel">
|
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||||
zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')}
|
zuletzt geprüft: {datumKurz(s.geholtAm)}
|
||||||
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
@@ -439,6 +482,19 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
{b !== null && (
|
{b !== null && (
|
||||||
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
|
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
|
||||||
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
||||||
|
{b.frage !== undefined && b.frage.length > 0 && (
|
||||||
|
<div className="mt-2 rounded-lg border border-warn/45 bg-warn/10 p-2.5">
|
||||||
|
<p className="text-xs leading-5 text-dunst">{b.frage}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button onClick={() => beschaffungAntworten(true)} className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25">
|
||||||
|
Archiv-Kopie laden
|
||||||
|
</button>
|
||||||
|
<button onClick={() => beschaffungAntworten(false)} className={KNOPF_LEISE}>
|
||||||
|
Lieber nicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{b.laeuft && b.anteil !== null && (
|
{b.laeuft && b.anteil !== null && (
|
||||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
|
||||||
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
|
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
|
||||||
@@ -448,11 +504,14 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
)}
|
)}
|
||||||
<Feld
|
<Feld
|
||||||
name="Eigener Schlüssel (Dauerlizenz)"
|
name="Eigener Schlüssel (Dauerlizenz)"
|
||||||
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist"
|
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist; nach dem Eintragen prüft Rippy sofort"
|
||||||
geheim
|
geheim
|
||||||
wert={werte['makemkvAppKey'] ?? ''}
|
wert={werte['makemkvAppKey'] ?? ''}
|
||||||
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[11px] leading-4 text-nebel">
|
||||||
|
Rippy schreibt in MakeMKVs eigene Einstellungen: den Schlüssel, die Auswahlregel „alle Spuren behalten" und den Kanal für 4K-Disc-Schlüssel. Deshalb nimmt auch das MakeMKV-Programm seitdem alle Tonspuren und Untertitel mit — die Dateien dort werden größer, verloren geht nichts.
|
||||||
|
</p>
|
||||||
</SektionsKarte>
|
</SektionsKarte>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -519,6 +578,17 @@ export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung;
|
|||||||
return (
|
return (
|
||||||
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
|
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
|
||||||
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
|
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
|
||||||
|
<label className="block max-w-sm">
|
||||||
|
<span className="text-xs font-semibold text-dunst">Titel lesen — ohne Lebenszeichen höchstens</span>
|
||||||
|
<select value={werte['titelLesenMinuten'] ?? '15'} onChange={(e) => setzen('titelLesenMinuten', e.target.value)} className={FELD_KLASSE}>
|
||||||
|
<option value="5">5 Minuten</option>
|
||||||
|
<option value="10">10 Minuten</option>
|
||||||
|
<option value="15">15 Minuten (Vorgabe)</option>
|
||||||
|
<option value="30">30 Minuten</option>
|
||||||
|
<option value="60">60 Minuten</option>
|
||||||
|
</select>
|
||||||
|
<span className="mt-1 block text-[11px] leading-4 text-nebel">So lange darf MakeMKV still sein (kein Fortschritt, keine Meldung), bevor Rippy das Titel-Lesen beendet. Solange MakeMKV arbeitet und Fortschritt meldet — etwa beim Durchlesen einer DVD mit beschädigter IFO-Datei, was 20–60 Minuten dauern kann — wartet Rippy; „Abbrechen" im Dialog geht jederzeit.</span>
|
||||||
|
</label>
|
||||||
<label className="block max-w-sm">
|
<label className="block max-w-sm">
|
||||||
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
|
<span className="text-xs font-semibold text-dunst">Erscheinungsbild</span>
|
||||||
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
|
<select value={werte['design'] ?? 'dunkel'} onChange={(e) => setzen('design', e.target.value)} className={FELD_KLASSE}>
|
||||||
@@ -571,7 +641,7 @@ export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; hau
|
|||||||
<p>
|
<p>
|
||||||
<span className="text-dunst">Stand:</span> {haupt.update.text}
|
<span className="text-dunst">Stand:</span> {haupt.update.text}
|
||||||
</p>
|
</p>
|
||||||
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}</p>}
|
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {datumKurz(haupt.update.letzteErfolgreichePruefung)}</p>}
|
||||||
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden — die Karte in der Übersicht zeigt, was neu ist.</p>}
|
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden — die Karte in der Übersicht zeigt, was neu ist.</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ function meldung(text: string): void {
|
|||||||
function kernNachricht(nachricht: KernNachricht): void {
|
function kernNachricht(nachricht: KernNachricht): void {
|
||||||
switch (nachricht.art) {
|
switch (nachricht.art) {
|
||||||
case 'kern-bereit':
|
case 'kern-bereit':
|
||||||
|
// 5.7.0 (F4): Eine neue PID heißt, der Kern wurde neu gestartet — das
|
||||||
|
// steht jetzt im Fenster, statt dass die Laufwerke stumm einfrieren.
|
||||||
|
if (stand.kern !== null && stand.kern.pid !== nachricht.pid) {
|
||||||
|
meldung(`⚠ Der Kern wurde neu gestartet (PID ${nachricht.pid}) — Rippy hat das Fenster wieder verbunden. Ein laufender Rip ist damit abgebrochen; der Grund steht im Protokoll (Einstellungen → Allgemein).`)
|
||||||
|
}
|
||||||
stand.kern = nachricht
|
stand.kern = nachricht
|
||||||
break
|
break
|
||||||
case 'pong':
|
case 'pong':
|
||||||
@@ -208,6 +213,10 @@ function kernNachricht(nachricht: KernNachricht): void {
|
|||||||
case 'vorgaenge':
|
case 'vorgaenge':
|
||||||
stand.vorgaenge = nachricht.stand
|
stand.vorgaenge = nachricht.stand
|
||||||
break
|
break
|
||||||
|
case 'kompression-stand':
|
||||||
|
// 5.7.0 (F7): nur die Warteschlange frisch — der Rest bleibt stehen.
|
||||||
|
if (stand.vorgaenge !== null) stand.vorgaenge = { ...stand.vorgaenge, kompression: nachricht.kompression }
|
||||||
|
break
|
||||||
case 'kompression-ergebnis':
|
case 'kompression-ergebnis':
|
||||||
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
|
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
|
||||||
break
|
break
|
||||||
@@ -266,6 +275,15 @@ function kernNachricht(nachricht: KernNachricht): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function portAnnehmen(neuerPort: MessagePort): void {
|
function portAnnehmen(neuerPort: MessagePort): void {
|
||||||
|
// Ein zweiter Port kommt nach einem Kern-Neustart (5.7.0, F4) — der alte
|
||||||
|
// ist tot und wird geschlossen, damit nichts mehr ins Leere geht.
|
||||||
|
if (port !== null && port !== neuerPort) {
|
||||||
|
try {
|
||||||
|
port.close()
|
||||||
|
} catch {
|
||||||
|
// Ein toter Port lässt sich nicht immer schließen — er ist ohnehin weg.
|
||||||
|
}
|
||||||
|
}
|
||||||
port = neuerPort
|
port = neuerPort
|
||||||
stand.verbunden = true
|
stand.verbunden = true
|
||||||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||||
@@ -329,6 +347,11 @@ export function titelLesen(id: string): void {
|
|||||||
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 5.7.0: den laufenden Titel-Lauf beenden — makemkvcon gibt das Laufwerk frei. */
|
||||||
|
export function titelAbbrechen(id: string): void {
|
||||||
|
anKern({ art: 'titel-abbruch', id }, `Titel-Lauf abbrechen ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
|
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
|
||||||
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
|
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
|
||||||
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
|
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
|
||||||
@@ -363,6 +386,11 @@ export function schluesselPruefen(): void {
|
|||||||
senden({ art: 'schluessel-pruefen' })
|
senden({ art: 'schluessel-pruefen' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 5.7.0 (F16): Ja/Nein auf eine Frage der MakeMKV-Beschaffung. */
|
||||||
|
export function beschaffungAntworten(ja: boolean): void {
|
||||||
|
anKern({ art: 'beschaffung-antwort', ja }, 'Antwort an die Beschaffung')
|
||||||
|
}
|
||||||
|
|
||||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||||
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
RipStatus,
|
RipStatus,
|
||||||
VorgangEintrag,
|
VorgangEintrag,
|
||||||
} from '../../gemeinsam/nachrichten'
|
} from '../../gemeinsam/nachrichten'
|
||||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
import { DEFAULT_HB_PRESET, PRESET_KEINE, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
IconDisc,
|
IconDisc,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
PHASEN_MONO,
|
PHASEN_MONO,
|
||||||
TYP_NAMEN,
|
TYP_NAMEN,
|
||||||
backdropUrl,
|
backdropUrl,
|
||||||
|
datumKurz,
|
||||||
discHerkunft,
|
discHerkunft,
|
||||||
gb,
|
gb,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
@@ -57,9 +58,11 @@ export function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung):
|
|||||||
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
|
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
|
||||||
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
|
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
|
||||||
const eigene = werte[schluessel] ?? ''
|
const eigene = werte[schluessel] ?? ''
|
||||||
|
if (eigene === PRESET_KEINE) return 'ohne Kompression'
|
||||||
|
if (eigene.length > 0) return eigene
|
||||||
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
|
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
|
||||||
const gewaehlt = eigene.length > 0 ? eigene : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
|
if (empfehlung === PRESET_KEINE) return typ === 'uhd' ? 'verlustfrei (nur CPU-Encoder)' : 'ohne Kompression'
|
||||||
return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt
|
return empfehlungText(typ, empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LaufwerkKachel({
|
export function LaufwerkKachel({
|
||||||
@@ -158,7 +161,7 @@ export function LaufwerkKachel({
|
|||||||
onClick={aufRippen}
|
onClick={aufRippen}
|
||||||
disabled={!discDa}
|
disabled={!discDa}
|
||||||
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
|
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
|
||||||
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
|
title="Titel der Disc lesen und auswählen — danach verlustfrei rippen"
|
||||||
>
|
>
|
||||||
<IconPlay />
|
<IconPlay />
|
||||||
Rippen
|
Rippen
|
||||||
@@ -294,7 +297,7 @@ export function LaufwerkKachel({
|
|||||||
)}
|
)}
|
||||||
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
|
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
|
||||||
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
|
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
|
||||||
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />}
|
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${datumKurz(info.schonGerippt).slice(0, 10)}`} ton="gelb" />}
|
||||||
</div>
|
</div>
|
||||||
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
|
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
|
||||||
{erkannt && info.hinweise.length > 0 && (
|
{erkannt && info.hinweise.length > 0 && (
|
||||||
@@ -410,9 +413,9 @@ export function LaufwerkKachel({
|
|||||||
<button
|
<button
|
||||||
onClick={() => ripUeberspringen(geraet.id)}
|
onClick={() => ripUeberspringen(geraet.id)}
|
||||||
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
|
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
|
||||||
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
|
title="Diesen Titel aufgeben und mit dem nächsten weitermachen"
|
||||||
>
|
>
|
||||||
Titel ueberspringen
|
Titel überspringen
|
||||||
</button>
|
</button>
|
||||||
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen — abbrechen geht oben.</span>
|
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen — abbrechen geht oben.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,6 +98,30 @@ export interface SprachEintrag {
|
|||||||
spuren: number
|
spuren: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** EINE Spur eines Titels, wie der Info-Lauf sie gemessen hat (5.7.0,
|
||||||
|
* Commander-Entscheid 12.09.2026: „jede Spur einzeln anhaken"). Die
|
||||||
|
* Attribute stammen aus SINFO (parser.ts): 3 Sprachcode, 4 Sprachname,
|
||||||
|
* 6 Codec, 14 Kanäle, 2 Anordnung, 30 Beschreibung, 22 Flags. */
|
||||||
|
export interface SpurAngabe {
|
||||||
|
/** MakeMKVs Stream-Nummer innerhalb des Titels. */
|
||||||
|
nr: number
|
||||||
|
lang: string
|
||||||
|
sprache: string
|
||||||
|
/** „DTS-HD MA", „TrueHD", „DD", „PGS", „VobSub" … */
|
||||||
|
codec: string
|
||||||
|
/** Kanalzahl als Text („6", „2"); leer bei Untertiteln. */
|
||||||
|
kanaele: string
|
||||||
|
/** „Surround 5.1", „Stereo" — Attribut 2. */
|
||||||
|
layout: string
|
||||||
|
/** Attribut 30, z. B. „DTS-HD MA Surround 5.1 German" oder „PGS German (nur erzwungene)". */
|
||||||
|
beschreibung: string
|
||||||
|
/** Bit 0x800 in Attribut 22 (an der Kizuna-Disc gemessen): eine von
|
||||||
|
* MakeMKV ABGELEITETE Spur — der DTS-Kern einer HD-Spur (nicht in der
|
||||||
|
* Roh-Datei) oder die „nur erzwungene"-Fassung eines Untertitels (in der
|
||||||
|
* Roh-Datei, wählbar). */
|
||||||
|
abgeleitet: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
|
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
|
||||||
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
||||||
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
||||||
@@ -117,6 +141,12 @@ export interface RipAuftragTitel {
|
|||||||
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
|
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
|
||||||
* fehlend = Rippys Regel (fremder Ton → Wunschsprache). */
|
* fehlend = Rippys Regel (fremder Ton → Wunschsprache). */
|
||||||
untertitelVorwahl?: string | null
|
untertitelVorwahl?: string | null
|
||||||
|
/** 5.7.0: die GEWÄHLTEN Spuren mit allem, was die Ablage braucht, um sie
|
||||||
|
* in der Roh-Datei wiederzufinden. Fehlend: die Sprachlisten gelten. */
|
||||||
|
audioSpuren?: SpurAngabe[]
|
||||||
|
untertitelSpuren?: SpurAngabe[]
|
||||||
|
/** 5.7.0: die vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
|
||||||
|
untertitelVorwahlSpur?: number | null
|
||||||
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
|
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
|
||||||
* (Double Feature) — er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
|
* (Double Feature) — er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
|
||||||
film?: MetaVorschlag
|
film?: MetaVorschlag
|
||||||
@@ -147,6 +177,8 @@ export interface DiscTitelVorschlag {
|
|||||||
grund: string
|
grund: string
|
||||||
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
|
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
|
||||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||||
|
/** 5.7.0: jede Spur einzeln — der Dialog zeigt sie zum Anhaken. */
|
||||||
|
spuren: { audio: SpurAngabe[]; untertitel: SpurAngabe[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
||||||
@@ -257,6 +289,9 @@ export interface BeschaffungStand {
|
|||||||
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||||||
anteil: number | null
|
anteil: number | null
|
||||||
fehler: boolean
|
fehler: boolean
|
||||||
|
/** 5.7.0 (F16): Rippy wartet auf ein Ja/Nein — z. B. „Archiv-Kopie laden?".
|
||||||
|
* Fehlt: keine Frage offen. */
|
||||||
|
frage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
|
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
|
||||||
@@ -414,6 +449,9 @@ export type KernNachricht =
|
|||||||
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
|
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
|
||||||
* (5.4.0) — geht auch ans Haupt (Taskleiste, Updates warten). */
|
* (5.4.0) — geht auch ans Haupt (Taskleiste, Updates warten). */
|
||||||
| { art: 'vorgaenge'; stand: VorgaengeStand }
|
| { art: 'vorgaenge'; stand: VorgaengeStand }
|
||||||
|
/** 5.7.0 (F7): NUR die Warteschlange — jeder Prozent-Tick der Kompression
|
||||||
|
* kommt so, ohne den teuren vollen Vorgangs-Stand (Ordnerläufe, statfs). */
|
||||||
|
| { art: 'kompression-stand'; kompression: VorgaengeStand['kompression'] }
|
||||||
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
|
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
|
||||||
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
|
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
|
||||||
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
|
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
|
||||||
@@ -432,6 +470,8 @@ export type FensterNachricht =
|
|||||||
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||||
* sehr sehr lange"). */
|
* sehr sehr lange"). */
|
||||||
| { art: 'titel-lesen'; id: string }
|
| { art: 'titel-lesen'; id: string }
|
||||||
|
/** 5.7.0: den laufenden Titel-Lauf abbrechen (Kandidat 2). */
|
||||||
|
| { art: 'titel-abbruch'; id: string }
|
||||||
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
||||||
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
|
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
|
||||||
| { art: 'rip-abbruch'; id: string }
|
| { art: 'rip-abbruch'; id: string }
|
||||||
@@ -447,6 +487,8 @@ export type FensterNachricht =
|
|||||||
| { art: 'makemkv-update' }
|
| { art: 'makemkv-update' }
|
||||||
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||||||
| { art: 'schluessel-pruefen' }
|
| { art: 'schluessel-pruefen' }
|
||||||
|
/** 5.7.0 (F16): Antwort auf eine Frage der MakeMKV-Beschaffung. */
|
||||||
|
| { art: 'beschaffung-antwort'; ja: boolean }
|
||||||
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
||||||
| { art: 'vorgaenge-laden' }
|
| { art: 'vorgaenge-laden' }
|
||||||
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
|
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
|
||||||
@@ -594,6 +636,8 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
|||||||
istObjekt((wert.stand as Record<string, unknown>).kompression) &&
|
istObjekt((wert.stand as Record<string, unknown>).kompression) &&
|
||||||
istObjekt((wert.stand as Record<string, unknown>).statistik)
|
istObjekt((wert.stand as Record<string, unknown>).statistik)
|
||||||
)
|
)
|
||||||
|
case 'kompression-stand':
|
||||||
|
return istObjekt(wert.kompression) && Array.isArray((wert.kompression as Record<string, unknown>).wartend)
|
||||||
case 'kompression-ergebnis':
|
case 'kompression-ergebnis':
|
||||||
return (
|
return (
|
||||||
istKennung(wert.vorgangId) &&
|
istKennung(wert.vorgangId) &&
|
||||||
@@ -673,9 +717,20 @@ export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
|
|||||||
if (liste === undefined) continue
|
if (liste === undefined) continue
|
||||||
if (!Array.isArray(liste) || !liste.every(istText)) return false
|
if (!Array.isArray(liste) || !liste.every(istText)) return false
|
||||||
}
|
}
|
||||||
|
for (const feld of ['audioSpuren', 'untertitelSpuren']) {
|
||||||
|
const liste = wert[feld]
|
||||||
|
if (liste === undefined) continue
|
||||||
|
if (!Array.isArray(liste) || !liste.every(istSpurAngabe)) return false
|
||||||
|
}
|
||||||
|
if (wert.untertitelVorwahlSpur !== undefined && wert.untertitelVorwahlSpur !== null && typeof wert.untertitelVorwahlSpur !== 'number') return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function istSpurAngabe(wert: unknown): wert is SpurAngabe {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
return typeof wert.nr === 'number' && typeof wert.lang === 'string' && typeof wert.codec === 'string' && typeof wert.abgeleitet === 'boolean'
|
||||||
|
}
|
||||||
|
|
||||||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||||
if (!istObjekt(wert)) return false
|
if (!istObjekt(wert)) return false
|
||||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||||
@@ -695,6 +750,7 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
|||||||
if (
|
if (
|
||||||
wert.art === 'auswerfen' ||
|
wert.art === 'auswerfen' ||
|
||||||
wert.art === 'titel-lesen' ||
|
wert.art === 'titel-lesen' ||
|
||||||
|
wert.art === 'titel-abbruch' ||
|
||||||
wert.art === 'rip-abbruch' ||
|
wert.art === 'rip-abbruch' ||
|
||||||
wert.art === 'rip-ueberspringen' ||
|
wert.art === 'rip-ueberspringen' ||
|
||||||
wert.art === 'laufwerk-pruefen' ||
|
wert.art === 'laufwerk-pruefen' ||
|
||||||
@@ -707,6 +763,9 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
|||||||
if (wert.art === 'kompression-pause') {
|
if (wert.art === 'kompression-pause') {
|
||||||
return typeof wert.an === 'boolean'
|
return typeof wert.an === 'boolean'
|
||||||
}
|
}
|
||||||
|
if (wert.art === 'beschaffung-antwort') {
|
||||||
|
return typeof wert.ja === 'boolean'
|
||||||
|
}
|
||||||
if (wert.art === 'einstellung-setzen') {
|
if (wert.art === 'einstellung-setzen') {
|
||||||
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
|
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
|
|
||||||
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
|
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
|
||||||
|
|
||||||
|
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
|
||||||
|
// HandBrake-Preset heißt so (gegen alle 107 aus --preset-list geprüft).
|
||||||
|
// Lebt seit 5.7.0 hier, weil die Empfehlung ihn liefern kann.
|
||||||
|
export const PRESET_KEINE = 'keine'
|
||||||
|
|
||||||
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
|
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
|
||||||
['vce', 'VCN'],
|
['vce', 'VCN'],
|
||||||
['nvenc', 'NVENC'],
|
['nvenc', 'NVENC'],
|
||||||
@@ -25,7 +30,19 @@ const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]>
|
|||||||
]
|
]
|
||||||
|
|
||||||
/** Das empfohlene Preset für diesen Disc-Typ — aus den GEMESSENEN
|
/** Das empfohlene Preset für diesen Disc-Typ — aus den GEMESSENEN
|
||||||
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */
|
* Backends und Preset-Namen. DVD und Blu-ray fallen auf den CPU-Default
|
||||||
|
* zurück.
|
||||||
|
*
|
||||||
|
* 4K-UHD seit 5.7.0 nach der Leistung (Commander-Entscheid 12.09.2026):
|
||||||
|
* Gibt es einen Grafikkarten-Encoder UND dessen 4K-Preset, wird damit
|
||||||
|
* komprimiert; gibt es nur CPU-Encoder, bleibt UHD verlustfrei
|
||||||
|
* (PRESET_KEINE) — ein 4K-Encode auf der CPU dauert Tage, und der alte
|
||||||
|
* CPU-Default „1080p30" rechnete 4K klein. Gemessen am 12.09.2026: Die
|
||||||
|
* 4K-Hardware-Presets („H.265 VCN 2160p 4K" usw.) sind 8-Bit; der
|
||||||
|
* 10-Bit-HEVC-Encoder der AMD-Karte (vce_h265_10bit) stürzt in HandBrake
|
||||||
|
* 1.11.2 beim Start ab, 10-Bit-AV1 („AV1 VCN 2160p 4K") läuft — wer HDR
|
||||||
|
* in 10 Bit will, wählt das AV1-Preset selbst (Abspielgeräte müssen AV1
|
||||||
|
* können). */
|
||||||
export function presetEmpfehlung(
|
export function presetEmpfehlung(
|
||||||
discTyp: string,
|
discTyp: string,
|
||||||
backends: readonly string[],
|
backends: readonly string[],
|
||||||
@@ -36,9 +53,19 @@ export function presetEmpfehlung(
|
|||||||
if (!backends.includes(backend)) continue
|
if (!backends.includes(backend)) continue
|
||||||
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
|
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
|
||||||
if (treffer !== undefined) return treffer
|
if (treffer !== undefined) return treffer
|
||||||
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU.
|
// Eine 4K-Disc ohne 4K-Preset der Familie: nicht klein rechnen —
|
||||||
|
// verlustfrei lassen (unten). Bei DVD/Blu-ray ist das 1080p-Pendant
|
||||||
|
// besser als CPU.
|
||||||
|
if (discTyp === 'uhd') continue
|
||||||
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
|
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
|
||||||
if (kleiner !== undefined) return kleiner
|
if (kleiner !== undefined) return kleiner
|
||||||
}
|
}
|
||||||
return DEFAULT_HB_PRESET
|
return discTyp === 'uhd' ? PRESET_KEINE : DEFAULT_HB_PRESET
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klartext zur Empfehlung fürs Fenster (5.7.0). */
|
||||||
|
export function empfehlungText(discTyp: string, empfehlung: string): string {
|
||||||
|
if (empfehlung === PRESET_KEINE) return discTyp === 'uhd' ? 'verlustfrei lassen — nur CPU-Encoder gemessen' : 'verlustfrei lassen'
|
||||||
|
if (discTyp === 'uhd' && /2160p/.test(empfehlung)) return `${empfehlung} (8 Bit)`
|
||||||
|
return empfehlung
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Spuren einzeln (5.7.0, Commander-Entscheid 12.09.2026: „Jede Spur einzeln
|
||||||
|
// anhaken — Deutsch · DTS-HD MA 5.1, Deutsch · Kommentar 2.0 getrennt").
|
||||||
|
// PUR und gemeinsam: Der Dialog zeigt die Zeilen und belegt sie vor, der
|
||||||
|
// Kern findet die gewählten Spuren nach dem Rip in der Roh-Datei wieder.
|
||||||
|
//
|
||||||
|
// Zwei Welten, ein Vergleich:
|
||||||
|
// * MakeMKV (SINFO, kern/rip/parser.ts): Sprachcode, Codec-Wort („DTS-HD
|
||||||
|
// MA"), Kanalzahl („6"), Disc-Reihenfolge.
|
||||||
|
// * HandBrake (--scan --json, kern/komprimieren/scan.ts; am 12.09.2026 am
|
||||||
|
// mitgelieferten HandBrake 1.11.2 gemessen): LanguageCode, CodecName
|
||||||
|
// („dtshd", „dca", „truehd", „ac3", „eac3", „aac", „flac", „pcm_…"),
|
||||||
|
// ChannelCount, TrackNumber — in Datei-Reihenfolge.
|
||||||
|
// MakeMKV schreibt die Spuren in Disc-Reihenfolge; Rippys Auswahlregel
|
||||||
|
// (werkzeuge/spurauswahl.ts) lässt nur die abgeleiteten DTS-Kerne weg.
|
||||||
|
// Deshalb reicht: je gewählter Spur die erste noch freie HandBrake-Spur
|
||||||
|
// gleicher Sprache, gleicher Codec-Familie und gleicher Kanalzahl — sonst
|
||||||
|
// gestuft weniger streng; findet sich nichts, sagt die Ablage es.
|
||||||
|
import type { SpurAngabe } from './nachrichten'
|
||||||
|
|
||||||
|
/** Codec-Familie aus MakeMKVs Codec-Wort (Attribut 6) oder HandBrakes
|
||||||
|
* CodecName — beide auf denselben Schlüssel. '' wenn unbekannt. */
|
||||||
|
export function codecFamilie(text: string): string {
|
||||||
|
const t = (text ?? '').toLowerCase()
|
||||||
|
if (t.length === 0) return ''
|
||||||
|
if (t.includes('dts-hd') || t.includes('dtshd') || t.includes('dts:x') || t.includes('dts hd')) return 'dtshd'
|
||||||
|
if (t === 'dca' || t.startsWith('dts')) return 'dts'
|
||||||
|
if (t.includes('truehd') || t.includes('atmos') || t.includes('mlp')) return 'truehd'
|
||||||
|
if (t.includes('eac3') || t.includes('e-ac3') || t.includes('dd+') || t.includes('digital plus')) return 'eac3'
|
||||||
|
if (t === 'ac3' || t.startsWith('dd') || t.includes('dolby digital') || t === 'ac-3') return 'ac3'
|
||||||
|
if (t.includes('aac')) return 'aac'
|
||||||
|
if (t.includes('flac')) return 'flac'
|
||||||
|
if (t.includes('pcm') || t.includes('lpcm')) return 'pcm'
|
||||||
|
if (t.includes('mp3') || t.includes('mp2') || t.includes('mpeg')) return 'mp3'
|
||||||
|
if (t.includes('opus')) return 'opus'
|
||||||
|
if (t.includes('vorbis')) return 'vorbis'
|
||||||
|
if (t.includes('pgs') || t.includes('hdmv')) return 'pgs'
|
||||||
|
if (t.includes('vobsub') || t.startsWith('dvd')) return 'vobsub'
|
||||||
|
if (t.includes('srt') || t.includes('text') || t.includes('utf-8') || t.includes('ssa') || t.includes('ass')) return 'text'
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Zeile im Dialog: „German · DTS-HD MA · Surround 5.1", „German · DD ·
|
||||||
|
* Stereo (Kommentar)". MakeMKV nennt die Sprache englisch (Attribut 4,
|
||||||
|
* „German"), auch bei deutscher Oberfläche — gemessen an der Kizuna-Disc. */
|
||||||
|
export function spurLabel(s: SpurAngabe): string {
|
||||||
|
const teile: string[] = [s.sprache.length > 0 ? s.sprache : s.lang.length > 0 ? s.lang : 'ohne Sprache']
|
||||||
|
if (s.codec.length > 0) teile.push(s.codec)
|
||||||
|
if (s.kanaele.length > 0) {
|
||||||
|
const n = Number(s.kanaele)
|
||||||
|
const kurz = n === 6 ? '5.1' : n === 8 ? '7.1' : n === 2 ? '2.0' : n === 1 ? '1.0' : s.kanaele
|
||||||
|
// MakeMKVs Anordnung („Surround 5.1", „Stereo") sagt genug — die Zahl
|
||||||
|
// nur, wenn sie fehlt.
|
||||||
|
teile.push(s.layout.length > 0 ? s.layout : kurz)
|
||||||
|
}
|
||||||
|
let label = teile.join(' · ')
|
||||||
|
// Was MakeMKV in der Beschreibung dazusagt — „(nur erzwungene)",
|
||||||
|
// Kommentar-Hinweise — hängt in Klammern an.
|
||||||
|
const zusatz = /\(([^()]*)\)\s*$/.exec(s.beschreibung)
|
||||||
|
if (zusatz !== null && !label.toLowerCase().includes(zusatz[1].toLowerCase())) label += ` (${zusatz[1]})`
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile, die der Dialog ANBIETET: Abgeleitete TON-Spuren (DTS-Kerne)
|
||||||
|
* sind nicht in der Roh-Datei — sie werden nicht gezeigt. Abgeleitete
|
||||||
|
* UNTERTITEL („nur erzwungene") sind drin und wählbar. */
|
||||||
|
export function angeboten(s: SpurAngabe, art: 'audio' | 'untertitel'): boolean {
|
||||||
|
return art === 'untertitel' || !s.abgeleitet
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rippys Vorbelegung (pur):
|
||||||
|
* * Ton: je Wunschsprache die ERSTE angebotene Spur — das ist die Regel
|
||||||
|
* von 5.4.0 („eine Spur je Sprache", meist die beste); trifft keine
|
||||||
|
* Wunschsprache, die erste angebotene Spur überhaupt (japanischer Ton
|
||||||
|
* statt gar keiner).
|
||||||
|
* * Untertitel: ALLE Spuren der Wunschsprachen (auch „nur erzwungene") —
|
||||||
|
* wie bisher mit --all-subtitles; ohne Treffer nichts.
|
||||||
|
* Liefert MakeMKV-Stream-Nummern. */
|
||||||
|
export function spurenVorbelegt(spuren: readonly SpurAngabe[], wunsch: readonly string[], art: 'audio' | 'untertitel'): number[] {
|
||||||
|
const kandidaten = spuren.filter((s) => angeboten(s, art))
|
||||||
|
const wunschKlein = wunsch.map((w) => w.trim().toLowerCase()).filter((w) => w.length > 0)
|
||||||
|
if (art === 'audio') {
|
||||||
|
const gewaehlt: number[] = []
|
||||||
|
for (const w of wunschKlein) {
|
||||||
|
const erste = kandidaten.find((s) => s.lang.toLowerCase() === w)
|
||||||
|
if (erste !== undefined && !gewaehlt.includes(erste.nr)) gewaehlt.push(erste.nr)
|
||||||
|
}
|
||||||
|
if (gewaehlt.length === 0 && kandidaten.length > 0) gewaehlt.push(kandidaten[0].nr)
|
||||||
|
return gewaehlt
|
||||||
|
}
|
||||||
|
return kandidaten.filter((s) => wunschKlein.includes(s.lang.toLowerCase())).map((s) => s.nr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Spur, wie HandBrakes Scan sie in der Roh-Datei sieht (scan.ts). */
|
||||||
|
export interface HbSpurAngabe {
|
||||||
|
nr: number
|
||||||
|
lang: string
|
||||||
|
/** CodecName („dtshd") bzw. bei Untertiteln Source („PGS"). */
|
||||||
|
codec: string
|
||||||
|
kanaele: number
|
||||||
|
beschreibung: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpurZuordnung {
|
||||||
|
/** HandBrake-Spurnummern in der Reihenfolge der gewählten Spuren. */
|
||||||
|
nummern: number[]
|
||||||
|
/** Gewählte Spuren, die in der Roh-Datei nicht zu finden waren. */
|
||||||
|
fehlend: SpurAngabe[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gewählte MakeMKV-Spuren → HandBrake-Spurnummern (pur). Gestuft: Sprache
|
||||||
|
* + Familie + Kanäle → Sprache + Familie → Sprache + Kanäle → Sprache →
|
||||||
|
* (nur wenn die Datei GAR keine Sprachen kennt) Reihenfolge. Jede
|
||||||
|
* HandBrake-Spur nur einmal. */
|
||||||
|
export function spurenZuordnen(gewaehlt: readonly SpurAngabe[], vorhanden: readonly HbSpurAngabe[]): SpurZuordnung {
|
||||||
|
const frei = new Set(vorhanden.map((v) => v.nr))
|
||||||
|
const nummern: number[] = []
|
||||||
|
const fehlend: SpurAngabe[] = []
|
||||||
|
const ohneSprachen = vorhanden.every((v) => v.lang.length === 0 || v.lang === 'und')
|
||||||
|
for (const s of gewaehlt) {
|
||||||
|
const lang = s.lang.toLowerCase()
|
||||||
|
const familie = codecFamilie(s.codec)
|
||||||
|
const kanaele = Number(s.kanaele)
|
||||||
|
const stufen: Array<(v: HbSpurAngabe) => boolean> = [
|
||||||
|
(v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie && v.kanaele === kanaele,
|
||||||
|
(v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie,
|
||||||
|
(v) => v.lang.toLowerCase() === lang && v.kanaele === kanaele,
|
||||||
|
(v) => v.lang.toLowerCase() === lang,
|
||||||
|
]
|
||||||
|
if (ohneSprachen) stufen.push((v) => codecFamilie(v.codec) === familie && v.kanaele === kanaele, () => true)
|
||||||
|
let treffer: HbSpurAngabe | undefined
|
||||||
|
for (const passt of stufen) {
|
||||||
|
treffer = vorhanden.find((v) => frei.has(v.nr) && passt(v))
|
||||||
|
if (treffer !== undefined) break
|
||||||
|
}
|
||||||
|
if (treffer === undefined) {
|
||||||
|
fehlend.push(s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frei.delete(treffer.nr)
|
||||||
|
nummern.push(treffer.nr)
|
||||||
|
}
|
||||||
|
return { nummern, fehlend }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sprachen der gewählten Spuren, ohne Doppel, in Wahl-Reihenfolge —
|
||||||
|
* der Rückfall, wenn die Spuren in der Roh-Datei nicht zu finden sind. */
|
||||||
|
export function sprachenDerSpuren(spuren: readonly SpurAngabe[]): string[] {
|
||||||
|
const liste: string[] = []
|
||||||
|
for (const s of spuren) {
|
||||||
|
const l = s.lang.toLowerCase()
|
||||||
|
if (l.length > 0 && !liste.includes(l)) liste.push(l)
|
||||||
|
}
|
||||||
|
return liste
|
||||||
|
}
|
||||||
@@ -121,10 +121,18 @@ function start(): void {
|
|||||||
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
|
||||||
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
|
||||||
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
|
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
|
||||||
|
} else if (nachricht.art === 'kompression-stand') {
|
||||||
|
// 5.7.0 (F7): der leichte Fortschritts-Tick — dieselbe Wirkung.
|
||||||
|
kompressionVerarbeiten(nachricht.kompression.laeuft)
|
||||||
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
|
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
|
||||||
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
|
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
|
||||||
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
|
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
|
||||||
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
|
||||||
|
// 5.7.0 (F20): einmal je Disc und Titel — die Nachprüfung nach dem
|
||||||
|
// Info-Lauf und „Ändern …" schicken denselben Stand erneut.
|
||||||
|
const schluessel = `${nachricht.info.id}:${nachricht.info.titel}`
|
||||||
|
if (letzterErkennungsToast.get(nachricht.info.id) === schluessel) return
|
||||||
|
letzterErkennungsToast.set(nachricht.info.id, schluessel)
|
||||||
toast(
|
toast(
|
||||||
'Disc erkannt',
|
'Disc erkannt',
|
||||||
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
|
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
|
||||||
@@ -145,6 +153,8 @@ function start(): void {
|
|||||||
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
|
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
|
||||||
const KOMPRESSION = 'kompression'
|
const KOMPRESSION = 'kompression'
|
||||||
let kompressionStand: { titel: string; prozent: number } | null = null
|
let kompressionStand: { titel: string; prozent: number } | null = null
|
||||||
|
/** Je Laufwerk der zuletzt gemeldete Titel — gegen doppelte Toasts (F20). */
|
||||||
|
const letzterErkennungsToast = new Map<string, string>()
|
||||||
|
|
||||||
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
|
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
|
||||||
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
|
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
|
||||||
@@ -157,7 +167,8 @@ function start(): void {
|
|||||||
* die Kompression — sonst nichts. */
|
* die Kompression — sonst nichts. */
|
||||||
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
|
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
|
||||||
function anzeigeAktualisieren(): void {
|
function anzeigeAktualisieren(): void {
|
||||||
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
|
// 5.7.0 (F26): Das Rettungs-Abbild ist ein laufender Vorgang wie der Rip.
|
||||||
|
const rip = letzterRip !== null && (letzterRip.phase === 'rippt' || letzterRip.phase === 'rettet') ? letzterRip : null
|
||||||
if (fenster !== null && !fenster.isDestroyed()) {
|
if (fenster !== null && !fenster.isDestroyed()) {
|
||||||
if (rip !== null && rip.prozent >= 0) {
|
if (rip !== null && rip.prozent >= 0) {
|
||||||
fenster.setProgressBar(rip.prozent / 100)
|
fenster.setProgressBar(rip.prozent / 100)
|
||||||
@@ -181,7 +192,7 @@ function start(): void {
|
|||||||
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
|
||||||
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
|
||||||
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
|
||||||
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
|
if (status.phase === 'rippt' || status.phase === 'rettet' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
|
||||||
aktiveVorgaenge.add(status.id)
|
aktiveVorgaenge.add(status.id)
|
||||||
} else {
|
} else {
|
||||||
aktiveVorgaenge.delete(status.id)
|
aktiveVorgaenge.delete(status.id)
|
||||||
@@ -280,6 +291,28 @@ function start(): void {
|
|||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** „Beenden" im Tray (5.7.0, Durchsicht F15): Läuft ein Rip oder eine
|
||||||
|
* Kompression, fragt Rippy nach — app.quit() reißt über die Leine
|
||||||
|
* makemkvcon und HandBrake mit, und vierzig Minuten Rip wären weg. */
|
||||||
|
function beendenMitRueckfrage(): void {
|
||||||
|
if (aktiveVorgaenge.size === 0) {
|
||||||
|
beenden()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const laufend = kompressionStand !== null ? `die Kompression von „${kompressionStand.titel}"` : 'ein Rip'
|
||||||
|
const wahl = dialog.showMessageBoxSync({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Rippy beenden?',
|
||||||
|
message: `Gerade läuft ${laufend}.`,
|
||||||
|
detail: 'Beim Beenden bricht Rippy alles Laufende ab — der verlustfreie Rip und die halbe Kompression gehen verloren. Fenster schließen lässt Rippy im Infobereich weiterarbeiten.',
|
||||||
|
buttons: ['Weiterlaufen lassen', 'Trotzdem beenden'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
noLink: true,
|
||||||
|
})
|
||||||
|
if (wahl === 1) beenden()
|
||||||
|
}
|
||||||
|
|
||||||
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
|
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
|
||||||
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
|
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
|
||||||
// Fenster ↔ Kern in BEIDE Richtungen trägt.
|
// Fenster ↔ Kern in BEIDE Richtungen trägt.
|
||||||
@@ -389,7 +422,7 @@ function start(): void {
|
|||||||
void app.whenReady().then(() => {
|
void app.whenReady().then(() => {
|
||||||
kernVerwaltung.starten()
|
kernVerwaltung.starten()
|
||||||
if (!ohneNebenwirkungen) {
|
if (!ohneNebenwirkungen) {
|
||||||
trayErstellen(fensterZeigen, beenden)
|
trayErstellen(fensterZeigen, beendenMitRueckfrage)
|
||||||
}
|
}
|
||||||
fenster = fensterErstellen(app.getPath('userData'))
|
fenster = fensterErstellen(app.getPath('userData'))
|
||||||
fensterVerdrahten()
|
fensterVerdrahten()
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export class KernVerwaltung {
|
|||||||
private beendenGewollt = false
|
private beendenGewollt = false
|
||||||
private neustarts = 0
|
private neustarts = 0
|
||||||
private neustartZeiten: number[] = []
|
private neustartZeiten: number[] = []
|
||||||
|
/** Das Fenster, das zuletzt einen Port bekam — ein NEU gestarteter Kern
|
||||||
|
* bekommt es wieder (5.7.0, Durchsicht F4: vorher hielt das Fenster nach
|
||||||
|
* einem Kern-Neustart den toten Port und war taub, bis Rippy komplett neu
|
||||||
|
* gestartet wurde). */
|
||||||
|
private letztesFenster: WebContents | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly datenbankPfad: string,
|
private readonly datenbankPfad: string,
|
||||||
@@ -54,6 +59,11 @@ export class KernVerwaltung {
|
|||||||
|
|
||||||
kern.on('spawn', () => {
|
kern.on('spawn', () => {
|
||||||
this.beiZustand(this.zustand())
|
this.beiZustand(this.zustand())
|
||||||
|
// Ein Neustart: Das Fenster hängt noch am toten Port des alten Kerns —
|
||||||
|
// frischen Port aufspannen, sonst tut kein Knopf mehr etwas.
|
||||||
|
if (this.neustarts > 0 && this.letztesFenster !== null && !this.letztesFenster.isDestroyed()) {
|
||||||
|
this.fensterVerbinden(this.letztesFenster)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
kern.on('exit', (code) => {
|
kern.on('exit', (code) => {
|
||||||
@@ -80,6 +90,7 @@ export class KernVerwaltung {
|
|||||||
* ins Fenster (§ 4.1).
|
* ins Fenster (§ 4.1).
|
||||||
*/
|
*/
|
||||||
fensterVerbinden(webContents: WebContents): void {
|
fensterVerbinden(webContents: WebContents): void {
|
||||||
|
this.letztesFenster = webContents
|
||||||
if (this.kern === null) return
|
if (this.kern === null) return
|
||||||
const { port1, port2 } = new MessageChannelMain()
|
const { port1, port2 } = new MessageChannelMain()
|
||||||
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
const nachricht: HauptNachricht = { art: 'fenster-port' }
|
||||||
|
|||||||
@@ -45,3 +45,23 @@ export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, num
|
|||||||
return [n, gesamt.get(r) ?? 1]
|
return [n, gesamt.get(r) ?? 1]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein Name, der im Ordner noch frei ist (5.7.0, Durchsicht F3): `x.mkv`,
|
||||||
|
* sonst `x (2).mkv`, `x (3).mkv` … — nie eine vorhandene Datei
|
||||||
|
* überschreiben. Der Fall: Disc 2 einer Staffel legt `title_t00.mkv` in
|
||||||
|
* denselben Season-Ordner, in dem Disc 1 ihr `title_t00.mkv` noch trägt
|
||||||
|
* (Umbenennung ausgefallen) — HandBrake hätte sie still ersetzt. Pur:
|
||||||
|
* `existiert` kommt von außen; getrennt wird am letzten Punkt des Namens,
|
||||||
|
* Pfadtrenner beider Welten bleiben unangetastet. */
|
||||||
|
export function freierName(pfad: string, existiert: (p: string) => boolean): string {
|
||||||
|
if (!existiert(pfad)) return pfad
|
||||||
|
const trenner = Math.max(pfad.lastIndexOf('/'), pfad.lastIndexOf('\\'))
|
||||||
|
const punkt = pfad.lastIndexOf('.')
|
||||||
|
const stamm = punkt > trenner + 1 ? pfad.slice(0, punkt) : pfad
|
||||||
|
const endung = punkt > trenner + 1 ? pfad.slice(punkt) : ''
|
||||||
|
for (let n = 2; n < 1000; n++) {
|
||||||
|
const kandidat = `${stamm} (${n})${endung}`
|
||||||
|
if (!existiert(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
return `${stamm} (${Date.now()})${endung}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface NfoMeta {
|
|||||||
genres?: readonly string[]
|
genres?: readonly string[]
|
||||||
laufzeitMinuten?: number
|
laufzeitMinuten?: number
|
||||||
quelle?: string
|
quelle?: string
|
||||||
|
/** 5.7.0 (F23): die Kennung bei TMDb (Zahl) oder IMDb (tt…) — Jellyfin,
|
||||||
|
* Emby und Kodi lesen sie als <uniqueid> und müssen nicht mehr über
|
||||||
|
* Titel und Jahr raten (kodi.wiki NFO files: uniqueid type=tmdb|imdb). */
|
||||||
|
kennung?: { quelle: string; id: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function xml(wert: string): string {
|
function xml(wert: string): string {
|
||||||
@@ -40,6 +44,10 @@ export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | n
|
|||||||
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
|
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
|
||||||
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
|
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
|
||||||
}
|
}
|
||||||
|
if (meta.kennung !== undefined && meta.kennung.id.length > 0) {
|
||||||
|
const typ = meta.kennung.id.startsWith('tt') ? 'imdb' : meta.kennung.quelle === 'omdb' ? 'imdb' : 'tmdb'
|
||||||
|
zeilen.push(` <uniqueid type="${typ}" default="true">${xml(meta.kennung.id)}</uniqueid>`)
|
||||||
|
}
|
||||||
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
|
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
|
||||||
zeilen.push(`</${wurzel}>`)
|
zeilen.push(`</${wurzel}>`)
|
||||||
return zeilen.join('\n') + '\n'
|
return zeilen.join('\n') + '\n'
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function ordnerGroesse(pfad: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
|
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
|
||||||
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] {
|
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>, groesse: (pfad: string) => number = ordnerGroesse): FremderOrdner[] {
|
||||||
if (!existsSync(rohWurzel)) return []
|
if (!existsSync(rohWurzel)) return []
|
||||||
const liste: FremderOrdner[] = []
|
const liste: FremderOrdner[] = []
|
||||||
let namen: string[]
|
let namen: string[]
|
||||||
@@ -182,7 +182,7 @@ export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>):
|
|||||||
for (const name of namen) {
|
for (const name of namen) {
|
||||||
const voll = join(rohWurzel, name)
|
const voll = join(rohWurzel, name)
|
||||||
if (bekannte.has(voll) || bekannte.has(name)) continue
|
if (bekannte.has(voll) || bekannte.has(name)) continue
|
||||||
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) })
|
liste.push({ ordner: voll, bytes: groesse(voll) })
|
||||||
}
|
}
|
||||||
return liste.sort((a, b) => b.bytes - a.bytes)
|
return liste.sort((a, b) => b.bytes - a.bytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
|
|||||||
export { sichererName }
|
export { sichererName }
|
||||||
|
|
||||||
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
||||||
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
|
* gerippt), wird „(2)", „(3)" … angehängt statt zu überschreiben — 5.7.0
|
||||||
|
* (F24): vorher die ersten acht Zeichen der Vorgangskennung, also
|
||||||
|
* „Akira (1988) [bluray-2]", was niemandem etwas sagte. */
|
||||||
export function zielordner(
|
export function zielordner(
|
||||||
basis: string,
|
basis: string,
|
||||||
titel: string,
|
titel: string,
|
||||||
@@ -29,8 +31,12 @@ export function zielordner(
|
|||||||
const name = sichererName(titel, jahr)
|
const name = sichererName(titel, jahr)
|
||||||
if (name.length === 0) return join(basis, jobId)
|
if (name.length === 0) return join(basis, jobId)
|
||||||
const ziel = join(basis, name)
|
const ziel = join(basis, name)
|
||||||
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
|
if (!existiert(ziel)) return ziel
|
||||||
return ziel
|
for (let n = 2; n < 100; n++) {
|
||||||
|
const kandidat = join(basis, `${name} (${n})`)
|
||||||
|
if (!existiert(kandidat)) return kandidat
|
||||||
|
}
|
||||||
|
return join(basis, `${name} [${jobId.slice(0, 8)}]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
||||||
@@ -84,7 +90,14 @@ export function matcheEpisoden(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
|
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
|
||||||
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
|
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge).
|
||||||
|
*
|
||||||
|
* 5.7.0 (Durchsicht F3): `nurDiese` nennt die Dateien DIESER Disc (nur
|
||||||
|
* Namen, in Ablage-Reihenfolge). Vorher zählte die Funktion ALLE MKVs im
|
||||||
|
* Season-Ordner — und sobald Disc 1 dort schon S01E01/S01E02 abgelegt
|
||||||
|
* hatte, hieß es bei Disc 2 „übersprungen: 4 Dateien, 2 Zuordnungen".
|
||||||
|
* Der Serien-Merker lief damit ab der zweiten Disc ins Leere. Ohne
|
||||||
|
* `nurDiese` gilt das alte Verhalten (alle MKVs des Ordners). */
|
||||||
export function episodenUmbenennen(
|
export function episodenUmbenennen(
|
||||||
ordner: string,
|
ordner: string,
|
||||||
serie: string,
|
serie: string,
|
||||||
@@ -99,12 +112,16 @@ export function episodenUmbenennen(
|
|||||||
existiert: existsSync,
|
existiert: existsSync,
|
||||||
umbenennen: renameSync,
|
umbenennen: renameSync,
|
||||||
},
|
},
|
||||||
|
nurDiese?: readonly string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
const meldungen: string[] = []
|
const meldungen: string[] = []
|
||||||
const dateien = fs
|
const dateien =
|
||||||
.listen(ordner)
|
nurDiese !== undefined
|
||||||
.filter((f) => f.endsWith('.mkv'))
|
? [...nurDiese]
|
||||||
.sort()
|
: fs
|
||||||
|
.listen(ordner)
|
||||||
|
.filter((f) => f.endsWith('.mkv'))
|
||||||
|
.sort()
|
||||||
if (dateien.length !== zuordnung.length) {
|
if (dateien.length !== zuordnung.length) {
|
||||||
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
|
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
|
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
|
||||||
import { rollenNummern, zielDateiname } from '../ablage/dateinamen'
|
import { freierName, rollenNummern, zielDateiname } from '../ablage/dateinamen'
|
||||||
import { baueNfo } from '../ablage/nfo'
|
import { baueNfo } from '../ablage/nfo'
|
||||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||||
import { posterSpeichern } from '../ablage/poster'
|
import { posterSpeichern } from '../ablage/poster'
|
||||||
@@ -46,8 +46,12 @@ import {
|
|||||||
folgenNummern,
|
folgenNummern,
|
||||||
folgenPlausibel,
|
folgenPlausibel,
|
||||||
gewichteterFortschritt,
|
gewichteterFortschritt,
|
||||||
|
sprachlistenAusSpuren,
|
||||||
sprachwahl,
|
sprachwahl,
|
||||||
|
spurwahl,
|
||||||
type KompressionsAuftrag,
|
type KompressionsAuftrag,
|
||||||
|
type RohDatei,
|
||||||
|
type SpurWahl,
|
||||||
} from './auftrag'
|
} from './auftrag'
|
||||||
|
|
||||||
export interface AblageUmgebung {
|
export interface AblageUmgebung {
|
||||||
@@ -103,6 +107,24 @@ export function dateiVerschieben(von: string, nach: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Sprachlisten aus den gewählten Spuren, als Felder für sprachwahl. */
|
||||||
|
function sprachlistenAusSpurenFuerWahl(datei: RohDatei): { audioSprachen: string[]; untertitelSprachen: string[] } {
|
||||||
|
const listen = sprachlistenAusSpuren(datei)
|
||||||
|
return { audioSprachen: listen.audio, untertitelSprachen: listen.untertitel }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Zusatz in der Fortschrittszeile: „ · Ton 1,3 · Untertitel 2 (vorgewählt)". */
|
||||||
|
function spurText(spurNr: SpurWahl | null, wahl: { audio: string[]; untertitel: string[]; untertitelStandard: boolean }): string {
|
||||||
|
if (spurNr !== null) {
|
||||||
|
return `${spurNr.audioNr.length > 0 ? ` · Ton Spur ${spurNr.audioNr.join(',')}` : ' · Ton: erste Spur'}${
|
||||||
|
spurNr.untertitelNr.length > 0 ? ` · Untertitel Spur ${spurNr.untertitelNr.join(',')}${spurNr.untertitelStandard > 0 ? ' (vorgewählt)' : ''}` : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
return `${wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''}${
|
||||||
|
wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
function groesseVon(pfad: string): number {
|
function groesseVon(pfad: string): number {
|
||||||
try {
|
try {
|
||||||
return statSync(pfad).size
|
return statSync(pfad).size
|
||||||
@@ -121,7 +143,7 @@ export async function komprimierenUndAblegen(
|
|||||||
): Promise<AblageErgebnis> {
|
): Promise<AblageErgebnis> {
|
||||||
const einstellungen = umgebung.einstellungen()
|
const einstellungen = umgebung.einstellungen()
|
||||||
const handbrake = umgebung.handbrake()
|
const handbrake = umgebung.handbrake()
|
||||||
const soll = komprimierenFuer(auftrag.discTyp, einstellungen)
|
const soll = komprimierenFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
|
||||||
const komprimiert = soll && handbrake.length > 0
|
const komprimiert = soll && handbrake.length > 0
|
||||||
const preset =
|
const preset =
|
||||||
auftrag.preset.length > 0
|
auftrag.preset.length > 0
|
||||||
@@ -189,24 +211,35 @@ export async function komprimierenUndAblegen(
|
|||||||
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||||
// Episoden-Zuordnung weiter unten.
|
// Episoden-Zuordnung weiter unten.
|
||||||
const folgenSekunden: number[] = []
|
const folgenSekunden: number[] = []
|
||||||
|
/** Die Folgen DIESER Disc (Zielnamen, Ablage-Reihenfolge) — nur die werden
|
||||||
|
* umbenannt, nicht der ganze Season-Ordner (5.7.0, F3). */
|
||||||
|
const folgenDateien: string[] = []
|
||||||
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
|
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
|
||||||
// ihren Namen bis zur Episoden-Zuordnung.
|
// ihren Namen bis zur Episoden-Zuordnung.
|
||||||
const nummern = rollenNummern(dateien.map((d) => d.rolle))
|
const nummern = rollenNummern(dateien.map((d) => d.rolle))
|
||||||
|
// Beim Wiederholen darf die neue Fassung den alten Namen tragen — die alte
|
||||||
|
// Datei fällt erst, wenn die neue fertig ist (unten). Alles andere, was
|
||||||
|
// schon dasteht, wird NIE überschrieben (F3): dann „x (2).mkv".
|
||||||
|
const ersetzt = new Set(auftrag.ersetzt ?? [])
|
||||||
|
const freiOderErsetzt = (pfad: string): string => (ersetzt.has(pfad) ? pfad : freierName(pfad, existsSync))
|
||||||
for (let i = 0; i < dateien.length; i++) {
|
for (let i = 0; i < dateien.length; i++) {
|
||||||
const datei = dateien[i]
|
const datei = dateien[i]
|
||||||
const [nummer, anzahl] = nummern[i]
|
const [nummer, anzahl] = nummern[i]
|
||||||
const zielDatei = join(
|
const zielDatei = freiOderErsetzt(
|
||||||
zielFuer(datei.rolle),
|
join(
|
||||||
zielDateiname({
|
zielFuer(datei.rolle),
|
||||||
titel: zuordnung?.titel ?? '',
|
zielDateiname({
|
||||||
jahr: zuordnung?.jahr ?? null,
|
titel: zuordnung?.titel ?? '',
|
||||||
alsSerie,
|
jahr: zuordnung?.jahr ?? null,
|
||||||
rolle: datei.rolle,
|
alsSerie,
|
||||||
nummer,
|
rolle: datei.rolle,
|
||||||
anzahl,
|
nummer,
|
||||||
original: basename(datei.pfad),
|
anzahl,
|
||||||
}),
|
original: basename(datei.pfad),
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if (datei.rolle === 'folge') folgenDateien.push(basename(zielDatei))
|
||||||
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||||
if (!komprimiert) {
|
if (!komprimiert) {
|
||||||
melden({
|
melden({
|
||||||
@@ -228,13 +261,34 @@ export async function komprimierenUndAblegen(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const wahl = sprachwahl(datei, global)
|
let wahl = sprachwahl(datei, global)
|
||||||
|
// 5.7.0: Spuren EINZELN gewählt — die Roh-Datei wird gescannt und jede
|
||||||
|
// gewählte MakeMKV-Spur als HandBrake-Spurnummer wiedergefunden
|
||||||
|
// (auftrag.ts spurwahl). Scheitert der Scan, gelten die Sprachen der
|
||||||
|
// gewählten Spuren als Liste (wie 5.4.0–5.6.x).
|
||||||
|
const einzeln = datei.audioSpuren !== undefined || datei.untertitelSpuren !== undefined
|
||||||
|
let spurNr: SpurWahl | null = null
|
||||||
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
|
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
|
||||||
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
|
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
|
||||||
// globale Vorgabe war schon immer „was da ist".
|
// globale Vorgabe war schon immer „was da ist".
|
||||||
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
|
if (einzeln || datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
|
||||||
const spuren = await scannen(handbrake, datei.pfad)
|
const spuren = await scannen(handbrake, datei.pfad)
|
||||||
if (spuren !== null) {
|
if (spuren !== null && einzeln) {
|
||||||
|
spurNr = spurwahl(datei, spuren, global.audio[0] ?? '')
|
||||||
|
for (const f of spurNr?.fehlend ?? []) {
|
||||||
|
hinweise.push(`${basename(datei.pfad)}: ${f} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`)
|
||||||
|
}
|
||||||
|
if (spurNr !== null && spurNr.audioNr.length === 0 && (datei.audioSpuren?.length ?? 0) > 0) {
|
||||||
|
// Keine gewählte Tonspur gefunden → lieber Sprachlisten als eine
|
||||||
|
// stumme Datei.
|
||||||
|
spurNr = null
|
||||||
|
wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
|
||||||
|
hinweise.push(`${basename(datei.pfad)}: keine der gewählten Tonspuren gefunden — Sprachen ${wahl.audio.join(',') || 'nach Einstellung'} statt Spuren.`)
|
||||||
|
}
|
||||||
|
} else if (spuren === null && einzeln) {
|
||||||
|
wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
|
||||||
|
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — Sprachen ${[...wahl.audio, ...wahl.untertitel].join(',') || 'nach Einstellung'} statt einzelner Spuren.`)
|
||||||
|
} else if (spuren !== null) {
|
||||||
const fehlt = fehlendeSprachen(spuren, wahl)
|
const fehlt = fehlendeSprachen(spuren, wahl)
|
||||||
if (fehlt.audio.length > 0) {
|
if (fehlt.audio.length > 0) {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
@@ -254,9 +308,7 @@ export async function komprimierenUndAblegen(
|
|||||||
phase: 'komprimiert',
|
phase: 'komprimiert',
|
||||||
prozent: gewichteterFortschritt(gewichte, i, 0),
|
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||||
restS: -1,
|
restS: -1,
|
||||||
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
|
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${spurText(spurNr, wahl)} …`,
|
||||||
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
|
|
||||||
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} …`,
|
|
||||||
})
|
})
|
||||||
const encode = await komprimieren({
|
const encode = await komprimieren({
|
||||||
programm: handbrake,
|
programm: handbrake,
|
||||||
@@ -266,6 +318,12 @@ export async function komprimierenUndAblegen(
|
|||||||
audioSprachen: wahl.audio,
|
audioSprachen: wahl.audio,
|
||||||
untertitelSprachen: wahl.untertitel,
|
untertitelSprachen: wahl.untertitel,
|
||||||
untertitelStandard: wahl.untertitelStandard,
|
untertitelStandard: wahl.untertitelStandard,
|
||||||
|
...(spurNr !== null
|
||||||
|
? { audioNr: spurNr.audioNr, untertitelNr: spurNr.untertitelNr, untertitelStandardNr: spurNr.untertitelStandard }
|
||||||
|
: {}),
|
||||||
|
// Beim Wiederholen darf die neue Fassung die alte gleichen Namens
|
||||||
|
// ersetzen (ohne Arbeitsordner schreibt HandBrake direkt ins Ziel).
|
||||||
|
ueberschreiben: zwischen.length === 0 && ersetzt.has(zielDatei),
|
||||||
abbruch,
|
abbruch,
|
||||||
aufFortschritt: (prozent, restS) => {
|
aufFortschritt: (prozent, restS) => {
|
||||||
melden({
|
melden({
|
||||||
@@ -346,7 +404,7 @@ export async function komprimierenUndAblegen(
|
|||||||
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
||||||
}
|
}
|
||||||
if (episoden !== null) {
|
if (episoden !== null) {
|
||||||
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
|
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden, undefined, folgenDateien))
|
||||||
folgenVergeben = episoden
|
folgenVergeben = episoden
|
||||||
} else {
|
} else {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
@@ -372,6 +430,7 @@ export async function komprimierenUndAblegen(
|
|||||||
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
||||||
quelle:
|
quelle:
|
||||||
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
||||||
|
kennung: zuordnung === null || zuordnung.meta.id === null ? undefined : { quelle: zuordnung.meta.quelle, id: String(zuordnung.meta.id) },
|
||||||
},
|
},
|
||||||
anzeigeTitel,
|
anzeigeTitel,
|
||||||
zuordnung?.jahr ?? null,
|
zuordnung?.jahr ?? null,
|
||||||
@@ -383,7 +442,10 @@ export async function komprimierenUndAblegen(
|
|||||||
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
|
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
|
||||||
}
|
}
|
||||||
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
||||||
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
// 5.7.0 (F21): Was mit dem Poster passiert ist, steht in den Hinweisen
|
||||||
|
// — vorher wurde die Antwort verschluckt (R4).
|
||||||
|
const posterErgebnis = await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
||||||
|
if (posterErgebnis.length > 0 && !posterErgebnis.startsWith('poster.jpg')) hinweise.push(posterErgebnis)
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = String(einstellungen['mediaServer'] ?? '')
|
const server = String(einstellungen['mediaServer'] ?? '')
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
|
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
|
||||||
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
|
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
|
||||||
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
|
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
|
||||||
import type { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
|
import type { MetaVorschlag, SpurAngabe, TitelRolle } from '../../gemeinsam/nachrichten'
|
||||||
|
import { istSpurAngabe } from '../../gemeinsam/nachrichten'
|
||||||
|
import { spurenZuordnen, sprachenDerSpuren, type HbSpurAngabe } from '../../gemeinsam/spuren'
|
||||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||||
|
|
||||||
export interface RohDatei {
|
export interface RohDatei {
|
||||||
@@ -21,6 +23,11 @@ export interface RohDatei {
|
|||||||
untertitelSprachen: string[]
|
untertitelSprachen: string[]
|
||||||
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
|
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
|
||||||
untertitelVorwahl?: string | null
|
untertitelVorwahl?: string | null
|
||||||
|
/** 5.7.0: die GEWÄHLTEN Spuren (einzeln); fehlend = Sprachlisten gelten. */
|
||||||
|
audioSpuren?: SpurAngabe[]
|
||||||
|
untertitelSpuren?: SpurAngabe[]
|
||||||
|
/** 5.7.0: vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
|
||||||
|
untertitelVorwahlSpur?: number | null
|
||||||
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
|
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
|
||||||
film?: MetaVorschlag
|
film?: MetaVorschlag
|
||||||
}
|
}
|
||||||
@@ -86,6 +93,9 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
|
|||||||
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
|
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
|
||||||
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
|
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
|
||||||
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
|
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
|
||||||
|
...(Array.isArray(e.audioSpuren) && e.audioSpuren.every(istSpurAngabe) ? { audioSpuren: e.audioSpuren } : {}),
|
||||||
|
...(Array.isArray(e.untertitelSpuren) && e.untertitelSpuren.every(istSpurAngabe) ? { untertitelSpuren: e.untertitelSpuren } : {}),
|
||||||
|
...(e.untertitelVorwahlSpur === null || typeof e.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: e.untertitelVorwahlSpur } : {}),
|
||||||
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
|
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -162,6 +172,69 @@ export function sprachwahl(
|
|||||||
return { audio, untertitel, untertitelStandard }
|
return { audio, untertitel, untertitelStandard }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was HandBrake bekommt, wenn Spuren EINZELN gewählt wurden (5.7.0). */
|
||||||
|
export interface SpurWahl {
|
||||||
|
/** HandBrake-Spurnummern (1-basiert, Scan-Reihenfolge). */
|
||||||
|
audioNr: number[]
|
||||||
|
untertitelNr: number[]
|
||||||
|
/** Index (1-basiert) der vorgewählten Untertitelspur in `untertitelNr`, 0 = keine. */
|
||||||
|
untertitelStandard: number
|
||||||
|
/** Klartext für die Hinweise: gewählte Spuren, die nicht in der Datei sind. */
|
||||||
|
fehlend: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spurwahl je Datei (pur, 5.7.0): Die gewählten MakeMKV-Spuren werden in
|
||||||
|
* HandBrakes Scan der Roh-Datei wiedergefunden (spurenZuordnen). Die Regel
|
||||||
|
* von 5.4.0 gilt weiter: Spielt die erste gewählte Tonspur nicht in der
|
||||||
|
* Wunschsprache, wird eine gewählte Untertitelspur der Wunschsprache
|
||||||
|
* vorgewählt — außer der Nutzer hat eine Vorwahl gesetzt (Spur) oder
|
||||||
|
* ausdrücklich keine (null). Liefert null, wenn keine Spuren gewählt sind
|
||||||
|
* (dann gilt die Sprachwahl). */
|
||||||
|
export function spurwahl(
|
||||||
|
datei: Pick<RohDatei, 'audioSpuren' | 'untertitelSpuren' | 'untertitelVorwahlSpur'>,
|
||||||
|
scan: { audio: readonly HbSpurAngabe[]; untertitel: readonly HbSpurAngabe[] },
|
||||||
|
wunschSprache: string,
|
||||||
|
): SpurWahl | null {
|
||||||
|
if (datei.audioSpuren === undefined && datei.untertitelSpuren === undefined) return null
|
||||||
|
const audio = spurenZuordnen(datei.audioSpuren ?? [], scan.audio)
|
||||||
|
const untertitel = spurenZuordnen(datei.untertitelSpuren ?? [], scan.untertitel)
|
||||||
|
const gefundeneUntertitel = (datei.untertitelSpuren ?? []).filter((s) => !untertitel.fehlend.includes(s))
|
||||||
|
// Wer wird vorgewählt?
|
||||||
|
let standardSpur: SpurAngabe | undefined
|
||||||
|
if (datei.untertitelVorwahlSpur === null) {
|
||||||
|
standardSpur = undefined
|
||||||
|
} else if (typeof datei.untertitelVorwahlSpur === 'number') {
|
||||||
|
standardSpur = gefundeneUntertitel.find((s) => s.nr === datei.untertitelVorwahlSpur)
|
||||||
|
} else {
|
||||||
|
const ersteTon = (datei.audioSpuren ?? []).find((s) => !audio.fehlend.includes(s))
|
||||||
|
const tonFremd = wunschSprache.length > 0 && ersteTon !== undefined && ersteTon.lang.toLowerCase() !== wunschSprache
|
||||||
|
if (tonFremd) standardSpur = gefundeneUntertitel.find((s) => s.lang.toLowerCase() === wunschSprache)
|
||||||
|
}
|
||||||
|
// Die vorgewählte Spur nach vorn — HandBrakes --subtitle-default zählt
|
||||||
|
// die AUSGABE-Reihenfolge, und die folgt der Liste.
|
||||||
|
let untertitelNr = untertitel.nummern
|
||||||
|
let untertitelStandard = 0
|
||||||
|
if (standardSpur !== undefined) {
|
||||||
|
const i = gefundeneUntertitel.indexOf(standardSpur)
|
||||||
|
if (i >= 0) {
|
||||||
|
untertitelNr = [untertitel.nummern[i], ...untertitel.nummern.filter((_, k) => k !== i)]
|
||||||
|
untertitelStandard = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fehlend = [
|
||||||
|
...audio.fehlend.map((s) => `Tonspur ${s.sprache || s.lang}${s.codec.length > 0 ? ` ${s.codec}` : ''}`),
|
||||||
|
...untertitel.fehlend.map((s) => `Untertitel ${s.sprache || s.lang}${s.beschreibung.includes('erzwungen') ? ' (nur erzwungene)' : ''}`),
|
||||||
|
]
|
||||||
|
return { audioNr: audio.nummern, untertitelNr, untertitelStandard, fehlend }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Sprachlisten aus gewählten Spuren — der Rückfall, wenn der Scan der
|
||||||
|
* Roh-Datei scheitert (pur). */
|
||||||
|
export function sprachlistenAusSpuren(datei: Pick<RohDatei, 'audioSpuren' | 'untertitelSpuren'>): { audio: string[]; untertitel: string[] } {
|
||||||
|
return { audio: sprachenDerSpuren(datei.audioSpuren ?? []), untertitel: sprachenDerSpuren(datei.untertitelSpuren ?? []) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
|
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
|
||||||
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
|
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
|
||||||
const start = Math.max(1, Math.trunc(ersteFolge))
|
const start = Math.max(1, Math.trunc(ersteFolge))
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
|
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
|
||||||
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
|
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
|
||||||
// aus dem Abbild (iso:), das nicht mehr hängen kann.
|
// aus dem Abbild (iso:), das nicht mehr hängen kann.
|
||||||
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
import type {
|
import type {
|
||||||
BibliothekEintrag,
|
BibliothekEintrag,
|
||||||
|
DiscTyp,
|
||||||
GeraeteInfo,
|
GeraeteInfo,
|
||||||
RipAuftragOptionen,
|
RipAuftragOptionen,
|
||||||
RipAuftragTitel,
|
RipAuftragTitel,
|
||||||
@@ -39,6 +40,7 @@ import { mkdtempSync, createWriteStream } from 'node:fs'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
||||||
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
|
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
|
||||||
|
import { BLURAY_MIN_BYTES } from '../laufwerk/disc'
|
||||||
|
|
||||||
interface LaufenderVorgang {
|
interface LaufenderVorgang {
|
||||||
/** Beendet den GANZEN Auftrag. */
|
/** Beendet den GANZEN Auftrag. */
|
||||||
@@ -70,14 +72,15 @@ export interface PipelineUmgebung {
|
|||||||
flac: () => string
|
flac: () => string
|
||||||
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
|
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
|
||||||
audioToc?: (id: string) => Spur[]
|
audioToc?: (id: string) => Spur[]
|
||||||
/** Rohe Audio-Sektoren lesen (W-7). */
|
/** Rohe Audio-Sektoren lesen (W-7) — seit 5.7.0 asynchron mit Abbruch (F30). */
|
||||||
audioSpurLesen?: (
|
audioSpurLesen?: (
|
||||||
id: string,
|
id: string,
|
||||||
start: number,
|
start: number,
|
||||||
sektoren: number,
|
sektoren: number,
|
||||||
schreiben: (daten: Buffer) => void,
|
schreiben: (daten: Buffer) => void,
|
||||||
fortschritt?: (anteil: number) => void,
|
fortschritt?: (anteil: number) => void,
|
||||||
) => number
|
abbruch?: AbortSignal,
|
||||||
|
) => Promise<number>
|
||||||
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
||||||
geraet: (id: string) => GeraeteInfo | null
|
geraet: (id: string) => GeraeteInfo | null
|
||||||
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
|
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
|
||||||
@@ -114,6 +117,11 @@ export interface PipelineUmgebung {
|
|||||||
rettungsAbbild?: typeof rettungsAbbildDatei
|
rettungsAbbild?: typeof rettungsAbbildDatei
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Das Rettungs-Abbild und sein Bericht — die zwei Dateien im Roh-Ordner,
|
||||||
|
* die KEIN Aufräumen anfasst (5.7.0, Durchsicht F8). */
|
||||||
|
export const RETTUNG_ISO = 'rettung.iso'
|
||||||
|
export const RETTUNG_DATEIEN: readonly string[] = [RETTUNG_ISO, `${RETTUNG_ISO}.rettung.txt`]
|
||||||
|
|
||||||
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
|
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
|
||||||
interface LetzterAuftrag {
|
interface LetzterAuftrag {
|
||||||
auftrag: RipAuftragTitel[]
|
auftrag: RipAuftragTitel[]
|
||||||
@@ -180,12 +188,52 @@ export class Pipeline {
|
|||||||
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
|
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher).
|
||||||
|
*
|
||||||
|
* 5.7.0 (Durchsicht 12.09.2026, F2): Löst NIE mit einer Ausnahme auf.
|
||||||
|
* Vorher warf `mkdirSync` bei nicht erreichbarer Ablage aus dieser
|
||||||
|
* Funktion heraus, der Aufrufer (`void … .then(() => starten())`)
|
||||||
|
* verschluckte das — kein Fehler im Fenster, keine Zeile im Protokoll,
|
||||||
|
* der Dialog ging zu, nichts passierte (Sonde 10). Jetzt wird jede
|
||||||
|
* Ausnahme zur Fehler-Meldung des Laufwerks (R4). */
|
||||||
async starten(
|
async starten(
|
||||||
id: string,
|
id: string,
|
||||||
auftrag: readonly RipAuftragTitel[] = [],
|
auftrag: readonly RipAuftragTitel[] = [],
|
||||||
optionen: RipAuftragOptionen = {},
|
optionen: RipAuftragOptionen = {},
|
||||||
rettung = false,
|
rettung = false,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.lauf(id, auftrag, optionen, rettung)
|
||||||
|
} catch (fehler) {
|
||||||
|
const vorgangId = this.laufend.get(id)?.status.vorgangId
|
||||||
|
this.laufend.delete(id)
|
||||||
|
const text = `Rip auf Laufwerk ${id} abgebrochen — unerwarteter Fehler: ${String(fehler).slice(0, 300)}`
|
||||||
|
if (vorgangId !== undefined) this.umgebung.vorgangEndet?.(vorgangId, 'fehler', text)
|
||||||
|
this.umgebung.melden({ ...this.fehler(id, text), ...(vorgangId !== undefined ? { vorgangId } : {}) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist dieser Ordner da und beschreibbar? Eine Ablage auf einer NAS oder
|
||||||
|
* einem Netzlaufwerk ist beim Start nicht selbstverständlich erreichbar —
|
||||||
|
* ausprobieren statt annehmen (Regel aus AGENTS: Wo eine Eigenschaft
|
||||||
|
* ausprobierbar ist, probiere sie aus). '' wenn alles gut, sonst der Grund. */
|
||||||
|
static schreibprobe(ordner: string): string {
|
||||||
|
try {
|
||||||
|
mkdirSync(ordner, { recursive: true })
|
||||||
|
const probe = join(ordner, `.rippy-schreibprobe-${process.pid}`)
|
||||||
|
writeFileSync(probe, 'rippy')
|
||||||
|
unlinkSync(probe)
|
||||||
|
return ''
|
||||||
|
} catch (fehler) {
|
||||||
|
return String(fehler instanceof Error ? fehler.message : fehler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lauf(
|
||||||
|
id: string,
|
||||||
|
auftrag: readonly RipAuftragTitel[],
|
||||||
|
optionen: RipAuftragOptionen,
|
||||||
|
rettung: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.laufend.has(id)) {
|
if (this.laufend.has(id)) {
|
||||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||||
@@ -226,6 +274,21 @@ export class Pipeline {
|
|||||||
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||||
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
|
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
|
||||||
|
|
||||||
|
// 5.7.0 (F2): Erst nachsehen, ob das Ziel überhaupt beschreibbar ist —
|
||||||
|
// eine NAS, die gerade weg ist, soll einen Satz in der Kachel
|
||||||
|
// hinterlassen, nicht ein stilles Nichts.
|
||||||
|
const zielWurzel = geraet.typ === 'cd' || geraet.typ === 'unknown' ? this.umgebung.ablage() : (this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'))
|
||||||
|
const probe = Pipeline.schreibprobe(zielWurzel)
|
||||||
|
if (probe.length > 0) {
|
||||||
|
this.umgebung.melden(
|
||||||
|
this.fehler(
|
||||||
|
id,
|
||||||
|
`Rip nicht gestartet: Der Ordner ${zielWurzel} ist nicht erreichbar oder nicht beschreibbar (${probe}). Ist das Netzlaufwerk bzw. die NAS verbunden? Einstellungen → Ablage zeigt die Pfade.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
|
||||||
if (geraet.typ === 'cd') {
|
if (geraet.typ === 'cd') {
|
||||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
|
||||||
@@ -233,13 +296,22 @@ export class Pipeline {
|
|||||||
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
await this.audioCdRippen(id, geraet, startZeit, abbruch)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (geraet.typ === 'unknown') {
|
// 5.7.0 (F10): „unbekannt" heißt nur dann ISO, wenn NIEMAND Titel gewählt
|
||||||
|
// hat. Liegt ein Titel-Auftrag vor, hat makemkvcon die Disc gerade
|
||||||
|
// gelesen — dann ist es eine Video-Disc, egal was der Windows-Steuercode
|
||||||
|
// zum Typ sagte (bei manchen USB-Brücken antwortet er gar nicht).
|
||||||
|
if (geraet.typ === 'unknown' && auftrag.length === 0) {
|
||||||
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
|
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
|
||||||
this.melden(this.laufend.get(id)!.status)
|
this.melden(this.laufend.get(id)!.status)
|
||||||
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc: nach der Größe
|
||||||
|
// ist es eine Blu-ray oder DVD — der Typ steuert nur noch Preset und
|
||||||
|
// Ordnername.
|
||||||
|
const discTyp: DiscTyp = geraet.typ === 'unknown' ? (geraet.groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd') : geraet.typ
|
||||||
|
|
||||||
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
||||||
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
|
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
|
||||||
// behält seinen Zeitstempel-Namen als Kennung.
|
// behält seinen Zeitstempel-Namen als Kennung.
|
||||||
@@ -260,7 +332,7 @@ export class Pipeline {
|
|||||||
this.umgebung.vorgangBeginnt?.({
|
this.umgebung.vorgangBeginnt?.({
|
||||||
id: vorgangsName,
|
id: vorgangsName,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
typ: geraet.typ,
|
typ: discTyp,
|
||||||
titel: anzeigeTitel,
|
titel: anzeigeTitel,
|
||||||
jahr: zuordnung?.jahr ?? null,
|
jahr: zuordnung?.jahr ?? null,
|
||||||
rohOrdner,
|
rohOrdner,
|
||||||
@@ -279,10 +351,20 @@ export class Pipeline {
|
|||||||
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
|
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
|
||||||
this.laufend.delete(id)
|
this.laufend.delete(id)
|
||||||
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
|
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
|
||||||
// Punkt „Aufräumen nach Abbruch").
|
// Punkt „Aufräumen nach Abbruch"). 5.7.0 (Durchsicht F8): AUSSER dem
|
||||||
|
// Rettungs-Abbild — dafür wurde die Disc stundenlang Sektor für Sektor
|
||||||
|
// gelesen; es bleibt liegen, auch wenn der Rip daraus leer ausging.
|
||||||
if (alleDateien.size === 0) {
|
if (alleDateien.size === 0) {
|
||||||
try {
|
try {
|
||||||
rmSync(rohOrdner, { recursive: true, force: true })
|
const rettungDa = existsSync(join(rohOrdner, RETTUNG_ISO))
|
||||||
|
if (rettungDa) {
|
||||||
|
for (const name of dateienIn(rohOrdner)) {
|
||||||
|
if (!RETTUNG_DATEIEN.includes(name)) rmSync(join(rohOrdner, name), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
text += ` Das Rettungs-Abbild bleibt unter ${rohOrdner}.`
|
||||||
|
} else {
|
||||||
|
rmSync(rohOrdner, { recursive: true, force: true })
|
||||||
|
}
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
|
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
|
||||||
}
|
}
|
||||||
@@ -302,7 +384,7 @@ export class Pipeline {
|
|||||||
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
|
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
|
||||||
let quellePfad = geraet.pfad
|
let quellePfad = geraet.pfad
|
||||||
if (rettung) {
|
if (rettung) {
|
||||||
const isoPfad = join(rohOrdner, 'rettung.iso')
|
const isoPfad = join(rohOrdner, RETTUNG_ISO)
|
||||||
this.melden({
|
this.melden({
|
||||||
id,
|
id,
|
||||||
phase: 'rettet',
|
phase: 'rettet',
|
||||||
@@ -430,7 +512,7 @@ export class Pipeline {
|
|||||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||||
|
|
||||||
if (rip.status === 'cancelled') {
|
if (rip.status === 'cancelled') {
|
||||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
|
||||||
// Nur dieser Titel — oder der ganze Auftrag?
|
// Nur dieser Titel — oder der ganze Auftrag?
|
||||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||||
beenden(
|
beenden(
|
||||||
@@ -452,7 +534,7 @@ export class Pipeline {
|
|||||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||||
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
||||||
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
|
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
|
||||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? RETTUNG_DATEIEN : []))
|
||||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -493,6 +575,10 @@ export class Pipeline {
|
|||||||
audioSprachen: a?.audioSprachen ?? [],
|
audioSprachen: a?.audioSprachen ?? [],
|
||||||
untertitelSprachen: a?.untertitelSprachen ?? [],
|
untertitelSprachen: a?.untertitelSprachen ?? [],
|
||||||
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
|
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
|
||||||
|
// 5.7.0: die einzeln gewählten Spuren reisen mit in die Ablage.
|
||||||
|
...(a?.audioSpuren !== undefined ? { audioSpuren: a.audioSpuren } : {}),
|
||||||
|
...(a?.untertitelSpuren !== undefined ? { untertitelSpuren: a.untertitelSpuren } : {}),
|
||||||
|
...(a?.untertitelVorwahlSpur === null || typeof a?.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: a.untertitelVorwahlSpur } : {}),
|
||||||
...(a?.film !== undefined ? { film: a.film } : {}),
|
...(a?.film !== undefined ? { film: a.film } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -527,7 +613,7 @@ export class Pipeline {
|
|||||||
this.umgebung.vorgangBeginnt?.({
|
this.umgebung.vorgangBeginnt?.({
|
||||||
id: gruppenId,
|
id: gruppenId,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
typ: geraet.typ,
|
typ: discTyp,
|
||||||
titel: gruppenZuordnung.titel,
|
titel: gruppenZuordnung.titel,
|
||||||
jahr: gruppenZuordnung.jahr,
|
jahr: gruppenZuordnung.jahr,
|
||||||
rohOrdner: gruppenOrdner,
|
rohOrdner: gruppenOrdner,
|
||||||
@@ -538,7 +624,7 @@ export class Pipeline {
|
|||||||
auftrag: {
|
auftrag: {
|
||||||
vorgangId: gruppenId,
|
vorgangId: gruppenId,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
discTyp: geraet.typ,
|
discTyp,
|
||||||
rohOrdner: gruppenOrdner,
|
rohOrdner: gruppenOrdner,
|
||||||
dateien: gruppe.dateien,
|
dateien: gruppe.dateien,
|
||||||
zuordnung: gruppenZuordnung,
|
zuordnung: gruppenZuordnung,
|
||||||
@@ -559,7 +645,7 @@ export class Pipeline {
|
|||||||
const kompression: KompressionsAuftrag = {
|
const kompression: KompressionsAuftrag = {
|
||||||
vorgangId: vorgangsName,
|
vorgangId: vorgangsName,
|
||||||
laufwerk: id,
|
laufwerk: id,
|
||||||
discTyp: geraet.typ,
|
discTyp,
|
||||||
rohOrdner,
|
rohOrdner,
|
||||||
dateien: hauptDateien,
|
dateien: hauptDateien,
|
||||||
zuordnung: hauptZuordnung,
|
zuordnung: hauptZuordnung,
|
||||||
@@ -667,10 +753,17 @@ export class Pipeline {
|
|||||||
const wav = join(arbeit, 'spur.wav')
|
const wav = join(arbeit, 'spur.wav')
|
||||||
const strom = createWriteStream(wav)
|
const strom = createWriteStream(wav)
|
||||||
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
|
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
|
||||||
this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => {
|
await this.umgebung.audioSpurLesen(
|
||||||
const s = this.laufend.get(id)?.status
|
id,
|
||||||
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
|
spur.start,
|
||||||
})
|
spur.sektoren,
|
||||||
|
(daten) => strom.write(daten),
|
||||||
|
(anteil) => {
|
||||||
|
const s = this.laufend.get(id)?.status
|
||||||
|
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
|
||||||
|
},
|
||||||
|
abbruch.signal,
|
||||||
|
)
|
||||||
await new Promise<void>((fertig, kaputt) => {
|
await new Promise<void>((fertig, kaputt) => {
|
||||||
strom.end(() => fertig())
|
strom.end(() => fertig())
|
||||||
strom.on('error', kaputt)
|
strom.on('error', kaputt)
|
||||||
|
|||||||
@@ -74,7 +74,15 @@ export class AutomatikDienst {
|
|||||||
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
|
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
|
||||||
const id = geraet.id
|
const id = geraet.id
|
||||||
if (!this.abh.an()) return
|
if (!this.abh.an()) return
|
||||||
if (geraet.typ === 'cd' || geraet.typ === 'unknown') return
|
// 5.7.0 (F28): nicht stumm — der Kasten sagt, warum nichts passiert.
|
||||||
|
if (geraet.typ === 'cd') {
|
||||||
|
this.melden(id, false, 0, 'Automatik: Audio-CDs startet Rippy nicht von selbst — „Rippen" liest sie als FLAC.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (geraet.typ === 'unknown') {
|
||||||
|
this.melden(id, false, 0, 'Automatik wartet: Disc-Typ unbekannt — „Rippen" liest die Titel, dann entscheidest du.')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (this.abh.ripLaeuft(id)) return
|
if (this.abh.ripLaeuft(id)) return
|
||||||
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
|
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
|
||||||
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
|
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
|
|||||||
'telegramToken',
|
'telegramToken',
|
||||||
'telegramChatId',
|
'telegramChatId',
|
||||||
'design',
|
'design',
|
||||||
|
// 5.7.0: wie lange makemkvcon die Titel lesen darf (Kandidat 2 der Durchsicht)
|
||||||
|
'titelLesenMinuten',
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
|
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
|
||||||
@@ -56,6 +58,10 @@ const PIPELINE_EINSTELLUNGEN = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
|
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
|
||||||
|
/** 5.7.0: Vorgabe 15 min (Commander-Entscheid 12.09.2026) — kopiergeschützte
|
||||||
|
* DVDs und Discs mit vielen Playlisten brauchen im MakeMKV-GUI regelmäßig
|
||||||
|
* länger als die alten fünf Minuten. */
|
||||||
|
export const TITEL_LESEN_MINUTEN_VORGABE = 15
|
||||||
|
|
||||||
export class EinstellungsDienst {
|
export class EinstellungsDienst {
|
||||||
constructor(private readonly kontext: KernKontext) {}
|
constructor(private readonly kontext: KernKontext) {}
|
||||||
@@ -101,6 +107,12 @@ export class EinstellungsDienst {
|
|||||||
return this.lesen('automatik', 'an') !== 'aus'
|
return this.lesen('automatik', 'an') !== 'aus'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grenze fürs Titel-Lesen in Minuten (5–60, Vorgabe 15). */
|
||||||
|
titelLesenMinuten(): number {
|
||||||
|
const n = Number(this.lesen('titelLesenMinuten', String(TITEL_LESEN_MINUTEN_VORGABE)))
|
||||||
|
return Number.isInteger(n) && n >= 5 && n <= 60 ? n : TITEL_LESEN_MINUTEN_VORGABE
|
||||||
|
}
|
||||||
|
|
||||||
automatikSekunden(): number {
|
automatikSekunden(): number {
|
||||||
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
|
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
|
||||||
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
|
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ import { discFingerprint, discTitel, type DiscTitel } from '../metadaten/discmer
|
|||||||
import { OmdbClient } from '../metadaten/omdb'
|
import { OmdbClient } from '../metadaten/omdb'
|
||||||
import { TmdbClient } from '../metadaten/tmdb'
|
import { TmdbClient } from '../metadaten/tmdb'
|
||||||
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
|
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
|
||||||
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
|
import { ABGELOEST, titelInfoLesen, type Lebenszeichen } from '../rip/makemkv'
|
||||||
import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
|
import { spurenFuerTitel, sprachenFuerTitel, type TitelInfo } from '../rip/parser'
|
||||||
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
|
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
|
||||||
|
|
||||||
export interface ErkennungAbhaengigkeiten {
|
export interface ErkennungAbhaengigkeiten {
|
||||||
ripLaeuft(id: string): boolean
|
ripLaeuft(id: string): boolean
|
||||||
geraetInfoVon(id: string): GeraeteInfo | null
|
geraetInfoVon(id: string): GeraeteInfo | null
|
||||||
makemkvcon(): string
|
makemkvcon(): string
|
||||||
|
/** 5.7.0: wie lange makemkvcon die Titel lesen darf (Minuten). Fehlt: 15. */
|
||||||
|
titelLesenMinuten?(): number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
|
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
|
||||||
@@ -311,8 +313,35 @@ export class ErkennungDienst {
|
|||||||
|
|
||||||
senden({ ...leer, laeuft: true })
|
senden({ ...leer, laeuft: true })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
|
// 5.7.0 (Kandidat 2): Die Grenze kommt aus den Einstellungen (Vorgabe
|
||||||
|
// 15 min statt fest fünf), und ab der zweiten Minute sagt der Dialog,
|
||||||
|
// dass es länger dauert — mit Abbrechen-Knopf, statt stumm zu drehen.
|
||||||
|
// 5.7.1 (Der Patriot): Die Grenze gilt für STILLE — solange MakeMKV
|
||||||
|
// Fortschritt oder Meldungen schickt, wartet Rippy, und der Dialog zeigt,
|
||||||
|
// was MakeMKV gerade tut.
|
||||||
|
const minuten = this.abh.titelLesenMinuten?.() ?? 15
|
||||||
|
let lebens: Lebenszeichen = { prozent: -1, schritt: '', meldung: '', vobScan: false }
|
||||||
|
let letztesLebenszeichen = start
|
||||||
|
let letzteSendung = start
|
||||||
|
const zwischenstandSenden = (): void => {
|
||||||
|
if (!nochAktuell()) return
|
||||||
|
letzteSendung = Date.now()
|
||||||
|
senden({ ...leer, laeuft: true, einordnung: titelLaufZwischenstand(Date.now() - start, Date.now() - letztesLebenszeichen, minuten, lebens) })
|
||||||
|
}
|
||||||
|
const zwischenstand = setInterval(zwischenstandSenden, 60_000)
|
||||||
|
zwischenstand.unref()
|
||||||
try {
|
try {
|
||||||
const info = await titelInfoLesen(programm, geraet.pfad, 300_000, abbruch.signal)
|
const info = await titelInfoLesen(programm, geraet.pfad, {
|
||||||
|
stillMs: minuten * 60_000,
|
||||||
|
abbruch: abbruch.signal,
|
||||||
|
aufLebenszeichen: (stand) => {
|
||||||
|
const neueMeldung = stand.meldung !== lebens.meldung || stand.schritt !== lebens.schritt
|
||||||
|
lebens = stand
|
||||||
|
letztesLebenszeichen = Date.now()
|
||||||
|
// Neue Meldung oder neuer Schritt sofort, Prozente höchstens alle 5 s.
|
||||||
|
if (Date.now() - start >= 30_000 && (neueMeldung || Date.now() - letzteSendung >= 5_000)) zwischenstandSenden()
|
||||||
|
},
|
||||||
|
})
|
||||||
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||||
const merkmale = this.merkmale.get(id)
|
const merkmale = this.merkmale.get(id)
|
||||||
const wahl = titelVorauswahl(info.titel, {
|
const wahl = titelVorauswahl(info.titel, {
|
||||||
@@ -332,6 +361,8 @@ export class ErkennungDienst {
|
|||||||
...t,
|
...t,
|
||||||
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
|
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
|
||||||
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
|
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
|
||||||
|
// 5.7.0: und jede Spur einzeln.
|
||||||
|
spuren: spurenFuerTitel(info.streams.get(t.nr)),
|
||||||
}))
|
}))
|
||||||
const stand = senden({
|
const stand = senden({
|
||||||
id,
|
id,
|
||||||
@@ -351,12 +382,17 @@ export class ErkennungDienst {
|
|||||||
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||||
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
|
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
|
||||||
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
||||||
const grund = String(fehler).includes('keine Antwort nach')
|
const grund = String(fehler).includes('kein Lebenszeichen seit')
|
||||||
? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.'
|
? `MakeMKV hat ${minuten} Minuten lang kein Lebenszeichen gegeben (kein Fortschritt, keine Meldung) — Rippy hat den Versuch nach ${Math.round((Date.now() - start) / 60_000)} min beendet und das Laufwerk freigegeben.${
|
||||||
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
lebens.meldung.length > 0 ? ` Letzte Meldung: „${lebens.meldung}".` : ''
|
||||||
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
|
} Das kann an einer beschädigten Disc oder am Laufwerk liegen. Die Geduld steht unter Einstellungen → Allgemein; zur Probe die Disc im MakeMKV-Programm öffnen.`
|
||||||
|
: String(fehler).includes('VOB-Scan nach')
|
||||||
|
? `MakeMKV liest die beschädigte DVD-Struktur seit ${Math.round((Date.now() - start) / 60_000)} min und ist nicht fertig geworden — Rippy hat den Versuch beendet und das Laufwerk freigegeben. Zur Probe die Disc im MakeMKV-Programm öffnen.`
|
||||||
|
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
||||||
|
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund} [${String(fehler).slice(0, 900)}]`)
|
||||||
return senden({ ...leer, fehler: grund })
|
return senden({ ...leer, fehler: grund })
|
||||||
} finally {
|
} finally {
|
||||||
|
clearInterval(zwischenstand)
|
||||||
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
|
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
|
||||||
if (nochAktuell()) {
|
if (nochAktuell()) {
|
||||||
this.titelAbbrueche.delete(id)
|
this.titelAbbrueche.delete(id)
|
||||||
@@ -365,6 +401,18 @@ export class ErkennungDienst {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Nutzer bricht das Titel-Lesen ab (5.7.0): Der Lauf endet sofort,
|
||||||
|
* makemkvcon gibt das Laufwerk frei, der Dialog zeigt „abgebrochen". */
|
||||||
|
titelAbbrechen(id: string): void {
|
||||||
|
const abbruch = this.titelAbbrueche.get(id)
|
||||||
|
if (abbruch === undefined) return
|
||||||
|
abbruch.abort()
|
||||||
|
this.titelAbbrueche.delete(id)
|
||||||
|
this.titelLaufNummern.delete(id)
|
||||||
|
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} vom Nutzer abgebrochen`)
|
||||||
|
this.kontext.anFenster({ art: 'disc-titel', stand: { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: 'Abgebrochen — „Rippen" liest die Titel neu.' } })
|
||||||
|
}
|
||||||
|
|
||||||
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
|
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
|
||||||
* Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb,
|
* Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb,
|
||||||
* Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird
|
* Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird
|
||||||
@@ -417,3 +465,22 @@ export class ErkennungDienst {
|
|||||||
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
|
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was der Dialog während des Titel-Lesens zeigt (pur, 5.7.1): wie lange
|
||||||
|
* MakeMKV schon liest, was es gerade tut, wann Rippy aufgeben würde. */
|
||||||
|
export function titelLaufZwischenstand(vergangenMs: number, stillMs: number, geduldMinuten: number, lebens: Lebenszeichen): string {
|
||||||
|
const min = Math.max(1, Math.round(vergangenMs / 60_000))
|
||||||
|
const teile: string[] = [`MakeMKV liest seit ${min} min`]
|
||||||
|
if (lebens.schritt.length > 0 || lebens.prozent >= 0) {
|
||||||
|
teile.push(`${lebens.schritt.length > 0 ? lebens.schritt : 'Fortschritt'}${lebens.prozent >= 0 ? ` ${lebens.prozent} %` : ''}`)
|
||||||
|
}
|
||||||
|
if (lebens.meldung.length > 0) teile.push(`letzte Meldung: „${lebens.meldung.trim().slice(0, 160)}"`)
|
||||||
|
let text = teile.join(' · ') + '.'
|
||||||
|
if (lebens.vobScan) {
|
||||||
|
text += ` Die DVD-Struktur ist beschädigt — MakeMKV liest die ganze VOB-Datei durch; das dauert so lange wie das Lesen der Disc (oft 20–60 min), im MakeMKV-Programm genauso. Rippy wartet, solange makemkvcon läuft.`
|
||||||
|
} else {
|
||||||
|
const stillMin = Math.floor(stillMs / 60_000)
|
||||||
|
text += ` Solange MakeMKV Fortschritt meldet, wartet Rippy; ohne Lebenszeichen bricht es nach ${geduldMinuten} min ab (Einstellungen → Allgemein)${stillMin >= 1 ? ` — still seit ${stillMin} min` : ''}.`
|
||||||
|
}
|
||||||
|
return text + ' „Abbrechen" beendet den Lauf.'
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
listOptischeLaufwerke,
|
listOptischeLaufwerke,
|
||||||
} from '../laufwerk/disc'
|
} from '../laufwerk/disc'
|
||||||
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
|
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
|
||||||
|
import { typAusDateisystem } from '../metadaten/discmerkmale'
|
||||||
import { DiscWache } from '../laufwerk/wache'
|
import { DiscWache } from '../laufwerk/wache'
|
||||||
import type { Spur } from '../rip/audiocd'
|
import type { Spur } from '../rip/audiocd'
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ export class LaufwerkDienst {
|
|||||||
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
|
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
|
||||||
readonly fake: string
|
readonly fake: string
|
||||||
readonly bereit: Promise<void>
|
readonly bereit: Promise<void>
|
||||||
|
/** 5.7.0 (F10): Der Typ aus dem Dateisystem, je Laufwerk und Disc-Größe
|
||||||
|
* gemerkt — die Wache fragt alle 3 s, die Disc ändert sich nicht. */
|
||||||
|
private typGemerkt = new Map<string, { groesse: number; typ: GeraeteInfo['typ'] }>()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly kontext: KernKontext,
|
private readonly kontext: KernKontext,
|
||||||
@@ -99,7 +103,7 @@ export class LaufwerkDienst {
|
|||||||
const { win32Api } = await import('../laufwerk/win32')
|
const { win32Api } = await import('../laufwerk/win32')
|
||||||
this.api = win32Api()
|
this.api = win32Api()
|
||||||
this.wache = new DiscWache(
|
this.wache = new DiscWache(
|
||||||
() => listOptischeLaufwerke(this.api!).map((g) => geraeteInfo(this.api!, g)),
|
() => listOptischeLaufwerke(this.api!).map((g) => this.typVerfeinern(geraeteInfo(this.api!, g))),
|
||||||
(m) => this.wacheMeldung(m),
|
(m) => this.wacheMeldung(m),
|
||||||
wacheFehler,
|
wacheFehler,
|
||||||
)
|
)
|
||||||
@@ -115,6 +119,30 @@ export class LaufwerkDienst {
|
|||||||
return this.wache?.aktuellerStand() ?? []
|
return this.wache?.aktuellerStand() ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was auf der Disc liegt, schlägt Steuercode und Größe (F10): BDMV mit
|
||||||
|
* INDX0300 ist UHD, sonst Blu-ray; VIDEO_TS ist DVD. Nur bei „ready",
|
||||||
|
* und nur einmal je Disc (gemerkt an der Größe). */
|
||||||
|
private typVerfeinern(geraet: GeraeteInfo): GeraeteInfo {
|
||||||
|
if (geraet.status !== 'ready' || geraet.typ === 'cd') {
|
||||||
|
this.typGemerkt.delete(geraet.id)
|
||||||
|
return geraet
|
||||||
|
}
|
||||||
|
const alt = this.typGemerkt.get(geraet.id)
|
||||||
|
if (alt !== undefined && alt.groesse === geraet.groesseBytes) return alt.typ === geraet.typ ? geraet : { ...geraet, typ: alt.typ }
|
||||||
|
let typ = geraet.typ
|
||||||
|
try {
|
||||||
|
const gesehen = typAusDateisystem(geraet.pfad)
|
||||||
|
if (gesehen !== null && gesehen !== geraet.typ) {
|
||||||
|
this.kontext.protokoll.schreiben(`Laufwerk ${geraet.id}: Typ laut Dateisystem ${gesehen} (Steuercode/Größe sagten ${geraet.typ})`)
|
||||||
|
typ = gesehen
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
this.kontext.protokoll.schreiben(`Laufwerk ${geraet.id}: Dateisystem nicht lesbar (${String(fehler)})`)
|
||||||
|
}
|
||||||
|
this.typGemerkt.set(geraet.id, { groesse: geraet.groesseBytes, typ })
|
||||||
|
return typ === geraet.typ ? geraet : { ...geraet, typ }
|
||||||
|
}
|
||||||
|
|
||||||
/** Der Geräte-Name zu einer Kennung — oder null. */
|
/** Der Geräte-Name zu einer Kennung — oder null. */
|
||||||
geraetVon(id: string): string | null {
|
geraetVon(id: string): string | null {
|
||||||
if (this.api === null) return null
|
if (this.api === null) return null
|
||||||
@@ -126,7 +154,7 @@ export class LaufwerkDienst {
|
|||||||
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
|
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
|
||||||
const geraet = this.geraetVon(id)
|
const geraet = this.geraetVon(id)
|
||||||
if (geraet === null || this.api === null) return null
|
if (geraet === null || this.api === null) return null
|
||||||
return geraeteInfo(this.api, geraet)
|
return this.typVerfeinern(geraeteInfo(this.api, geraet))
|
||||||
}
|
}
|
||||||
|
|
||||||
audioToc(id: string): Spur[] {
|
audioToc(id: string): Spur[] {
|
||||||
@@ -141,10 +169,11 @@ export class LaufwerkDienst {
|
|||||||
sektoren: number,
|
sektoren: number,
|
||||||
schreiben: (daten: Buffer) => void,
|
schreiben: (daten: Buffer) => void,
|
||||||
fortschritt?: (anteil: number) => void,
|
fortschritt?: (anteil: number) => void,
|
||||||
): number {
|
abbruch?: AbortSignal,
|
||||||
|
): Promise<number> {
|
||||||
const geraet = this.geraetVon(id)
|
const geraet = this.geraetVon(id)
|
||||||
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
if (this.api === null || geraet === null) return Promise.reject(new Error(`Laufwerk ${id}: gibt es nicht`))
|
||||||
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt)
|
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt, abbruch)
|
||||||
}
|
}
|
||||||
|
|
||||||
async auswerfen(id: string): Promise<void> {
|
async auswerfen(id: string): Promise<void> {
|
||||||
@@ -195,7 +224,16 @@ export class LaufwerkDienst {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN)
|
// 5.7.0 (F11): nur die Ereignisse DIESES Geräts — \Device\CdRomN
|
||||||
|
// hinter dem Buchstaben, nicht die einer Festplatte oder eines zweiten
|
||||||
|
// Laufwerks. Ohne Gerätename (Nachbau, Fehlschlag) zählt nur cdrom.
|
||||||
|
let geraet = ''
|
||||||
|
try {
|
||||||
|
geraet = this.api?.geraeteName(id) ?? ''
|
||||||
|
} catch (fehler) {
|
||||||
|
this.kontext.protokoll.schreiben(`Laufwerk ${id}: Gerätename nicht ermittelbar (${String(fehler)})`)
|
||||||
|
}
|
||||||
|
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN, undefined, geraet)
|
||||||
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil} — ${bericht.text}`)
|
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil} — ${bericht.text}`)
|
||||||
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
|
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,12 +115,46 @@ export class VorgangDienst {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jede Änderung der Warteschlange — Fortschritt eingeschlossen. 5.7.0
|
||||||
|
* (Durchsicht F7): Ein Prozent-Tick ist eine KLEINE Nachricht
|
||||||
|
* (kompression-stand); vorher löste jeder Tick (HandBrake: etwa einmal je
|
||||||
|
* Sekunde) den vollen Vorgangs-Stand aus — mit einem Ordnerlauf durch
|
||||||
|
* jeden Roh-Ordner, zwei statfs und einem kompletten Neuzeichnen des
|
||||||
|
* Fensters. Der volle Stand geht nur noch bei einem Phasenwechsel. */
|
||||||
private standGeaendert(laeuft: KompressionStand | null): void {
|
private standGeaendert(laeuft: KompressionStand | null): void {
|
||||||
if (laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase) {
|
const phasenwechsel = laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase
|
||||||
|
if (laeuft !== null && phasenwechsel) {
|
||||||
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
|
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
|
||||||
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
|
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
|
||||||
}
|
}
|
||||||
this.senden()
|
if (phasenwechsel || laeuft === null) {
|
||||||
|
this.senden()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.kontext.anHauptUndFenster({
|
||||||
|
art: 'kompression-stand',
|
||||||
|
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roh-Größen sind teuer (rekursiver Ordnerlauf, bei einer NAS über das
|
||||||
|
// Netz) und ändern sich nur, wenn Rippy selbst etwas anlegt oder löscht —
|
||||||
|
// deshalb 30 s lang gemerkt (F7). Wer löscht, leert das Gedächtnis.
|
||||||
|
private groessen = new Map<string, { bytes: number; wann: number }>()
|
||||||
|
private static readonly GROESSEN_FRISCH_MS = 30_000
|
||||||
|
|
||||||
|
private groesseGemerkt(pfad: string): number {
|
||||||
|
const jetzt = Date.now()
|
||||||
|
const alt = this.groessen.get(pfad)
|
||||||
|
if (alt !== undefined && jetzt - alt.wann < VorgangDienst.GROESSEN_FRISCH_MS) return alt.bytes
|
||||||
|
const bytes = ordnerGroesse(pfad)
|
||||||
|
this.groessen.set(pfad, { bytes, wann: jetzt })
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach Löschen oder Aufräumen: frisch messen. */
|
||||||
|
private groessenVergessen(): void {
|
||||||
|
this.groessen.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
|
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
|
||||||
@@ -220,7 +254,7 @@ export class VorgangDienst {
|
|||||||
let rohGesamtBytes = 0
|
let rohGesamtBytes = 0
|
||||||
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
|
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
|
||||||
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
|
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
|
||||||
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
|
const rohBytes = rohDa ? this.groesseGemerkt(v.rohOrdner) : 0
|
||||||
rohGesamtBytes += rohBytes
|
rohGesamtBytes += rohBytes
|
||||||
const auftrag = auftragAusJson(v.auftrag)
|
const auftrag = auftragAusJson(v.auftrag)
|
||||||
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
|
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
|
||||||
@@ -242,12 +276,15 @@ export class VorgangDienst {
|
|||||||
preset: v.preset,
|
preset: v.preset,
|
||||||
fehler: v.fehler,
|
fehler: v.fehler,
|
||||||
titelZahl: auftrag?.dateien.length ?? 0,
|
titelZahl: auftrag?.dateien.length ?? 0,
|
||||||
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
|
// 5.7.0 (F27): Ein fertiger, UNkomprimierter Vorgang hat seine Roh-
|
||||||
|
// Dateien verschoben — da ist nichts mehr zu wiederholen.
|
||||||
|
neuKomprimierbar:
|
||||||
|
rohDa && ruhig && !(v.phase === 'fertig' && !v.komprimiert) && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
|
||||||
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
|
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const bekannte = new Set(liste.map((v) => v.rohOrdner))
|
const bekannte = new Set(liste.map((v) => v.rohOrdner))
|
||||||
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte)
|
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte, (pfad) => this.groesseGemerkt(pfad))
|
||||||
for (const f of fremde) rohGesamtBytes += f.bytes
|
for (const f of fremde) rohGesamtBytes += f.bytes
|
||||||
return {
|
return {
|
||||||
vorgaenge,
|
vorgaenge,
|
||||||
@@ -259,6 +296,12 @@ export class VorgangDienst {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Knopf „Aktualisieren": frisch messen, nicht aus dem Gedächtnis. */
|
||||||
|
neuMessen(): void {
|
||||||
|
this.groessenVergessen()
|
||||||
|
this.senden()
|
||||||
|
}
|
||||||
|
|
||||||
senden(): void {
|
senden(): void {
|
||||||
try {
|
try {
|
||||||
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
|
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
|
||||||
@@ -290,6 +333,7 @@ export class VorgangDienst {
|
|||||||
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
|
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
|
||||||
}))
|
}))
|
||||||
const ergebnis = aufraeumen(kandidaten, regel, new Date())
|
const ergebnis = aufraeumen(kandidaten, regel, new Date())
|
||||||
|
if (ergebnis.geloescht.length > 0) this.groessenVergessen()
|
||||||
const jetzt = new Date().toISOString()
|
const jetzt = new Date().toISOString()
|
||||||
for (const id of ergebnis.geloescht) {
|
for (const id of ergebnis.geloescht) {
|
||||||
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
|
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
|
||||||
@@ -321,6 +365,7 @@ export class VorgangDienst {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
rmSync(v.rohOrdner, { recursive: true, force: true })
|
rmSync(v.rohOrdner, { recursive: true, force: true })
|
||||||
|
this.groessenVergessen()
|
||||||
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
|
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
|
||||||
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
|
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
@@ -341,6 +386,7 @@ export class VorgangDienst {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
rmSync(ordner, { recursive: true, force: true })
|
rmSync(ordner, { recursive: true, force: true })
|
||||||
|
this.groessenVergessen()
|
||||||
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
|
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
this.kontext.fehler(`„${ordner}" nicht gelöscht: ${String(fehler)}`)
|
this.kontext.fehler(`„${ordner}" nicht gelöscht: ${String(fehler)}`)
|
||||||
|
|||||||
@@ -15,26 +15,51 @@ export class WerkzeugDienst {
|
|||||||
schluesselStand: SchluesselStand | null = null
|
schluesselStand: SchluesselStand | null = null
|
||||||
private beschaffungLaeuft = false
|
private beschaffungLaeuft = false
|
||||||
private installerAntwort: ((fehler: string | null) => void) | null = null
|
private installerAntwort: ((fehler: string | null) => void) | null = null
|
||||||
|
/** 5.7.0 (F16): Die offene Ja/Nein-Frage der Beschaffung — null, wenn keine. */
|
||||||
|
private frageAntwort: ((ja: boolean) => void) | null = null
|
||||||
|
private letzterBeschaffungsText = ''
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly kontext: KernKontext,
|
private readonly kontext: KernKontext,
|
||||||
private readonly einstellungen: EinstellungsDienst,
|
private readonly einstellungen: EinstellungsDienst,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F22): Ein Fund gilt 60 s. Vorher lief bei fehlendem
|
||||||
|
// MakeMKV bei JEDEM Aufruf die Suchkette bis in die Registry (dreimal
|
||||||
|
// `reg query … /s`, synchron, bis 45 s) — und makemkvcon() wird oft
|
||||||
|
// gerufen: je Pipeline-Start, je Titel-Lauf, im Update-Lauf alle 15 s.
|
||||||
|
private gefunden = new Map<string, { pfad: string; eingestellt: string; wann: number }>()
|
||||||
|
private static readonly FUND_FRISCH_MS = 60_000
|
||||||
|
|
||||||
|
private suchen(name: string, schluessel: string): string {
|
||||||
|
const eingestellt = this.einstellungen.lesen(schluessel)
|
||||||
|
const alt = this.gefunden.get(name)
|
||||||
|
if (alt !== undefined && alt.eingestellt === eingestellt && Date.now() - alt.wann < WerkzeugDienst.FUND_FRISCH_MS) return alt.pfad
|
||||||
|
const pfad = finden(name, eingestellt)
|
||||||
|
this.gefunden.set(name, { pfad, eingestellt, wann: Date.now() })
|
||||||
|
return pfad
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach einer Installation oder einem neuen Pfad: frisch suchen. */
|
||||||
|
neuSuchen(): void {
|
||||||
|
this.gefunden.clear()
|
||||||
|
}
|
||||||
|
|
||||||
makemkvcon(): string {
|
makemkvcon(): string {
|
||||||
return finden('makemkv', this.einstellungen.lesen('werkzeug.makemkv'))
|
return this.suchen('makemkv', 'werkzeug.makemkv')
|
||||||
}
|
}
|
||||||
|
|
||||||
handbrake(): string {
|
handbrake(): string {
|
||||||
return finden('handbrake', this.einstellungen.lesen('werkzeug.handbrake'))
|
return this.suchen('handbrake', 'werkzeug.handbrake')
|
||||||
}
|
}
|
||||||
|
|
||||||
flac(): string {
|
flac(): string {
|
||||||
return finden('flac', this.einstellungen.lesen('werkzeug.flac'))
|
return this.suchen('flac', 'werkzeug.flac')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
|
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
|
||||||
async senden(): Promise<void> {
|
async senden(): Promise<void> {
|
||||||
|
this.neuSuchen()
|
||||||
const makemkvcon = this.makemkvcon()
|
const makemkvcon = this.makemkvcon()
|
||||||
const handbrake = this.handbrake()
|
const handbrake = this.handbrake()
|
||||||
const flac = this.flac()
|
const flac = this.flac()
|
||||||
@@ -93,9 +118,40 @@ export class WerkzeugDienst {
|
|||||||
this.installerAntwort = null
|
this.installerAntwort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false, frage?: string): void {
|
||||||
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
this.letzterBeschaffungsText = text
|
||||||
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
|
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler, ...(frage !== undefined ? { frage } : {}) } })
|
||||||
|
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}${frage !== undefined ? ` — FRAGE: ${frage}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort des Fensters auf eine offene Frage (beschaffung-antwort). */
|
||||||
|
beschaffungAntwort(ja: boolean): void {
|
||||||
|
const antwort = this.frageAntwort
|
||||||
|
this.frageAntwort = null
|
||||||
|
if (antwort === null) {
|
||||||
|
this.kontext.hinweis('Es ist keine Frage der MakeMKV-Beschaffung offen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
antwort(ja)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Ja/Nein-Frage ans Fenster; ohne Antwort in zehn Minuten gilt Nein. */
|
||||||
|
private frage(text: string): Promise<boolean> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
this.frageAntwort = (ja) => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
this.beschaffungMelden(ja ? 'Freigegeben — die Archiv-Kopie wird geladen.' : 'Nicht freigegeben.', null, true)
|
||||||
|
fertig(ja)
|
||||||
|
}
|
||||||
|
const zeiger = setTimeout(() => {
|
||||||
|
if (this.frageAntwort === null) return
|
||||||
|
this.frageAntwort = null
|
||||||
|
this.beschaffungMelden('Keine Antwort — die Archiv-Kopie wird nicht geladen.', null, true)
|
||||||
|
fertig(false)
|
||||||
|
}, 10 * 60_000)
|
||||||
|
zeiger.unref()
|
||||||
|
this.beschaffungMelden(this.letzterBeschaffungsText, null, true, false, text)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||||
@@ -146,8 +202,25 @@ export class WerkzeugDienst {
|
|||||||
const alteExe = this.makemkvcon()
|
const alteExe = this.makemkvcon()
|
||||||
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||||
|
|
||||||
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
|
const ergebnis = await makemkvInstallerHolen(
|
||||||
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
neueste,
|
||||||
|
(text, anteil) => this.beschaffungMelden(text, anteil, true),
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
// F16: Das Archiv liefert eine unsignierte EXE, die gleich mit
|
||||||
|
// Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy.
|
||||||
|
(quelle) =>
|
||||||
|
this.frage(
|
||||||
|
`makemkv.com antwortet nicht. Soll Rippy den MakeMKV-Installer ${neueste} aus dem ${quelle} laden? Das ist eine Kopie von fremder Hand, geprüft wird nur, dass sie ein MakeMKV-Programm ist — er läuft danach mit Adminrechten. Wer sicher gehen will, lädt später selbst von makemkv.com.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
this.beschaffungMelden(
|
||||||
|
`Geprüft (Quelle: ${ergebnis.quelle}, ${(ergebnis.bytes / 1024 ** 2).toFixed(1)} MB, SHA-256 ${ergebnis.sha256}) — der Installer wird gestartet …`,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
const startFehler = await new Promise<string | null>((fertig) => {
|
const startFehler = await new Promise<string | null>((fertig) => {
|
||||||
this.installerAntwort = fertig
|
this.installerAntwort = fertig
|
||||||
|
|||||||
@@ -87,6 +87,21 @@ const kontext: KernKontext = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F2): Ein Fehler, den niemand fängt, darf
|
||||||
|
// nicht still verschwinden. Gemessen in Electron 44: Ein utilityProcess
|
||||||
|
// STIRBT NICHT an einer unbehandelten Promise-Ablehnung — er schweigt nur,
|
||||||
|
// und die Konsole eines Tray-Programms sieht niemand. Deshalb landet jede
|
||||||
|
// solche Ablehnung im Protokoll und im Fenster (R4). Eine echte Ausnahme
|
||||||
|
// wird protokolliert und beendet den Kern kontrolliert — der Haupt startet
|
||||||
|
// ihn neu und verdrahtet das Fenster wieder (kernstart.ts).
|
||||||
|
process.on('unhandledRejection', (grund: unknown) => {
|
||||||
|
kontext.fehler(`Unbehandelte Ablehnung im Kern: ${String(grund instanceof Error ? (grund.stack ?? grund.message) : grund).slice(0, 600)}`)
|
||||||
|
})
|
||||||
|
process.on('uncaughtException', (fehler: Error) => {
|
||||||
|
kontext.fehler(`Absturz im Kern: ${String(fehler.stack ?? fehler.message).slice(0, 600)} — der Kern beendet sich, der Haupt startet ihn neu.`)
|
||||||
|
setTimeout(() => process.exit(1), 200)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Dienste verdrahten ──────────────────────────────────────────────────
|
// ── Dienste verdrahten ──────────────────────────────────────────────────
|
||||||
const einstellungen = new EinstellungsDienst(kontext)
|
const einstellungen = new EinstellungsDienst(kontext)
|
||||||
const werkzeuge = new WerkzeugDienst(kontext, einstellungen)
|
const werkzeuge = new WerkzeugDienst(kontext, einstellungen)
|
||||||
@@ -101,10 +116,13 @@ const laufwerke = new LaufwerkDienst(
|
|||||||
kontext,
|
kontext,
|
||||||
{
|
{
|
||||||
discEingelegt: (geraet) => {
|
discEingelegt: (geraet) => {
|
||||||
void erkennung.discErmitteln(geraet).then((zuordnung) => {
|
void erkennung
|
||||||
automatik.zuordnungMerken(geraet.id, zuordnung)
|
.discErmitteln(geraet)
|
||||||
return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id))
|
.then((zuordnung) => {
|
||||||
})
|
automatik.zuordnungMerken(geraet.id, zuordnung)
|
||||||
|
return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id))
|
||||||
|
})
|
||||||
|
.catch((fehler: unknown) => kontext.fehler(`Nach dem Einlegen in ${geraet.id}: ${String(fehler)}`))
|
||||||
},
|
},
|
||||||
discEntnommen: (geraet) => {
|
discEntnommen: (geraet) => {
|
||||||
erkennung.discVergessen(geraet.id)
|
erkennung.discVergessen(geraet.id)
|
||||||
@@ -119,6 +137,7 @@ const erkennung = new ErkennungDienst(kontext, {
|
|||||||
ripLaeuft: (id) => ripVerwaltung.laeuft(id),
|
ripLaeuft: (id) => ripVerwaltung.laeuft(id),
|
||||||
geraetInfoVon: (id) => laufwerke.geraetInfoVon(id),
|
geraetInfoVon: (id) => laufwerke.geraetInfoVon(id),
|
||||||
makemkvcon: () => werkzeuge.makemkvcon(),
|
makemkvcon: () => werkzeuge.makemkvcon(),
|
||||||
|
titelLesenMinuten: () => einstellungen.titelLesenMinuten(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const vorgaenge = new VorgangDienst(kontext, einstellungen, werkzeuge, erkennung, benachrichtigung, () => speicher.senden())
|
const vorgaenge = new VorgangDienst(kontext, einstellungen, werkzeuge, erkennung, benachrichtigung, () => speicher.senden())
|
||||||
@@ -128,7 +147,7 @@ ripVerwaltung = new Pipeline({
|
|||||||
handbrake: () => werkzeuge.handbrake(),
|
handbrake: () => werkzeuge.handbrake(),
|
||||||
flac: () => werkzeuge.flac(),
|
flac: () => werkzeuge.flac(),
|
||||||
audioToc: (id) => laufwerke.audioToc(id),
|
audioToc: (id) => laufwerke.audioToc(id),
|
||||||
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt),
|
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt, abbruch) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt, abbruch),
|
||||||
geraet: (id) => laufwerke.geraetInfoVon(id),
|
geraet: (id) => laufwerke.geraetInfoVon(id),
|
||||||
ablage: () => einstellungen.ablageOrdner(),
|
ablage: () => einstellungen.ablageOrdner(),
|
||||||
rohWurzel: () => einstellungen.rohWurzel(),
|
rohWurzel: () => einstellungen.rohWurzel(),
|
||||||
@@ -221,6 +240,9 @@ function fensterNachricht(frage: unknown): void {
|
|||||||
automatik.stopp(frage.id, '')
|
automatik.stopp(frage.id, '')
|
||||||
void erkennung.titelLesen(frage.id)
|
void erkennung.titelLesen(frage.id)
|
||||||
return
|
return
|
||||||
|
case 'titel-abbruch':
|
||||||
|
erkennung.titelAbbrechen(frage.id)
|
||||||
|
return
|
||||||
case 'rip-start':
|
case 'rip-start':
|
||||||
automatik.stopp(frage.id, '')
|
automatik.stopp(frage.id, '')
|
||||||
ripMitPlatz(frage.id, frage.auftrag ?? [], frage.optionen ?? {})
|
ripMitPlatz(frage.id, frage.auftrag ?? [], frage.optionen ?? {})
|
||||||
@@ -250,6 +272,12 @@ function fensterNachricht(frage: unknown): void {
|
|||||||
vorgaenge.senden()
|
vorgaenge.senden()
|
||||||
speicher.senden()
|
speicher.senden()
|
||||||
}
|
}
|
||||||
|
// 5.7.0 (Durchsicht F13): Ein neuer Werkzeug-Pfad wird sofort neu
|
||||||
|
// gesucht und gemessen — vorher stand „NICHT GEFUNDEN" so lange, bis
|
||||||
|
// das Fenster neu geladen wurde (in der Erst-Einrichtung besonders
|
||||||
|
// irreführend). Ein neuer eigener Schlüssel wird sofort geprüft.
|
||||||
|
if (frage.schluessel.startsWith('werkzeug.')) void werkzeuge.senden()
|
||||||
|
if (frage.schluessel === 'makemkvAppKey') void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
case 'einstellungen-laden':
|
case 'einstellungen-laden':
|
||||||
@@ -264,8 +292,11 @@ function fensterNachricht(frage: unknown): void {
|
|||||||
case 'schluessel-pruefen':
|
case 'schluessel-pruefen':
|
||||||
void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
|
void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
|
||||||
return
|
return
|
||||||
|
case 'beschaffung-antwort':
|
||||||
|
werkzeuge.beschaffungAntwort(frage.ja)
|
||||||
|
return
|
||||||
case 'vorgaenge-laden':
|
case 'vorgaenge-laden':
|
||||||
vorgaenge.senden()
|
vorgaenge.neuMessen()
|
||||||
return
|
return
|
||||||
case 'vorgang-roh-loeschen':
|
case 'vorgang-roh-loeschen':
|
||||||
vorgaenge.rohLoeschen(frage.vorgangId)
|
vorgaenge.rohLoeschen(frage.vorgangId)
|
||||||
|
|||||||
@@ -15,8 +15,15 @@
|
|||||||
// im Fehlerfall die einzige Auskunft.
|
// im Fehlerfall die einzige Auskunft.
|
||||||
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
|
||||||
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
|
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
|
||||||
|
// - `--aencoder copy` OHNE `--audio-copy-mask` kopiert NUR AAC: HandBrake-
|
||||||
|
// CLI 1.11.2 setzt die Kopiermaske dann auf „copy:aac" — obwohl das
|
||||||
|
// Preset alle Codecs erlaubt — und rechnet AC3/DTS/TrueHD in AAC um
|
||||||
|
// („Auto Passthru: allowed codecs are AAC … passthru not possible for
|
||||||
|
// track 1, using fallback"). Gemessen 12.09.2026 (Durchsicht, F31):
|
||||||
|
// mit ausdrücklicher Maske bleibt AC3 AC3. Seit 5.0 bis 5.6.1 wurde
|
||||||
|
// also jede Tonspur verlustbehaftet umgerechnet.
|
||||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync, renameSync, unlinkSync } from 'node:fs'
|
||||||
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
|
||||||
import { win32 as pfad } from 'node:path'
|
import { win32 as pfad } from 'node:path'
|
||||||
import { textVon } from '../rip/parser'
|
import { textVon } from '../rip/parser'
|
||||||
@@ -33,6 +40,13 @@ export interface HandbrakeArgsOptionen {
|
|||||||
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
|
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
|
||||||
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
|
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
|
||||||
untertitelStandard?: boolean
|
untertitelStandard?: boolean
|
||||||
|
/** 5.7.0: Spuren EINZELN — HandBrake-Spurnummern (`-a`/`-s`, 1-basiert,
|
||||||
|
* Scan-Reihenfolge). Gesetzt, gelten die Sprachlisten nicht. Eine leere
|
||||||
|
* Liste heißt: keine Spur dieser Art. `untertitelStandardNr` ist der
|
||||||
|
* 1-basierte Index in `untertitelNr` (0 = keine Vorwahl). */
|
||||||
|
audioNr?: readonly number[]
|
||||||
|
untertitelNr?: readonly number[]
|
||||||
|
untertitelStandardNr?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
|
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
|
||||||
@@ -47,16 +61,47 @@ export interface HandbrakeArgsOptionen {
|
|||||||
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
|
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
|
||||||
// sie damit von selbst)
|
// sie damit von selbst)
|
||||||
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
|
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
|
||||||
|
// Spur-Schalter GEMESSEN (12.09.2026, Testdatei jpn 5.1/deu 2.0/deu 2.0
|
||||||
|
// „Kommentar" AC3 + deu/eng SRT, Preset „H.265 MKV 1080p30"):
|
||||||
|
// --audio 1,3 → jpn und deu-Kommentar, in dieser Reihenfolge
|
||||||
|
// --subtitle 2,1 --subtitle-default=1 → eng (default=1) vor deu
|
||||||
|
// --subtitle none → keine Untertitelspur (ohne --audio: die erste Tonspur)
|
||||||
|
// --audio-copy-mask aac,ac3,eac3,dtshd,dts,mp3,truehd,flac → AC3 bleibt AC3
|
||||||
|
|
||||||
|
/** Was HandBrake verlustfrei durchreichen darf — genau die Maske der
|
||||||
|
* mitgelieferten Presets („copy:aac" … „copy:flac"), ausdrücklich, weil
|
||||||
|
* die CLI sie sonst auf AAC verengt (siehe Kopf). */
|
||||||
|
export const KOPIERMASKE = 'aac,ac3,eac3,dtshd,dts,mp3,truehd,flac'
|
||||||
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||||
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
||||||
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
||||||
if (format !== undefined) args.push('--format', format)
|
if (format !== undefined) args.push('--format', format)
|
||||||
|
// 5.7.0: Spuren einzeln — `--audio 1,3` und `--subtitle 2` (HandBrakeCLI
|
||||||
|
// --help: „-a, --audio <string> Select audio track(s), separated by
|
||||||
|
// commas", „-s, --subtitle <string> Select subtitle track(s)"; am
|
||||||
|
// mitgelieferten 1.11.2 gemessen, siehe test/messung.spuren.test.ts).
|
||||||
|
if (optionen.audioNr !== undefined || optionen.untertitelNr !== undefined) {
|
||||||
|
const audioNr = optionen.audioNr ?? []
|
||||||
|
// Ohne gewählte Tonspur bleibt HandBrakes Vorgabe: die erste Spur —
|
||||||
|
// ein stummer Film wäre nie gewollt (der Dialog sagt das dazu).
|
||||||
|
if (audioNr.length > 0) args.push('--audio', audioNr.join(','))
|
||||||
|
args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
|
||||||
|
const untertitelNr = optionen.untertitelNr ?? []
|
||||||
|
if (untertitelNr.length > 0) {
|
||||||
|
args.push('--subtitle', untertitelNr.join(','))
|
||||||
|
if ((optionen.untertitelStandardNr ?? 0) > 0) args.push(`--subtitle-default=${optionen.untertitelStandardNr}`)
|
||||||
|
} else {
|
||||||
|
args.push('--subtitle', 'none')
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
|
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
|
||||||
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
|
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
|
||||||
args.push('--first-audio')
|
args.push('--first-audio')
|
||||||
// `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist
|
// `copy` reicht die gewählten Tonspuren verlustfrei durch — aber nur mit
|
||||||
// der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
|
// ausdrücklicher Kopiermaske (F31, siehe Kopf); `av_aac` ist der
|
||||||
args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac')
|
// dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
|
||||||
|
args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
|
||||||
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
||||||
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
||||||
args.push('--all-subtitles')
|
args.push('--all-subtitles')
|
||||||
@@ -264,14 +309,55 @@ export interface HbLaufOptionen {
|
|||||||
untertitelStandard?: boolean
|
untertitelStandard?: boolean
|
||||||
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
||||||
extraArgs?: readonly string[]
|
extraArgs?: readonly string[]
|
||||||
|
/** Die fertige Datei darf eine vorhandene gleichen Namens ersetzen —
|
||||||
|
* nur beim Wiederholen (auftrag.ersetzt); sonst ist das ein Fehler. */
|
||||||
|
ueberschreiben?: boolean
|
||||||
/** Fortschritt 0–100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
|
/** Fortschritt 0–100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
|
||||||
aufFortschritt?: (prozent: number, restS: number) => void
|
aufFortschritt?: (prozent: number, restS: number) => void
|
||||||
abbruch?: AbortSignal
|
abbruch?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Zwischenname, unter dem HandBrake schreibt (5.7.0, Durchsicht F9):
|
||||||
|
* `Akira (1988).mkv` → `Akira (1988).teil.mkv`. Die Endung bleibt, damit
|
||||||
|
* --format und die Datei-daneben-Prüfung weiter an der Endung hängen;
|
||||||
|
* erst eine FERTIGE Datei bekommt den echten Namen. Vorher blieb nach
|
||||||
|
* Abbruch oder Fehler eine halbe Datei unter dem endgültigen Namen liegen
|
||||||
|
* — ohne Arbeitsordner direkt in der Ablage, wo Jellyfin sie als kaputten
|
||||||
|
* Film zeigte. */
|
||||||
|
export function zwischenname(ausgabe: string): string {
|
||||||
|
const endung = extname(ausgabe)
|
||||||
|
return endung.length > 0 ? ausgabe.slice(0, -endung.length) + '.teil' + endung : ausgabe + '.teil'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `x.teil.mkv` → `x.mkv` (auch für die Datei daneben: `x.teil.mp4` → `x.mp4`). */
|
||||||
|
export function endname(zwischen: string): string {
|
||||||
|
return zwischen.replace(/\.teil(\.[^.\\/]+)$/, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reste eines abgebrochenen oder gescheiterten Laufs entfernen — die
|
||||||
|
* Zwischen-Datei und, falls HandBrake einen anderen Container wählte,
|
||||||
|
* ihre Datei daneben. Liefert, was weg ist. */
|
||||||
|
export function zwischenReste(zwischen: string, entfernen: (p: string) => void = unlinkSync, existiert: (p: string) => boolean = existsSync): string[] {
|
||||||
|
const weg: string[] = []
|
||||||
|
for (const kandidat of [zwischen, dateiDaneben(zwischen, existiert)]) {
|
||||||
|
if (kandidat.length === 0 || !existiert(kandidat)) continue
|
||||||
|
try {
|
||||||
|
entfernen(kandidat)
|
||||||
|
weg.push(kandidat)
|
||||||
|
} catch {
|
||||||
|
// Eine Datei, die sich nicht löschen lässt (noch offen?), bleibt —
|
||||||
|
// sie heißt „.teil" und ist damit als halb erkennbar.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return weg
|
||||||
|
}
|
||||||
|
|
||||||
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
||||||
return new Promise((fertig) => {
|
return new Promise((fertig) => {
|
||||||
const args = buildHandbrakeArgs(optionen)
|
// HandBrake schreibt auf den Zwischennamen; der echte Name kommt erst
|
||||||
|
// mit dem Erfolg (F9).
|
||||||
|
const zwischen = zwischenname(optionen.ausgabe)
|
||||||
|
const args = buildHandbrakeArgs({ ...optionen, ausgabe: zwischen })
|
||||||
args.push(...(optionen.extraArgs ?? []))
|
args.push(...(optionen.extraArgs ?? []))
|
||||||
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
|
||||||
@@ -294,6 +380,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
|||||||
|
|
||||||
kind.on('error', (fehler) => {
|
kind.on('error', (fehler) => {
|
||||||
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
zwischenReste(zwischen)
|
||||||
fertig({ status: 'error', fehler: String(fehler) })
|
fertig({ status: 'error', fehler: String(fehler) })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -301,10 +388,27 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
|||||||
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
leser.schluss().forEach(zeile)
|
leser.schluss().forEach(zeile)
|
||||||
if (abgebrochen) {
|
if (abgebrochen) {
|
||||||
|
zwischenReste(zwischen)
|
||||||
fertig({ status: 'cancelled' })
|
fertig({ status: 'cancelled' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fertig(auswertung.urteil(code, optionen.ausgabe))
|
const urteil = auswertung.urteil(code, zwischen)
|
||||||
|
if (urteil.status !== 'success') {
|
||||||
|
zwischenReste(zwischen)
|
||||||
|
fertig(urteil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fertig: Zwischenname → echter Name (eine vorhandene Datei gleichen
|
||||||
|
// Namens wäre ein Fehler in der Ablage-Logik — nie still überschreiben).
|
||||||
|
const ziel = endname(urteil.ausgabe)
|
||||||
|
try {
|
||||||
|
if (optionen.ueberschreiben !== true && existsSync(ziel)) throw new Error(`${basename(ziel)} existiert schon`)
|
||||||
|
renameSync(urteil.ausgabe, ziel)
|
||||||
|
} catch (fehler) {
|
||||||
|
fertig({ status: 'error', fehler: `Die fertige Datei ließ sich nicht umbenennen (${String(fehler)}) — sie liegt unter ${urteil.ausgabe}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fertig({ ...urteil, ausgabe: ziel })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
|
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
|
||||||
// 4K auf 1080p herunter und DVDs hinauf.
|
// 4K auf 1080p herunter und DVDs hinauf.
|
||||||
|
|
||||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
import { DEFAULT_HB_PRESET, PRESET_KEINE, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||||
|
|
||||||
export { DEFAULT_HB_PRESET, presetEmpfehlung }
|
export { DEFAULT_HB_PRESET, PRESET_KEINE, presetEmpfehlung }
|
||||||
|
|
||||||
/** Einstellungs-Schlüssel je Disc-Typ. */
|
/** Einstellungs-Schlüssel je Disc-Typ. */
|
||||||
export const PRESET_SCHLUESSEL: Record<string, string> = {
|
export const PRESET_SCHLUESSEL: Record<string, string> = {
|
||||||
@@ -13,15 +13,12 @@ export const PRESET_SCHLUESSEL: Record<string, string> = {
|
|||||||
uhd: 'transcodePresetUhd',
|
uhd: 'transcodePresetUhd',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
|
|
||||||
// HandBrake-Preset heißt so (gegen alle 90 aus --preset-list geprüft).
|
|
||||||
// Grund: 4K verlustfrei durchreichen, DVDs trotzdem schrumpfen.
|
|
||||||
export const PRESET_KEINE = 'keine'
|
|
||||||
|
|
||||||
export type Einstellungen = Record<string, string | boolean | undefined>
|
export type Einstellungen = Record<string, string | boolean | undefined>
|
||||||
|
|
||||||
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur) */
|
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur)
|
||||||
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen): boolean {
|
* 5.7.0: Steht der Typ auf „automatisch" und die gemessene Empfehlung
|
||||||
|
* sagt „keine" (4K ohne Grafikkarten-Encoder), bleibt es verlustfrei. */
|
||||||
|
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): boolean {
|
||||||
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
|
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -29,24 +26,25 @@ export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen):
|
|||||||
if (schluessel.length > 0) {
|
if (schluessel.length > 0) {
|
||||||
const wert = String(einstellungen[schluessel] ?? '').trim()
|
const wert = String(einstellungen[schluessel] ?? '').trim()
|
||||||
if (wert === PRESET_KEINE) return false
|
if (wert === PRESET_KEINE) return false
|
||||||
|
if (wert.length === 0 && empfehlung === PRESET_KEINE) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
|
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
|
||||||
* Kette: Typ-Preset → allgemeines transcodePreset → EMPFEHLUNG (gemessen,
|
* Kette: Typ-Preset → EMPFEHLUNG (gemessen, Hardware zuerst) → CPU-DEFAULT.
|
||||||
* Hardware zuerst) → CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN — diese
|
* PRESET_KEINE wird ÜBERSPRUNGEN — diese Funktion darf nie „keine" als
|
||||||
* Funktion darf nie „keine" als Preset-Namen liefern, sonst bekäme
|
* Preset-Namen liefern, sonst bekäme HandBrake `--preset keine` und
|
||||||
* HandBrake `--preset keine` und scheiterte. */
|
* scheiterte. (5.7.0, A5: das „allgemeine Preset" transcodePreset ist seit
|
||||||
|
* 5.5.0 aus den Pipeline-Einstellungen raus — hier stand es noch als
|
||||||
|
* toter Zweig.) */
|
||||||
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
|
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
|
||||||
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
||||||
if (schluessel.length > 0) {
|
if (schluessel.length > 0) {
|
||||||
const eigen = String(einstellungen[schluessel] ?? '').trim()
|
const eigen = String(einstellungen[schluessel] ?? '').trim()
|
||||||
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
|
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
|
||||||
}
|
}
|
||||||
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
|
return empfehlung.length > 0 && empfehlung !== PRESET_KEINE ? empfehlung : DEFAULT_HB_PRESET
|
||||||
if (allgemein.length > 0) return allgemein
|
|
||||||
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
|
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
|
||||||
|
|||||||
@@ -18,9 +18,21 @@
|
|||||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||||
import { textVon } from '../rip/parser'
|
import { textVon } from '../rip/parser'
|
||||||
|
|
||||||
|
/** Eine Spur laut HandBrake-Scan. 5.7.0: dazu CodecName („dtshd", „dca",
|
||||||
|
* „truehd", „ac3" …), ChannelCount und bei Untertiteln Source/SourceName
|
||||||
|
* („PGS") — am 12.09.2026 am mitgelieferten HandBrake 1.11.2 gemessen
|
||||||
|
* (AudioList[].CodecName/ChannelCount/ChannelLayout/TrackNumber). */
|
||||||
|
export interface HbSpur {
|
||||||
|
nr: number
|
||||||
|
lang: string
|
||||||
|
beschreibung: string
|
||||||
|
codec: string
|
||||||
|
kanaele: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface HbSpuren {
|
export interface HbSpuren {
|
||||||
audio: Array<{ nr: number; lang: string; beschreibung: string }>
|
audio: HbSpur[]
|
||||||
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
|
untertitel: HbSpur[]
|
||||||
dauerS: number
|
dauerS: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +72,17 @@ export function spurenAusScan(ausgabe: string): HbSpuren | null {
|
|||||||
const liste = (daten as { TitleList?: unknown }).TitleList
|
const liste = (daten as { TitleList?: unknown }).TitleList
|
||||||
if (!Array.isArray(liste) || liste.length === 0) return null
|
if (!Array.isArray(liste) || liste.length === 0) return null
|
||||||
const titel = liste[0] as Record<string, unknown>
|
const titel = liste[0] as Record<string, unknown>
|
||||||
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
|
const spuren = (feld: unknown): HbSpur[] => {
|
||||||
if (!Array.isArray(feld)) return []
|
if (!Array.isArray(feld)) return []
|
||||||
return feld.map((s, i) => {
|
return feld.map((s, i) => {
|
||||||
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
|
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
|
||||||
|
const kanaele = eintrag.ChannelCount
|
||||||
return {
|
return {
|
||||||
nr: i + 1,
|
nr: i + 1,
|
||||||
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
|
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
|
||||||
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
|
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
|
||||||
|
codec: String(eintrag.CodecName ?? eintrag.SourceName ?? eintrag.Source ?? eintrag.Format ?? ''),
|
||||||
|
kanaele: typeof kanaele === 'number' && Number.isFinite(kanaele) ? kanaele : 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export class Win32Fehler extends Error {
|
|||||||
export interface LaufwerkApi {
|
export interface LaufwerkApi {
|
||||||
laufwerksbuchstaben(): string[]
|
laufwerksbuchstaben(): string[]
|
||||||
laufwerkstyp(buchstabe: string): number
|
laufwerkstyp(buchstabe: string): number
|
||||||
|
/** Kernel-Gerätename hinter dem Buchstaben (\Device\CdRom0) — '' wenn
|
||||||
|
* nicht zu ermitteln (5.7.0, F11). */
|
||||||
|
geraeteName(buchstabe: string): string
|
||||||
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM —
|
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM —
|
||||||
* bei gestörter Disc scheitert das zweite und das erste geht weiter
|
* bei gestörter Disc scheitert das zweite und das erste geht weiter
|
||||||
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
|
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
|
||||||
|
|||||||
@@ -196,27 +196,39 @@ export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
|
|||||||
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block —
|
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block —
|
||||||
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
|
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
|
||||||
* ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
|
* ⚠ DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
|
||||||
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig. */
|
* 2352 Bytes hat — dokumentiert, sieht falsch aus, ist richtig.
|
||||||
export function audioSpurLesen(
|
* 5.7.0 (Durchsicht F30): Zwischen den Blöcken bekommt der Kern die
|
||||||
|
* Schleife zurück (setImmediate) — vorher blockierte eine ganze Spur den
|
||||||
|
* Kern, und „Abbrechen" wirkte erst zwischen zwei Spuren. `abbruch` endet
|
||||||
|
* den Lauf am nächsten Block. */
|
||||||
|
export async function audioSpurLesen(
|
||||||
api: LaufwerkApi,
|
api: LaufwerkApi,
|
||||||
geraet: string,
|
geraet: string,
|
||||||
start: number,
|
start: number,
|
||||||
sektoren: number,
|
sektoren: number,
|
||||||
schreiben: (daten: Buffer) => void,
|
schreiben: (daten: Buffer) => void,
|
||||||
fortschritt?: (anteil: number) => void,
|
fortschritt?: (anteil: number) => void,
|
||||||
): number {
|
abbruch?: AbortSignal,
|
||||||
return mitHandle(api, geraet, GENERIC_READ, (h) => {
|
): Promise<number> {
|
||||||
|
const handle = api.oeffnen(geraet, GENERIC_READ)
|
||||||
|
try {
|
||||||
const teile = bloecke(start, sektoren)
|
const teile = bloecke(start, sektoren)
|
||||||
let gelesen = 0
|
let gelesen = 0
|
||||||
for (let i = 0; i < teile.length; i++) {
|
for (let i = 0; i < teile.length; i++) {
|
||||||
|
if (abbruch?.aborted === true) throw new Error('abgebrochen')
|
||||||
const [von, anzahl] = teile[i]
|
const [von, anzahl] = teile[i]
|
||||||
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
|
const daten = api.steuern(handle, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
|
||||||
schreiben(daten)
|
schreiben(daten)
|
||||||
gelesen += daten.length
|
gelesen += daten.length
|
||||||
fortschritt?.((i + 1) / teile.length)
|
fortschritt?.((i + 1) / teile.length)
|
||||||
|
// Alle acht Blöcke (~500 KB) einmal Luft holen — Nachrichten, Wache,
|
||||||
|
// Abbruch kommen dann durch.
|
||||||
|
if (i % 8 === 7) await new Promise<void>((weiter) => setImmediate(weiter))
|
||||||
}
|
}
|
||||||
return gelesen
|
return gelesen
|
||||||
})
|
} finally {
|
||||||
|
api.schliessen(handle)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
|
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
|
||||||
|
|||||||
@@ -62,10 +62,24 @@ export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
|
|||||||
return liste
|
return liste
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gehört dieses Ereignis zu DIESEM Laufwerk? (pur, 5.7.0 — Durchsicht F11)
|
||||||
|
* Mit bekanntem Kernel-Gerät (\Device\CdRom0) zählt nur, was dieses
|
||||||
|
* Gerät nennt; ohne Gerätename zählt nur der cdrom-Treiber — nie mehr die
|
||||||
|
* Festplatten (Provider disk), die vorher ein „VERDACHT: DIE HARDWARE"
|
||||||
|
* für das Blu-ray-Laufwerk auslösten. */
|
||||||
|
export function gehoertZuLaufwerk(e: LaufwerkEreignis, geraet: string): boolean {
|
||||||
|
if (geraet.length > 0) return e.geraet.toLowerCase().includes(geraet.toLowerCase())
|
||||||
|
return e.provider === 'cdrom'
|
||||||
|
}
|
||||||
|
|
||||||
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
|
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
|
||||||
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */
|
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist.
|
||||||
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht {
|
* `geraet` ist der Kernel-Gerätename des Laufwerks (\Device\CdRom0),
|
||||||
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime())
|
* '' wenn unbekannt — dann zählen alle cdrom-Ereignisse. */
|
||||||
|
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date, geraet = ''): LaufwerkBericht {
|
||||||
|
const relevant = ereignisse.filter(
|
||||||
|
(e) => gehoertZuLaufwerk(e, geraet) && (seit === undefined || new Date(e.zeit).getTime() >= seit.getTime()),
|
||||||
|
)
|
||||||
let fehlerhafteBloecke = 0
|
let fehlerhafteBloecke = 0
|
||||||
let controllerfehler = 0
|
let controllerfehler = 0
|
||||||
let sonstige = 0
|
let sonstige = 0
|
||||||
@@ -87,7 +101,7 @@ export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: num
|
|||||||
if (relevant.length === 0) {
|
if (relevant.length === 0) {
|
||||||
return {
|
return {
|
||||||
urteil: 'ruhig',
|
urteil: 'ruhig',
|
||||||
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`,
|
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zu${geraet.length > 0 ? ` ${geraet}` : 'm Laufwerk'} — weder Lesefehler noch Hardware-Fehler.`,
|
||||||
fehlerhafteBloecke,
|
fehlerhafteBloecke,
|
||||||
controllerfehler,
|
controllerfehler,
|
||||||
sonstige,
|
sonstige,
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ export class DiscWache {
|
|||||||
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
|
||||||
runde(): void {
|
runde(): void {
|
||||||
if (!this.laeuft) return
|
if (!this.laeuft) return
|
||||||
|
// 5.7.0 (Durchsicht F6): Eine von außen angestoßene Runde (nach dem
|
||||||
|
// Auswurf) ersetzt die geplante — vorher legte JEDER Aufruf eine zweite
|
||||||
|
// Takt-Kette an, und nach zwanzig Discs fragte Rippy die Laufwerke alle
|
||||||
|
// 150 ms statt alle 3 s (Sonde 3: neun statt fünf Runden).
|
||||||
|
if (this.zeiger !== null) {
|
||||||
|
clearTimeout(this.zeiger)
|
||||||
|
this.zeiger = null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const liste = this.lesen()
|
const liste = this.lesen()
|
||||||
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
const jetzt = new Map(liste.map((g) => [g.id, g]))
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ export function win32Api(): LaufwerkApi {
|
|||||||
)
|
)
|
||||||
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
|
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
|
||||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||||
|
// 5.7.0 (Durchsicht F11): Welches Kernel-Gerät steckt hinter G:? —
|
||||||
|
// QueryDosDeviceW liefert \Device\CdRom0 (learn.microsoft.com,
|
||||||
|
// fileapi/nf-fileapi-querydosdevicew: Ziel ist eine Mehrfach-Zeichenkette,
|
||||||
|
// der erste Eintrag zählt; am 12.09.2026 an C: dieses PCs gemessen).
|
||||||
|
// Damit zählt die Laufwerks-Gesundheit nur noch die Ereignisse DIESES
|
||||||
|
// Laufwerks, nicht die einer hustenden Festplatte.
|
||||||
|
const QueryDosDeviceW = kernel32.func(
|
||||||
|
'uint32_t __stdcall QueryDosDeviceW(str16 lpDeviceName, _Out_ char16_t *lpTargetPath, uint32_t ucchMax)',
|
||||||
|
)
|
||||||
const DeviceIoControl = kernel32.func(
|
const DeviceIoControl = kernel32.func(
|
||||||
'bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)',
|
'bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)',
|
||||||
)
|
)
|
||||||
@@ -65,6 +74,16 @@ export function win32Api(): LaufwerkApi {
|
|||||||
CloseHandle(handle)
|
CloseHandle(handle)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
geraeteName(buchstabe: string): string {
|
||||||
|
const ziel = Buffer.alloc(2 * 1024)
|
||||||
|
const laenge = QueryDosDeviceW(`${buchstabe.replace(/:$/, '')}:`, ziel, 1024) as number
|
||||||
|
if (laenge === 0) return ''
|
||||||
|
// UTF-16LE, die erste Zeichenkette bis zum ersten NUL.
|
||||||
|
const text = ziel.toString('utf16le', 0, laenge * 2)
|
||||||
|
const ende = text.indexOf('\0')
|
||||||
|
return (ende >= 0 ? text.slice(0, ende) : text).trim()
|
||||||
|
},
|
||||||
|
|
||||||
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
|
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
|
||||||
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
|
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
|
||||||
const zurueck = [0]
|
const zurueck = [0]
|
||||||
|
|||||||
@@ -250,6 +250,46 @@ export function discZusatzAbtrennen(titel: string): DiscZusatz {
|
|||||||
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
|
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Disc-Typ aus dem, was auf der Disc liegt (5.7.0, Durchsicht F10) —
|
||||||
|
* unabhängig vom Windows-Steuercode, der bei manchen USB-Brücken nicht
|
||||||
|
* antwortet, und genauer als die Größe (eine 4K-Disc mit 48 GB Daten
|
||||||
|
* wurde vorher „bluray"):
|
||||||
|
* * BDMV/index.bdmv beginnt mit „INDX" + Fassung: „0300" ist das
|
||||||
|
* UHD-Format (BDMV 3.0), „0100"/„0200" eine gewöhnliche Blu-ray —
|
||||||
|
* am Akira-UHD-Abbild (F:\Video\backup\UHD_AKIRA) am 12.09.2026 gemessen:
|
||||||
|
* INDX0300.
|
||||||
|
* * VIDEO_TS → DVD.
|
||||||
|
* null, wenn nichts davon zu sehen ist (Audio-CD, Datendisc, Lesefehler). */
|
||||||
|
export function typAusIndexKopf(kopf: Buffer): 'uhd' | 'bluray' | null {
|
||||||
|
if (kopf.length < 8 || kopf.subarray(0, 4).toString('latin1') !== 'INDX') return null
|
||||||
|
const fassung = kopf.subarray(4, 8).toString('latin1')
|
||||||
|
if (fassung === '0300') return 'uhd'
|
||||||
|
if (fassung === '0100' || fassung === '0200') return 'bluray'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typAusDateisystem(geraetepfad: string): 'uhd' | 'bluray' | 'dvd' | null {
|
||||||
|
const wurzel = discWurzel(geraetepfad)
|
||||||
|
if (wurzel.length === 0) return null
|
||||||
|
const index = join(wurzel, 'BDMV', 'index.bdmv')
|
||||||
|
if (existsSync(index)) {
|
||||||
|
let fd: number | null = null
|
||||||
|
try {
|
||||||
|
fd = openSync(index, 'r')
|
||||||
|
const kopf = lesenBei(fd, 0, 8)
|
||||||
|
const typ = typAusIndexKopf(kopf)
|
||||||
|
if (typ !== null) return typ
|
||||||
|
} catch {
|
||||||
|
// nicht lesbar — dann entscheidet die Größe (unten)
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) closeSync(fd)
|
||||||
|
}
|
||||||
|
return 'bluray'
|
||||||
|
}
|
||||||
|
if (existsSync(join(wurzel, 'VIDEO_TS'))) return 'dvd'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||||
export function discWurzel(geraetepfad: string): string {
|
export function discWurzel(geraetepfad: string): string {
|
||||||
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export interface FilmTreffer {
|
|||||||
id: number
|
id: number
|
||||||
title?: string
|
title?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
/** 5.7.0 (Durchsicht F14): TMDb antwortet mit language=de-DE auf Deutsch
|
||||||
|
* („Der Patriot"), die Disc trägt meist den Originaltitel („The
|
||||||
|
* Patriot") — der wörtliche Vergleich braucht beide. */
|
||||||
|
original_title?: string
|
||||||
|
original_name?: string
|
||||||
poster_path?: string | null
|
poster_path?: string | null
|
||||||
backdrop_path?: string | null
|
backdrop_path?: string | null
|
||||||
release_date?: string
|
release_date?: string
|
||||||
|
|||||||
@@ -153,11 +153,19 @@ export function istStub(details: FilmDetails): boolean {
|
|||||||
return !beschreibung && !bild && !bewertet
|
return !beschreibung && !bild && !bewertet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Akzente und Umlaut-Striche fallen (5.7.0, Durchsicht F14): „Déjà Vu"
|
||||||
|
* auf der Disc heißt „DEJA_VU", bei TMDb „Déjà Vu" — dasselbe Wort. NFD
|
||||||
|
* zerlegt „é" in „e" + Akzent, \p{M} entfernt den Akzent. Gemessen an
|
||||||
|
* Sonde 1 der Durchsicht: vorher kein wörtlicher Treffer. */
|
||||||
|
export function ohneAkzente(t: string): string {
|
||||||
|
return t.normalize('NFD').replace(/\p{M}+/gu, '').replace(/ß/g, 'ss')
|
||||||
|
}
|
||||||
|
|
||||||
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
||||||
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
||||||
function worte(t: string): Set<string> {
|
function worte(t: string): Set<string> {
|
||||||
return new Set(
|
return new Set(
|
||||||
t
|
ohneAkzente(t)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[^\p{L}\p{N}]+/u)
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
.filter((w) => w.length > 0),
|
.filter((w) => w.length > 0),
|
||||||
@@ -185,7 +193,7 @@ export function gleichBedeutend(a: string, b: string): boolean {
|
|||||||
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
|
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
|
||||||
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
|
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
|
||||||
const nackt = (t: string): string =>
|
const nackt = (t: string): string =>
|
||||||
t
|
ohneAkzente(t)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[^\p{L}\p{N}]+/u)
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
.filter((w) => w.length > 0)
|
.filter((w) => w.length > 0)
|
||||||
@@ -298,9 +306,10 @@ export async function zuordnen(
|
|||||||
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
|
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
|
||||||
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
||||||
|
|
||||||
// Wörtlicher Filmtreffer (0,95)
|
// Wörtlicher Filmtreffer (0,95) — auf den deutschen ODER den Originaltitel
|
||||||
|
// (F14: „The Patriot" auf der Disc, „Der Patriot" bei TMDb).
|
||||||
for (const film of filme) {
|
for (const film of filme) {
|
||||||
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
|
if (gleichBedeutend(film.title ?? '', kandidat.text) || gleichBedeutend(film.original_title ?? '', kandidat.text)) {
|
||||||
const details = await tmdb.movieDetails(film.id)
|
const details = await tmdb.movieDetails(film.id)
|
||||||
if (details !== null && !istStub(details)) {
|
if (details !== null && !istStub(details)) {
|
||||||
const meta = ausDetails('movie', film.id, details)
|
const meta = ausDetails('movie', film.id, details)
|
||||||
@@ -312,9 +321,9 @@ export async function zuordnen(
|
|||||||
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
|
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
|
||||||
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
||||||
|
|
||||||
// Wörtlicher Serientreffer (0,90)
|
// Wörtlicher Serientreffer (0,90) — deutscher oder Originalname.
|
||||||
for (const serie of serien) {
|
for (const serie of serien) {
|
||||||
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
|
if (gleichBedeutend(serie.name ?? '', kandidat.text) || gleichBedeutend(serie.original_name ?? '', kandidat.text)) {
|
||||||
const details = await tmdb.tvDetails(serie.id)
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
if (details !== null && !istStub(details)) {
|
if (details !== null && !istStub(details)) {
|
||||||
const meta = ausDetails('tv', serie.id, details)
|
const meta = ausDetails('tv', serie.id, details)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
|||||||
import { mkdirSync, readdirSync } from 'node:fs'
|
import { mkdirSync, readdirSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
|
aufgabeAusPrg,
|
||||||
|
istVobScanMeldung,
|
||||||
KRITISCHE_CODES,
|
KRITISCHE_CODES,
|
||||||
KRITISCHE_TEXTE,
|
KRITISCHE_TEXTE,
|
||||||
LESEFEHLER_MARKE,
|
LESEFEHLER_MARKE,
|
||||||
@@ -31,8 +33,11 @@ import {
|
|||||||
type TitelStreams,
|
type TitelStreams,
|
||||||
} from './parser'
|
} from './parser'
|
||||||
|
|
||||||
|
/** 5.7.1: auch der Info-Lauf meldet Fortschritt (--progress=-same) — daran
|
||||||
|
* erkennt Rippy, dass MakeMKV noch arbeitet, statt nach fester Zeit
|
||||||
|
* abzubrechen. */
|
||||||
export function buildInfoArgs(geraetepfad: string): string[] {
|
export function buildInfoArgs(geraetepfad: string): string[] {
|
||||||
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
|
return ['-r', '--noscan', '--progress=-same', 'info', quelle(geraetepfad)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
|
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
|
||||||
@@ -151,26 +156,106 @@ export interface DiscInfo {
|
|||||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was der Info-Lauf gerade tut — für den Dialog (5.7.1). */
|
||||||
|
export interface Lebenszeichen {
|
||||||
|
/** Gesamtfortschritt 0–100, -1 ohne Angabe. */
|
||||||
|
prozent: number
|
||||||
|
/** Der aktuelle Schritt laut PRGC, '' wenn unbekannt. */
|
||||||
|
schritt: string
|
||||||
|
/** Die letzte Meldung (MSG-Klartext), '' wenn keine. */
|
||||||
|
meldung: string
|
||||||
|
/** MakeMKV hat den VOB-Scan einer beschädigten DVD angekündigt. */
|
||||||
|
vobScan: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitelInfoOptionen {
|
||||||
|
/** So lange darf makemkvcon STILL sein (kein Byte Ausgabe), bevor Rippy
|
||||||
|
* ihn beendet. Solange Fortschritt oder Meldungen kommen, wartet Rippy —
|
||||||
|
* eine feste Gesamtgrenze warf den Patriot-Lauf weg, während das
|
||||||
|
* MakeMKV-Programm dieselbe Disc fertig las (Commander, 12.09.2026). */
|
||||||
|
stillMs?: number
|
||||||
|
/** Hat MakeMKV den VOB-Scan angekündigt, gilt statt der Stille-Grenze
|
||||||
|
* nur diese Obergrenze — der Scan liest die ganze Disc, und ob er dabei
|
||||||
|
* Fortschritt meldet, ist nicht gemessen. */
|
||||||
|
vobScanHoechstensMs?: number
|
||||||
|
abbruch?: AbortSignal
|
||||||
|
aufLebenszeichen?: (stand: Lebenszeichen) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VOB_SCAN_HOECHSTENS_MS = 4 * 60 * 60_000
|
||||||
|
|
||||||
/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20–120 s,
|
/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20–120 s,
|
||||||
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
|
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py).
|
||||||
export function titelInfoLesen(
|
* 5.7.1: Die Grenze ist eine STILLE-Grenze — makemkvcon wird nur beendet,
|
||||||
programm: string,
|
* wenn er so lange nichts mehr sagt; wer Fortschritt meldet, darf weiter. */
|
||||||
geraetepfad: string,
|
export function titelInfoLesen(programm: string, geraetepfad: string, optionen: TitelInfoOptionen = {}): Promise<DiscInfo> {
|
||||||
timeoutMs = 300_000,
|
const stillMs = optionen.stillMs ?? 300_000
|
||||||
abbruch?: AbortSignal,
|
const vobHoechstensMs = optionen.vobScanHoechstensMs ?? VOB_SCAN_HOECHSTENS_MS
|
||||||
): Promise<DiscInfo> {
|
const abbruch = optionen.abbruch
|
||||||
return new Promise((fertig, scheitert) => {
|
return new Promise((fertig, scheitert) => {
|
||||||
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
|
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
const stuecke: Buffer[] = []
|
const stuecke: Buffer[] = []
|
||||||
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
const stand: Lebenszeichen = { prozent: -1, schritt: '', meldung: '', vobScan: false }
|
||||||
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
const start = Date.now()
|
||||||
const zeiger = setTimeout(() => {
|
let zeiger: NodeJS.Timeout
|
||||||
|
// Die letzten Zeilen reisen im Fehler mit — fürs Protokoll, damit ein
|
||||||
|
// fremder PC sagen kann, WO makemkvcon stehen blieb.
|
||||||
|
const letzteZeilen: string[] = []
|
||||||
|
const stumm = (): void => {
|
||||||
kind.kill()
|
kind.kill()
|
||||||
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
const grund = stand.vobScan
|
||||||
}, timeoutMs)
|
? `makemkvcon info: VOB-Scan nach ${Math.round(vobHoechstensMs / 60_000)} min nicht fertig`
|
||||||
|
: `makemkvcon info: kein Lebenszeichen seit ${Math.round(stillMs / 1000)} s`
|
||||||
|
scheitert(new Error(letzteZeilen.length > 0 ? `${grund} — letzte Zeilen: ${letzteZeilen.join(' | ')}` : grund))
|
||||||
|
}
|
||||||
|
const neuWarten = (): void => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
// Beim VOB-Scan zählt nur die Obergrenze ab Start des Laufs.
|
||||||
|
const frist = stand.vobScan ? Math.max(0, vobHoechstensMs - (Date.now() - start)) : stillMs
|
||||||
|
zeiger = setTimeout(stumm, frist)
|
||||||
|
}
|
||||||
|
neuWarten()
|
||||||
|
const leserAus = new ZeilenLeser()
|
||||||
|
const leserFehler = new ZeilenLeser()
|
||||||
|
const aufStueck = (leser: ZeilenLeser, stueck: Buffer): void => {
|
||||||
|
stuecke.push(stueck)
|
||||||
|
let neu = false
|
||||||
|
for (const zeile of leser.hinzu(stueck)) {
|
||||||
|
if (zeile.trim().length > 0) {
|
||||||
|
letzteZeilen.push(zeile.trim().slice(0, 200))
|
||||||
|
if (letzteZeilen.length > 8) letzteZeilen.shift()
|
||||||
|
}
|
||||||
|
const p = progressAusPrgv(zeile)
|
||||||
|
if (p >= 0) {
|
||||||
|
if (p !== stand.prozent) {
|
||||||
|
stand.prozent = p
|
||||||
|
neu = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const aufgabe = aufgabeAusPrg(zeile)
|
||||||
|
if (aufgabe !== null) {
|
||||||
|
if (aufgabe.art === 'schritt' && aufgabe.name !== stand.schritt) {
|
||||||
|
stand.schritt = aufgabe.name
|
||||||
|
neu = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const meldung = parseMsg(zeile)
|
||||||
|
if (meldung !== null && meldung.text !== stand.meldung) {
|
||||||
|
stand.meldung = meldung.text
|
||||||
|
if (istVobScanMeldung(meldung.text)) stand.vobScan = true
|
||||||
|
neu = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
neuWarten()
|
||||||
|
if (neu) optionen.aufLebenszeichen?.({ ...stand })
|
||||||
|
}
|
||||||
|
kind.stdout.on('data', (stueck: Buffer) => aufStueck(leserAus, stueck))
|
||||||
|
kind.stderr.on('data', (stueck: Buffer) => aufStueck(leserFehler, stueck))
|
||||||
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
|
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
|
||||||
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
|
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
|
||||||
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
|
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
|
||||||
|
|||||||
@@ -68,6 +68,27 @@ export function progressAusPrgv(zeile: string): number {
|
|||||||
return Math.min(100, Math.floor((gesamt * 100) / maximum))
|
return Math.min(100, Math.floor((gesamt * 100) / maximum))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PRGC/PRGT: Was MakeMKV gerade tut ───────────────────────────────────
|
||||||
|
// PRGC:code,id,"name" ist der aktuelle Schritt („Analysieren von seamless
|
||||||
|
// segments"), PRGT:code,id,"name" die Gesamtaufgabe („Öffnen der DVD-Disc").
|
||||||
|
// Für die Lebenszeichen im Titel-Dialog (5.7.1, Der Patriot).
|
||||||
|
const PRG_ZEILE = /^PRG([CT]):(\d+),(\d+),"((?:[^"\\]|\\.)*)"/
|
||||||
|
|
||||||
|
export function aufgabeAusPrg(zeile: string): { art: 'schritt' | 'aufgabe'; name: string } | null {
|
||||||
|
const treffer = PRG_ZEILE.exec(zeile.trim())
|
||||||
|
if (treffer === null) return null
|
||||||
|
return { art: treffer[1] === 'C' ? 'schritt' : 'aufgabe', name: treffer[4].replaceAll('\\"', '"') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MakeMKV kündigt den langen Weg an: „IFO file for VTS #1 is corrupt, VOB
|
||||||
|
* file needs to be scanned. This may take a long time" — deutsch „IFO-Datei
|
||||||
|
* für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden". Dann liest
|
||||||
|
* MakeMKV die ganze VOB durch, so lange wie die Disc braucht (Der Patriot,
|
||||||
|
* Commander 12.09.2026: 15 Minuten reichten nicht, das GUI schaffte es). */
|
||||||
|
export function istVobScanMeldung(text: string): boolean {
|
||||||
|
return /\bIFO\b/i.test(text) && /\bVOB\b/i.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
// ── TINFO: Titel-Liste ──────────────────────────────────────────────────
|
// ── TINFO: Titel-Liste ──────────────────────────────────────────────────
|
||||||
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
|
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
|
||||||
// ("H:MM:SS"), 11 = DiskSizeBytes.
|
// ("H:MM:SS"), 11 = DiskSizeBytes.
|
||||||
@@ -114,22 +135,44 @@ export function parseTitelInfo(ausgabe: string): TitelInfo[] {
|
|||||||
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
|
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
|
||||||
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
|
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
|
||||||
// jede Disc für englisch.
|
// jede Disc für englisch.
|
||||||
|
// ⚠️ 5.6.1 (Commander-Fund 03.09.2026, Digimon Last Evolution Kizuna): Auch
|
||||||
|
// der TEXT von Attribut 1 ist MakeMKVs Anzeigesprache — deutsch eingestellt
|
||||||
|
// schreibt es `SINFO:1,5,1,6203,"Untertitel"`, nicht „Subtitles". „Audio"
|
||||||
|
// heißt zufällig in beiden Sprachen gleich, deshalb fehlten NUR die
|
||||||
|
// Untertitel. Verlässlich ist der CODE im vierten Feld: 6201 Video,
|
||||||
|
// 6202 Audio, 6203 Untertitel (gemessen an makemkvcon64 auf diesem PC).
|
||||||
|
export const STREAM_CODE_VIDEO = 6201
|
||||||
|
export const STREAM_CODE_AUDIO = 6202
|
||||||
|
export const STREAM_CODE_UNTERTITEL = 6203
|
||||||
|
|
||||||
export interface StreamInfo {
|
export interface StreamInfo {
|
||||||
nr: number
|
nr: number
|
||||||
typ: string
|
typ: string
|
||||||
|
/** Typ-Code aus Attribut 1 (6201/6202/6203), 0 wenn keiner dastand. */
|
||||||
|
typCode: number
|
||||||
lang: string
|
lang: string
|
||||||
sprache: string
|
sprache: string
|
||||||
codec: string
|
codec: string
|
||||||
kanaele: string
|
kanaele: string
|
||||||
beschreibung: string
|
beschreibung: string
|
||||||
|
/** Attribut 2: „Surround 5.1", „Stereo" (5.7.0). */
|
||||||
|
layout: string
|
||||||
|
/** Attribut 22: Flags. Bit 0x800 heißt „abgeleitet" — an der Kizuna-Disc
|
||||||
|
* gemessen (03.09.2026): 2304 (0x900) am DTS-Kern der DTS-HD-Spur, 6144
|
||||||
|
* (0x1800) an der „nur erzwungene"-Fassung eines Untertitels, 1024 und 0
|
||||||
|
* an den vollen Spuren (5.7.0). */
|
||||||
|
flags: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bit 0x800 in Attribut 22: eine von MakeMKV abgeleitete Spur. */
|
||||||
|
export const STREAM_FLAG_ABGELEITET = 0x800
|
||||||
|
|
||||||
export interface TitelStreams {
|
export interface TitelStreams {
|
||||||
audio: StreamInfo[]
|
audio: StreamInfo[]
|
||||||
untertitel: StreamInfo[]
|
untertitel: StreamInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/
|
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),(\d+),"(.*)"\s*$/
|
||||||
|
|
||||||
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
||||||
const titel = new Map<number, Map<number, StreamInfo>>()
|
const titel = new Map<number, Map<number, StreamInfo>>()
|
||||||
@@ -139,7 +182,8 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
|||||||
const tNr = Number(treffer[1])
|
const tNr = Number(treffer[1])
|
||||||
const sNr = Number(treffer[2])
|
const sNr = Number(treffer[2])
|
||||||
const attr = Number(treffer[3])
|
const attr = Number(treffer[3])
|
||||||
const wert = treffer[4]
|
const code = Number(treffer[4])
|
||||||
|
const wert = treffer[5]
|
||||||
let streams = titel.get(tNr)
|
let streams = titel.get(tNr)
|
||||||
if (streams === undefined) {
|
if (streams === undefined) {
|
||||||
streams = new Map()
|
streams = new Map()
|
||||||
@@ -147,14 +191,19 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
|||||||
}
|
}
|
||||||
let eintrag = streams.get(sNr)
|
let eintrag = streams.get(sNr)
|
||||||
if (eintrag === undefined) {
|
if (eintrag === undefined) {
|
||||||
eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
|
eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '', layout: '', flags: 0 }
|
||||||
streams.set(sNr, eintrag)
|
streams.set(sNr, eintrag)
|
||||||
}
|
}
|
||||||
if (attr === 1) eintrag.typ = wert
|
if (attr === 1) {
|
||||||
|
eintrag.typ = wert
|
||||||
|
eintrag.typCode = code
|
||||||
|
}
|
||||||
|
else if (attr === 2) eintrag.layout = wert
|
||||||
else if (attr === 3) eintrag.lang = wert
|
else if (attr === 3) eintrag.lang = wert
|
||||||
else if (attr === 4) eintrag.sprache = wert
|
else if (attr === 4) eintrag.sprache = wert
|
||||||
else if (attr === 6) eintrag.codec = wert
|
else if (attr === 6) eintrag.codec = wert
|
||||||
else if (attr === 14) eintrag.kanaele = wert
|
else if (attr === 14) eintrag.kanaele = wert
|
||||||
|
else if (attr === 22 && /^\d+$/.test(wert)) eintrag.flags = Number(wert)
|
||||||
else if (attr === 30) eintrag.beschreibung = wert
|
else if (attr === 30) eintrag.beschreibung = wert
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +213,10 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
|||||||
const untertitel: StreamInfo[] = []
|
const untertitel: StreamInfo[] = []
|
||||||
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
|
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
|
||||||
const s = streams.get(sNr)!
|
const s = streams.get(sNr)!
|
||||||
// MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
|
// Der Code entscheidet (5.6.1); das Wort nur als Notnagel für Ausgaben
|
||||||
if (s.typ === 'Audio') audio.push(s)
|
// ohne Code („Audio", „Subtitles", „Untertitel").
|
||||||
else if (s.typ.startsWith('Subtitle')) untertitel.push(s)
|
if (s.typCode === STREAM_CODE_AUDIO || (s.typCode === 0 && s.typ === 'Audio')) audio.push(s)
|
||||||
|
else if (s.typCode === STREAM_CODE_UNTERTITEL || (s.typCode === 0 && (s.typ.startsWith('Subtitle') || s.typ.startsWith('Untertitel')))) untertitel.push(s)
|
||||||
}
|
}
|
||||||
ergebnis.set(tNr, { audio, untertitel })
|
ergebnis.set(tNr, { audio, untertitel })
|
||||||
}
|
}
|
||||||
@@ -175,8 +225,8 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
|||||||
|
|
||||||
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
|
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
|
||||||
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
|
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
|
||||||
import type { SprachEintrag } from '../../gemeinsam/nachrichten'
|
import type { SprachEintrag, SpurAngabe } from '../../gemeinsam/nachrichten'
|
||||||
export type { SprachEintrag }
|
export type { SprachEintrag, SpurAngabe }
|
||||||
|
|
||||||
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
||||||
* Reihenfolge stabil (wie auf der Disc). */
|
* Reihenfolge stabil (wie auf der Disc). */
|
||||||
@@ -202,6 +252,24 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
|
|||||||
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jede Spur EINES Titels als SpurAngabe (5.7.0, für den Auswahl-Dialog):
|
||||||
|
* Ton und Untertitel getrennt, in Disc-Reihenfolge, abgeleitete Spuren
|
||||||
|
* markiert. Ohne Streams leer. */
|
||||||
|
export function spurenFuerTitel(streams: TitelStreams | undefined): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } {
|
||||||
|
const angabe = (s: StreamInfo): SpurAngabe => ({
|
||||||
|
nr: s.nr,
|
||||||
|
lang: s.lang,
|
||||||
|
sprache: s.sprache.length > 0 ? s.sprache : s.lang,
|
||||||
|
codec: s.codec,
|
||||||
|
kanaele: s.kanaele,
|
||||||
|
layout: s.layout,
|
||||||
|
beschreibung: s.beschreibung,
|
||||||
|
abgeleitet: (s.flags & STREAM_FLAG_ABGELEITET) !== 0,
|
||||||
|
})
|
||||||
|
if (streams === undefined) return { audio: [], untertitel: [] }
|
||||||
|
return { audio: streams.audio.map(angabe), untertitel: streams.untertitel.map(angabe) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
|
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
|
||||||
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
|
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
|
||||||
* (Titel unbekannt) leer, nicht erfunden. */
|
* (Titel unbekannt) leer, nicht erfunden. */
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
|
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
|
||||||
// `.neu`, geprüft, erst dann getauscht.
|
// `.neu`, geprüft, erst dann getauscht.
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
import { createHash } from 'node:crypto'
|
||||||
|
import { createWriteStream, openSync, readFileSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||||
import { win32 as pfad } from 'node:path'
|
import { win32 as pfad } from 'node:path'
|
||||||
import { werkzeugOrdner } from './katalog'
|
import { werkzeugOrdner } from './katalog'
|
||||||
|
|
||||||
@@ -285,6 +286,22 @@ export interface BeschaffungErgebnis {
|
|||||||
pfad: string
|
pfad: string
|
||||||
quelle: string
|
quelle: string
|
||||||
version: string
|
version: string
|
||||||
|
/** 5.7.0 (F16): Größe und SHA-256 der geladenen Datei — damit man sie
|
||||||
|
* gegen die Herstellerangabe halten kann, sobald die Seite wieder da ist. */
|
||||||
|
bytes: number
|
||||||
|
sha256: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 einer Datei als Hex — für die Anzeige neben dem Installer. */
|
||||||
|
export function pruefsumme(dateiPfad: string): string {
|
||||||
|
return createHash('sha256').update(readFileSync(dateiPfad)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Quelle ein Rückfrage braucht: alles außer Hersteller und eigener
|
||||||
|
* Basis. Das Internet Archive liefert eine unsignierte EXE, die mit
|
||||||
|
* Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy (F16). */
|
||||||
|
export function brauchtFreigabe(quellenName: string): boolean {
|
||||||
|
return quellenName === 'Internet Archive'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
|
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
|
||||||
@@ -297,6 +314,9 @@ export async function makemkvInstallerHolen(
|
|||||||
holen: typeof fetch = fetch,
|
holen: typeof fetch = fetch,
|
||||||
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
|
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
|
||||||
pruefen: typeof dateiPruefen = dateiPruefen,
|
pruefen: typeof dateiPruefen = dateiPruefen,
|
||||||
|
/** 5.7.0 (F16): Darf eine Quelle mit Rückfrage benutzt werden? Ohne
|
||||||
|
* diese Funktion wird sie übersprungen — nie still geladen. */
|
||||||
|
freigabe?: (quellenName: string, url: string) => Promise<boolean>,
|
||||||
): Promise<BeschaffungErgebnis> {
|
): Promise<BeschaffungErgebnis> {
|
||||||
mkdirSync(ordner, { recursive: true })
|
mkdirSync(ordner, { recursive: true })
|
||||||
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
|
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
|
||||||
@@ -304,6 +324,14 @@ export async function makemkvInstallerHolen(
|
|||||||
|
|
||||||
const fehlschlaege: string[] = []
|
const fehlschlaege: string[] = []
|
||||||
for (const [name, url] of makemkvQuellen(version, basis)) {
|
for (const [name, url] of makemkvQuellen(version, basis)) {
|
||||||
|
if (brauchtFreigabe(name)) {
|
||||||
|
const erlaubt = freigabe !== undefined && (await freigabe(name, url))
|
||||||
|
if (!erlaubt) {
|
||||||
|
fehlschlaege.push(`${name}: nicht freigegeben`)
|
||||||
|
melden(`${name} wurde nicht freigegeben.`, null)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
|
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
|
||||||
try {
|
try {
|
||||||
await dateiLaden(url, vorlaeufig, melden, holen)
|
await dateiLaden(url, vorlaeufig, melden, holen)
|
||||||
@@ -312,7 +340,7 @@ export async function makemkvInstallerHolen(
|
|||||||
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
|
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
|
||||||
// Datei nicht ersetzen.
|
// Datei nicht ersetzen.
|
||||||
renameSync(vorlaeufig, ziel)
|
renameSync(vorlaeufig, ziel)
|
||||||
return { pfad: ziel, quelle: name, version }
|
return { pfad: ziel, quelle: name, version, bytes: statSync(ziel).size, sha256: pruefsumme(ziel) }
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
|
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
|
||||||
melden(`${name} hat nicht geliefert.`, null)
|
melden(`${name} hat nicht geliefert.`, null)
|
||||||
|
|||||||
@@ -3,20 +3,32 @@
|
|||||||
// am Commander-PC gemessen):
|
// am Commander-PC gemessen):
|
||||||
//
|
//
|
||||||
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
|
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
|
||||||
// 2. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
|
// 2. Sonst: Ein Schlüssel, der schon im Register steht und angenommen
|
||||||
// 3. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
|
// wird, bleibt (5.7.0 — vorher überschrieb Rippy jeden vorhandenen
|
||||||
|
// Schlüssel mit dem Forum-Key, auch einen gekauften aus dem GUI).
|
||||||
|
// 3. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
|
||||||
|
// 4. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
|
||||||
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
|
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
|
||||||
// 4. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
|
// 5. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
|
||||||
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
|
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
|
||||||
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
|
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
|
||||||
// 5. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
|
// Ohne angeschlossenes Laufwerk sagt makemkvcon NICHTS zur Lizenz
|
||||||
|
// (12.09.2026 gemessen: nur 5042/5010) — dann heißt das Urteil
|
||||||
|
// „nicht prüfbar", nicht „angenommen".
|
||||||
|
// 6. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
|
||||||
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
|
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
|
||||||
import { spawn, spawnSync } from 'node:child_process'
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
import { textVon } from '../rip/parser'
|
import { textVon } from '../rip/parser'
|
||||||
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
|
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
|
||||||
|
|
||||||
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
|
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
|
||||||
export const KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/
|
// 5.7.0 (Durchsicht 12.09.2026, F1): Der September-Key lautet
|
||||||
|
// „T-vwvn…6Ox4BI@N5rPp" — 68 Zeichen MIT einem @. Das alte Muster
|
||||||
|
// /T-[A-Za-z0-9_]{50,}/ endete am @ und lieferte 62 Zeichen; MakeMKV lehnte
|
||||||
|
// den Stumpf ab (5020), Rippy nahm ihn zurück, und MakeMKV lief ohne
|
||||||
|
// Schlüssel. Jetzt zählt alles bis zum nächsten Leerraum oder HTML-Zeichen —
|
||||||
|
// Rippy rät nicht, welche Zeichen der Hersteller in seine Schlüssel setzt.
|
||||||
|
export const KEY_MUSTER = /T-[^\s<>"'&;]{50,}/
|
||||||
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
||||||
const KEY_NAME = 'app_Key'
|
const KEY_NAME = 'app_Key'
|
||||||
|
|
||||||
@@ -28,12 +40,15 @@ export function extractKey(html: string): string {
|
|||||||
|
|
||||||
/** „valid until/through <Datum>" aus dem Thread — best effort, Rohtext.
|
/** „valid until/through <Datum>" aus dem Thread — best effort, Rohtext.
|
||||||
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
|
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
|
||||||
* aber makemkvcon selbst (MSG 5051 = abgelaufen). */
|
* aber makemkvcon selbst (MSG 5051 = abgelaufen). Der Thread schreibt
|
||||||
|
* mal „September 30th, 2026", mal „end of September 2026" (so am
|
||||||
|
* 12.09.2026 gemessen) — beide Formen kommen an. */
|
||||||
export function extractGueltigBis(html: string): string {
|
export function extractGueltigBis(html: string): string {
|
||||||
const treffer = /valid\s+(?:until|through)\s+([A-Za-zäöüÄÖÜ]+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})/i.exec(
|
const treffer =
|
||||||
html ?? '',
|
/valid\s+(?:until|through)\s+((?:(?:the\s+)?end\s+of\s+|(?:the\s+)?beginning\s+of\s+|mid-?\s*)?[A-Za-zäöüÄÖÜ]+(?:\s+\d{1,2}(?:st|nd|rd|th)?,?)?\s+\d{4})/i.exec(
|
||||||
)
|
html ?? '',
|
||||||
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1')
|
)
|
||||||
|
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1').replace(/\s+/g, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForumKey {
|
export interface ForumKey {
|
||||||
@@ -79,6 +94,19 @@ export function keyZuruecknehmen(): boolean {
|
|||||||
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
|
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Den Wert aus einer `reg query`-Ausgabe lesen (pur, testbar):
|
||||||
|
* „ app_Key REG_SZ T-…" — '' wenn keiner dasteht. */
|
||||||
|
export function keyAusQuery(ausgabe: string): string {
|
||||||
|
const treffer = new RegExp(`${KEY_NAME}\\s+REG_SZ\\s+(\\S+)`).exec(ausgabe ?? '')
|
||||||
|
return treffer === null ? '' : treffer[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Schlüssel, der gerade im Register steht — '' wenn keiner. */
|
||||||
|
export function keyLesen(): string {
|
||||||
|
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', KEY_NAME])
|
||||||
|
return vorhanden.ok ? keyAusQuery(vorhanden.ausgabe) : ''
|
||||||
|
}
|
||||||
|
|
||||||
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt — wer den Web-Kontakt
|
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt — wer den Web-Kontakt
|
||||||
* bewusst abgeschaltet hat, behält seine Entscheidung. */
|
* bewusst abgeschaltet hat, behält seine Entscheidung. */
|
||||||
export function updateKanalSicherstellen(): boolean {
|
export function updateKanalSicherstellen(): boolean {
|
||||||
@@ -89,6 +117,9 @@ export function updateKanalSicherstellen(): boolean {
|
|||||||
|
|
||||||
export interface KeyUrteil {
|
export interface KeyUrteil {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
|
/** false: makemkvcon hat die Lizenz gar nicht angesehen — es sah kein
|
||||||
|
* Laufwerk (5042). Dann ist `ok` keine Aussage über den Schlüssel. */
|
||||||
|
pruefbar: boolean
|
||||||
code: number | null
|
code: number | null
|
||||||
grund: string
|
grund: string
|
||||||
/** Die MakeMKV-Version aus MSG 1005 („MakeMKV v1.18.4 …") — '' wenn
|
/** Die MakeMKV-Version aus MSG 1005 („MakeMKV v1.18.4 …") — '' wenn
|
||||||
@@ -98,7 +129,9 @@ export interface KeyUrteil {
|
|||||||
|
|
||||||
/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt:
|
/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt:
|
||||||
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
|
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
|
||||||
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026).
|
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026) — ABER nur mit einem
|
||||||
|
* angeschlossenen Laufwerk (12.09.2026 ohne Laufwerk gemessen: 5042 und
|
||||||
|
* 5010, keine Lizenzzeile).
|
||||||
*
|
*
|
||||||
* ⚠ ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
|
* ⚠ ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
|
||||||
* 5021 zusammen (30.08.2026 live gemessen) — 5020 allein klingt nach
|
* 5021 zusammen (30.08.2026 live gemessen) — 5020 allein klingt nach
|
||||||
@@ -108,7 +141,7 @@ export interface KeyUrteil {
|
|||||||
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
|
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
|
||||||
return new Promise((fertig) => {
|
return new Promise((fertig) => {
|
||||||
if (programm.length === 0) {
|
if (programm.length === 0) {
|
||||||
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
|
fertig({ ok: false, pruefbar: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
|
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
|
||||||
@@ -121,7 +154,7 @@ export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUr
|
|||||||
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||||
kind.on('error', (fehler) => {
|
kind.on('error', (fehler) => {
|
||||||
clearTimeout(zeiger)
|
clearTimeout(zeiger)
|
||||||
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
|
fertig({ ok: false, pruefbar: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
|
||||||
})
|
})
|
||||||
kind.on('close', () => {
|
kind.on('close', () => {
|
||||||
clearTimeout(zeiger)
|
clearTimeout(zeiger)
|
||||||
@@ -130,10 +163,14 @@ export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MakeMKV sah kein Laufwerk — ohne eines prüft es die Lizenz nicht. */
|
||||||
|
export const KEIN_LAUFWERK = 5042
|
||||||
|
|
||||||
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
|
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
|
||||||
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
||||||
const codes: number[] = []
|
const codes: number[] = []
|
||||||
let version = ''
|
let version = ''
|
||||||
|
let keinLaufwerk = false
|
||||||
for (const zeile of ausgabe.split(/\r?\n/)) {
|
for (const zeile of ausgabe.split(/\r?\n/)) {
|
||||||
if (!zeile.startsWith('MSG:')) continue
|
if (!zeile.startsWith('MSG:')) continue
|
||||||
const code = Number(zeile.slice(4).split(',', 1)[0])
|
const code = Number(zeile.slice(4).split(',', 1)[0])
|
||||||
@@ -142,21 +179,29 @@ export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
|||||||
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
|
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
|
||||||
if (treffer !== null) version = treffer[1]
|
if (treffer !== null) version = treffer[1]
|
||||||
}
|
}
|
||||||
|
if (code === KEIN_LAUFWERK) keinLaufwerk = true
|
||||||
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
|
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
|
||||||
}
|
}
|
||||||
if (codes.length === 0) return { ok: true, code: null, grund: '', version }
|
if (codes.length === 0) {
|
||||||
|
// Keine Lizenzzeile: entweder angenommen — oder gar nicht angesehen.
|
||||||
|
return keinLaufwerk
|
||||||
|
? { ok: true, pruefbar: false, code: KEIN_LAUFWERK, grund: 'MakeMKV sah kein Laufwerk und hat die Lizenz nicht geprüft', version }
|
||||||
|
: { ok: true, pruefbar: true, code: null, grund: '', version }
|
||||||
|
}
|
||||||
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
|
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
|
||||||
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
|
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
|
||||||
const code = codes.includes(5021) ? 5021 : codes[0]
|
const code = codes.includes(5021) ? 5021 : codes[0]
|
||||||
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
|
return { ok: false, pruefbar: true, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchluesselStand {
|
export interface SchluesselStand {
|
||||||
/** 'dauerlizenz' | 'forum' | 'keiner' */
|
/** 'dauerlizenz' | 'vorhanden' | 'forum' | 'keiner' */
|
||||||
quelle: string
|
quelle: string
|
||||||
|
/** true angenommen · false abgelehnt/fehlt · null nicht prüfbar (kein Laufwerk). */
|
||||||
angenommen: boolean | null
|
angenommen: boolean | null
|
||||||
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja — das ist eine
|
/** Laufen Rips trotzdem? Ohne angenommenen Schlüssel: DVDs ja (laut
|
||||||
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */
|
* Hersteller dauerhaft frei), Blu-rays nur in MakeMKVs Gnadenfrist —
|
||||||
|
* das ist ein AUSFALL für Blu-ray, keine Warnung (5.7.0). */
|
||||||
weiterBetrieb: boolean
|
weiterBetrieb: boolean
|
||||||
gueltigBis: string
|
gueltigBis: string
|
||||||
geholtAm: string
|
geholtAm: string
|
||||||
@@ -165,19 +210,51 @@ export interface SchluesselStand {
|
|||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die ganze Kette: Dauerlizenz → Forum-Key → ablegen → PRÜFEN →
|
/** Was ein abgelehnter oder fehlender Schlüssel bedeutet — EIN Satz für
|
||||||
* bei Ablehnung zurücknehmen. Gibt den Stand fürs Dashboard zurück. */
|
* alle Wege, damit das Fenster nie „Rips laufen" sagt, wo Blu-rays nicht
|
||||||
|
* laufen. */
|
||||||
|
export const OHNE_SCHLUESSEL_FOLGE =
|
||||||
|
'Ohne angenommenen Schlüssel liest MakeMKV DVDs weiter (dauerhaft frei), Blu-rays und 4K aber nur in seiner Gnadenfrist nach dem Testzeitraum — danach verweigert es sie.'
|
||||||
|
|
||||||
|
/** Die ganze Kette: Dauerlizenz → vorhandener Schlüssel → Forum-Key →
|
||||||
|
* ablegen → PRÜFEN → bei Ablehnung zurücknehmen. Gibt den Stand fürs
|
||||||
|
* Dashboard zurück. */
|
||||||
export async function schluesselKette(
|
export async function schluesselKette(
|
||||||
makemkvcon: string,
|
makemkvcon: string,
|
||||||
dauerlizenz: string,
|
dauerlizenz: string,
|
||||||
jetztIso: string,
|
jetztIso: string,
|
||||||
holen: typeof fetch = fetch,
|
holen: typeof fetch = fetch,
|
||||||
|
registerLesen: () => string = keyLesen,
|
||||||
): Promise<SchluesselStand> {
|
): Promise<SchluesselStand> {
|
||||||
updateKanalSicherstellen()
|
updateKanalSicherstellen()
|
||||||
|
|
||||||
|
const versionsText = (urteil: KeyUrteil): string => (urteil.version.length > 0 ? ` ${urteil.version}` : '')
|
||||||
|
const nichtPruefbar = (quelle: string, gueltigBis: string, urteil: KeyUrteil, wer: string): SchluesselStand => ({
|
||||||
|
quelle,
|
||||||
|
angenommen: null,
|
||||||
|
weiterBetrieb: true,
|
||||||
|
gueltigBis,
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: `${wer} hinterlegt — ob MakeMKV${versionsText(urteil)} ihn annimmt, lässt sich erst mit angeschlossenem Laufwerk prüfen (${urteil.grund}).`,
|
||||||
|
version: urteil.version,
|
||||||
|
})
|
||||||
|
|
||||||
if (dauerlizenz.trim().length > 0) {
|
if (dauerlizenz.trim().length > 0) {
|
||||||
keyAblegen(dauerlizenz)
|
// 5.7.0: Das Ergebnis von reg add zählt — „hinterlegt" stand vorher
|
||||||
|
// auch da, wenn nichts geschrieben wurde.
|
||||||
|
if (!keyAblegen(dauerlizenz)) {
|
||||||
|
return {
|
||||||
|
quelle: 'dauerlizenz',
|
||||||
|
angenommen: false,
|
||||||
|
weiterBetrieb: false,
|
||||||
|
gueltigBis: '',
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: 'Eigener Schlüssel konnte nicht ins Register geschrieben werden (reg add scheiterte). ' + OHNE_SCHLUESSEL_FOLGE,
|
||||||
|
version: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
const urteil = await keyPruefen(makemkvcon)
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.pruefbar) return nichtPruefbar('dauerlizenz', '', urteil, 'Eigener Schlüssel')
|
||||||
return {
|
return {
|
||||||
quelle: 'dauerlizenz',
|
quelle: 'dauerlizenz',
|
||||||
angenommen: urteil.ok,
|
angenommen: urteil.ok,
|
||||||
@@ -185,54 +262,85 @@ export async function schluesselKette(
|
|||||||
gueltigBis: '',
|
gueltigBis: '',
|
||||||
geholtAm: jetztIso,
|
geholtAm: jetztIso,
|
||||||
text: urteil.ok
|
text: urteil.ok
|
||||||
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
|
? `Eigener Schlüssel hinterlegt und von MakeMKV${versionsText(urteil)} angenommen.`
|
||||||
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
: `Eigener Schlüssel abgelehnt: ${urteil.grund}. ${OHNE_SCHLUESSEL_FOLGE}`,
|
||||||
version: urteil.version,
|
version: urteil.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5.7.0: Ein Schlüssel, der schon steht und angenommen wird, bleibt —
|
||||||
|
// egal ob gekauft (im GUI eingetragen) oder ein älterer Forum-Key.
|
||||||
|
const vorhanden = registerLesen()
|
||||||
|
if (vorhanden.length > 0) {
|
||||||
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.pruefbar) return nichtPruefbar('vorhanden', '', urteil, 'Ein Schlüssel ist')
|
||||||
|
if (urteil.ok) {
|
||||||
|
return {
|
||||||
|
quelle: 'vorhanden',
|
||||||
|
angenommen: true,
|
||||||
|
weiterBetrieb: true,
|
||||||
|
gueltigBis: '',
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: `Der hinterlegte Schlüssel wird von MakeMKV${versionsText(urteil)} angenommen — Rippy lässt ihn stehen.`,
|
||||||
|
version: urteil.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Abgelehnt: dann darf der Forum-Key ihn ersetzen (unten).
|
||||||
|
}
|
||||||
|
|
||||||
const forum = await betaKeyHolen(holen)
|
const forum = await betaKeyHolen(holen)
|
||||||
if (forum === null) {
|
if (forum === null) {
|
||||||
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
|
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
|
||||||
// wird geprüft, damit der Stand ehrlich ist.
|
// wird geprüft, damit der Stand ehrlich ist.
|
||||||
const urteil = await keyPruefen(makemkvcon)
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.pruefbar) return nichtPruefbar('keiner', '', urteil, 'Forum nicht erreichbar; der vorhandene Stand ist')
|
||||||
return {
|
return {
|
||||||
quelle: 'keiner',
|
quelle: 'keiner',
|
||||||
angenommen: urteil.ok,
|
angenommen: urteil.ok,
|
||||||
weiterBetrieb: true,
|
weiterBetrieb: urteil.ok,
|
||||||
gueltigBis: '',
|
gueltigBis: '',
|
||||||
geholtAm: jetztIso,
|
geholtAm: jetztIso,
|
||||||
text: urteil.ok
|
text: urteil.ok
|
||||||
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
|
||||||
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`,
|
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}. ${OHNE_SCHLUESSEL_FOLGE}`,
|
||||||
version: urteil.version,
|
version: urteil.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
keyAblegen(forum.key)
|
if (!keyAblegen(forum.key)) {
|
||||||
|
return {
|
||||||
|
quelle: 'forum',
|
||||||
|
angenommen: false,
|
||||||
|
weiterBetrieb: false,
|
||||||
|
gueltigBis: forum.gueltigBis,
|
||||||
|
geholtAm: jetztIso,
|
||||||
|
text: 'Beta-Key geholt, aber nicht ins Register schreibbar (reg add scheiterte). ' + OHNE_SCHLUESSEL_FOLGE,
|
||||||
|
version: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
const urteil = await keyPruefen(makemkvcon)
|
const urteil = await keyPruefen(makemkvcon)
|
||||||
|
if (!urteil.pruefbar) return nichtPruefbar('forum', forum.gueltigBis, urteil, 'Beta-Key aus dem Forum')
|
||||||
if (!urteil.ok) {
|
if (!urteil.ok) {
|
||||||
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
|
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
|
||||||
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
|
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
|
||||||
// alles; OHNE läuft es im Beta-Modus weiter und rippt.)
|
// alles; OHNE läuft es im Beta-Modus weiter und rippt — DVDs immer,
|
||||||
|
// Blu-rays nur bis zum Ende des Testzeitraums.)
|
||||||
keyZuruecknehmen()
|
keyZuruecknehmen()
|
||||||
}
|
}
|
||||||
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
|
|
||||||
return {
|
return {
|
||||||
quelle: 'forum',
|
quelle: 'forum',
|
||||||
angenommen: urteil.ok,
|
angenommen: urteil.ok,
|
||||||
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen —
|
weiterBetrieb: urteil.ok,
|
||||||
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
|
|
||||||
weiterBetrieb: true,
|
|
||||||
gueltigBis: forum.gueltigBis,
|
gueltigBis: forum.gueltigBis,
|
||||||
geholtAm: jetztIso,
|
geholtAm: jetztIso,
|
||||||
text: urteil.ok
|
text: urteil.ok
|
||||||
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
|
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText(urteil)} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
|
||||||
: urteil.code === 5021
|
: urteil.code === 5021
|
||||||
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` +
|
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText(urteil)} ist ZU ALT für ihn. ` +
|
||||||
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
|
'Rippy hat ihn wieder entfernt. ' +
|
||||||
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
|
OHNE_SCHLUESSEL_FOLGE +
|
||||||
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
|
' Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
|
||||||
|
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt. ${OHNE_SCHLUESSEL_FOLGE}`,
|
||||||
version: urteil.version,
|
version: urteil.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,20 @@
|
|||||||
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
|
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
|
||||||
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
|
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
|
||||||
// dort, die settings.conf ist der Linux-Weg).
|
// dort, die settings.conf ist der Linux-Weg).
|
||||||
|
//
|
||||||
|
// 5.7.0: Seit der Dialog jede Spur einzeln anbietet, dürfen auch die
|
||||||
|
// Stereo-Doppel bleiben (havemulti — „Deutsch · DD · 2.0 (Kommentar)"
|
||||||
|
// neben „Deutsch · DTS-HD MA · 5.1"): Nur die abgeleiteten DTS-Kerne
|
||||||
|
// (havecore) fallen weg, die zeigt der Dialog gar nicht erst. Stand
|
||||||
|
// Rippys ALTE Regel in der Registry, wird sie ersetzt; eine fremde bleibt.
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
||||||
export const REGEL_NAME = 'app_DefaultSelectionString'
|
export const REGEL_NAME = 'app_DefaultSelectionString'
|
||||||
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
|
/** Alles behalten, nur die Kerne weg — sprachunabhängig (5.7.0). */
|
||||||
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
|
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:havecore,=100:all'
|
||||||
|
/** Rippys frühere Regeln (5.4.0–5.6.1) — werden beim Start ersetzt. */
|
||||||
|
export const RIPPY_REGELN_ALT: readonly string[] = ['-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all']
|
||||||
|
|
||||||
export interface SpurauswahlStand {
|
export interface SpurauswahlStand {
|
||||||
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
|
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
|
||||||
@@ -37,6 +45,16 @@ export interface SpurauswahlStand {
|
|||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was mit einer vorgefundenen Regel geschieht (pur, testbar):
|
||||||
|
* 'rippy' = steht schon · 'ersetzen' = Rippys alte Regel · 'eigen' =
|
||||||
|
* fremd, bleibt · 'setzen' = keine da. */
|
||||||
|
export function regelUrteil(bisher: string | null): 'rippy' | 'ersetzen' | 'eigen' | 'setzen' {
|
||||||
|
if (bisher === null || bisher.length === 0) return 'setzen'
|
||||||
|
if (bisher === RIPPY_REGEL) return 'rippy'
|
||||||
|
if (RIPPY_REGELN_ALT.includes(bisher)) return 'ersetzen'
|
||||||
|
return 'eigen'
|
||||||
|
}
|
||||||
|
|
||||||
function reg(args: string[]): { ok: boolean; ausgabe: string } {
|
function reg(args: string[]): { ok: boolean; ausgabe: string } {
|
||||||
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
|
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
|
||||||
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
|
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
|
||||||
@@ -56,14 +74,15 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
|
|||||||
}
|
}
|
||||||
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
|
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
|
||||||
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
|
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
|
||||||
if (bisher !== null && bisher.length > 0) {
|
const urteil = regelUrteil(bisher)
|
||||||
if (bisher === RIPPY_REGEL) {
|
if (urteil === 'rippy') {
|
||||||
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
|
return { stand: 'rippy', regel: bisher ?? '', text: 'MakeMKV behält alle Spuren (Rippys Auswahlregel steht).' }
|
||||||
}
|
}
|
||||||
|
if (urteil === 'eigen') {
|
||||||
return {
|
return {
|
||||||
stand: 'eigen',
|
stand: 'eigen',
|
||||||
regel: bisher,
|
regel: bisher ?? '',
|
||||||
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
|
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Spuren in der Roh-Datei, liegt es daran.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
|
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
|
||||||
@@ -73,6 +92,9 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
|
|||||||
return {
|
return {
|
||||||
stand: 'gesetzt',
|
stand: 'gesetzt',
|
||||||
regel: RIPPY_REGEL,
|
regel: RIPPY_REGEL,
|
||||||
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
|
text:
|
||||||
|
urteil === 'ersetzen'
|
||||||
|
? 'MakeMKV-Auswahlregel erneuert: jede Spur kommt in die Roh-Datei, auch Stereo-Fassungen und Kommentare (nur DTS-Kerne nicht).'
|
||||||
|
: 'MakeMKV-Auswahlregel gesetzt: jede Spur kommt in die Roh-Datei (nur DTS-Kerne nicht).',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,3 +172,28 @@ describe('Encoder- und Preset-Listen', () => {
|
|||||||
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
|
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F3): Nur die Dateien DIESER Disc werden benannt — der
|
||||||
|
// Season-Ordner darf voll sein.
|
||||||
|
describe('episodenUmbenennen mit nurDiese (5.7.0)', () => {
|
||||||
|
it('Bestand im Ordner stört nicht', () => {
|
||||||
|
const umbenannt: Array<[string, string]> = []
|
||||||
|
const meldungen = episodenUmbenennen(
|
||||||
|
'S',
|
||||||
|
'Akte X',
|
||||||
|
1,
|
||||||
|
[3, 4],
|
||||||
|
{
|
||||||
|
listen: () => ['Akte X S01E01.mkv', 'Akte X S01E02.mkv', 'title_t00.mkv', 'title_t01.mkv'],
|
||||||
|
existiert: () => false,
|
||||||
|
umbenennen: (von, nach) => umbenannt.push([von, nach]),
|
||||||
|
},
|
||||||
|
['title_t00.mkv', 'title_t01.mkv'],
|
||||||
|
)
|
||||||
|
expect(umbenannt).toEqual([
|
||||||
|
[join('S', 'title_t00.mkv'), join('S', 'Akte X S01E03.mkv')],
|
||||||
|
[join('S', 'title_t01.mkv'), join('S', 'Akte X S01E04.mkv')],
|
||||||
|
])
|
||||||
|
expect(meldungen).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ describe('komprimierenUndAblegen', () => {
|
|||||||
const ergebnis = await komprimierenUndAblegen(
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
auftrag({ rohOrdner: roh, dateien }),
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
umgebung(ablage, {
|
umgebung(ablage, {
|
||||||
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }),
|
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch', codec: 'dtshd', kanaele: 6 }], untertitel: [], dauerS: 3167 }),
|
||||||
}),
|
}),
|
||||||
() => {},
|
() => {},
|
||||||
KEIN_ABBRUCH,
|
KEIN_ABBRUCH,
|
||||||
@@ -347,3 +347,139 @@ describe('Wiederholen ersetzt die alte Fassung', () => {
|
|||||||
expect(readFileSync(alt, 'utf8')).toBe('alt')
|
expect(readFileSync(alt, 'utf8')).toBe('alt')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ─────────────────────────
|
||||||
|
describe('komprimierenUndAblegen — Durchsicht 5.7.0', () => {
|
||||||
|
// F3: Disc 2 derselben Staffel landet im selben Season-Ordner wie Disc 1.
|
||||||
|
// Vorher zählte die Umbenennung ALLE MKVs des Ordners („4 Dateien, 2
|
||||||
|
// Zuordnungen") und tat nichts — der Serien-Merker lief ins Leere.
|
||||||
|
it('F3: zweite Disc in denselben Season-Ordner — nur ihre Folgen werden benannt', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
|
||||||
|
const season = join(ablage, 'Serien', 'Spartacus', 'Season 01')
|
||||||
|
mkdirSync(season, { recursive: true })
|
||||||
|
writeFileSync(join(season, 'Spartacus S01E01.mkv'), 'disc1')
|
||||||
|
writeFileSync(join(season, 'Spartacus S01E02.mkv'), 'disc1')
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1, ersteFolge: 3 }),
|
||||||
|
umgebung(ablage),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(readdirSync(season).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
|
||||||
|
'Spartacus S01E01.mkv',
|
||||||
|
'Spartacus S01E02.mkv',
|
||||||
|
'Spartacus S01E03.mkv',
|
||||||
|
'Spartacus S01E04.mkv',
|
||||||
|
])
|
||||||
|
expect(readFileSync(join(season, 'Spartacus S01E01.mkv'), 'utf8')).toBe('disc1')
|
||||||
|
expect(ergebnis.folgenVergeben).toEqual([3, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
// F3: Liegt im Season-Ordner noch ein title_t00.mkv der ersten Disc
|
||||||
|
// (deren Umbenennung ausfiel), darf die zweite es NICHT überschreiben.
|
||||||
|
it('F3: gleichnamige Datei im Ziel wird nie überschrieben — „(2)" statt Verlust', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'], ['folge'])
|
||||||
|
const season = join(ablage, 'Serien', 'Spartacus', 'Season 01')
|
||||||
|
mkdirSync(season, { recursive: true })
|
||||||
|
writeFileSync(join(season, 'title_t00.mkv'), 'disc1-unbenannt')
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1 }),
|
||||||
|
umgebung(ablage),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(readFileSync(join(season, 'title_t00.mkv'), 'utf8')).toBe('disc1-unbenannt')
|
||||||
|
expect(existsSync(join(season, 'title_t00 (2).mkv'))).toBe(true)
|
||||||
|
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['title_t00 (2).mkv'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Spuren einzeln ──────────────────────────────────────────────────
|
||||||
|
describe('Spuren einzeln (5.7.0)', () => {
|
||||||
|
const jpnHd = { nr: 1, lang: 'jpn', sprache: 'Japanese', codec: 'DTS-HD MA', kanaele: '6', layout: 'Surround 5.1', beschreibung: '', abgeleitet: false }
|
||||||
|
const deuKommentar = { nr: 4, lang: 'deu', sprache: 'German', codec: 'DD', kanaele: '2', layout: 'Stereo', beschreibung: 'DD Stereo German (Kommentar)', abgeleitet: false }
|
||||||
|
const deuPgs = { nr: 5, lang: 'deu', sprache: 'German', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS German', abgeleitet: false }
|
||||||
|
const engPgs = { nr: 7, lang: 'eng', sprache: 'English', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS English', abgeleitet: false }
|
||||||
|
const scan = {
|
||||||
|
audio: [
|
||||||
|
{ nr: 1, lang: 'jpn', beschreibung: '', codec: 'dtshd', kanaele: 6 },
|
||||||
|
{ nr: 2, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 6 },
|
||||||
|
{ nr: 3, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 2 },
|
||||||
|
],
|
||||||
|
untertitel: [
|
||||||
|
{ nr: 1, lang: 'deu', beschreibung: '', codec: 'PGS', kanaele: 0 },
|
||||||
|
{ nr: 2, lang: 'eng', beschreibung: '', codec: 'PGS', kanaele: 0 },
|
||||||
|
],
|
||||||
|
dauerS: 3167,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('gewählte Spuren erreichen HandBrake als Nummern — japanischer Ton stellt die deutschen Untertitel vor und wählt sie vor', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
dateien[0].audioSprachen = ['jpn', 'deu']
|
||||||
|
dateien[0].untertitelSprachen = ['eng', 'deu']
|
||||||
|
dateien[0].audioSpuren = [jpnHd, deuKommentar]
|
||||||
|
dateien[0].untertitelSpuren = [engPgs, deuPgs]
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { spurenScannen: async () => scan }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(hb.aufrufe[0].audioNr).toEqual([1, 3])
|
||||||
|
expect(hb.aufrufe[0].untertitelNr).toEqual([1, 2])
|
||||||
|
expect(hb.aufrufe[0].untertitelStandardNr).toBe(1)
|
||||||
|
expect(ergebnis.hinweise.join(' ')).not.toContain('NICHT in der Roh-Datei')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eine gewählte Spur fehlt in der Roh-Datei → Hinweis, der Rest läuft', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' }
|
||||||
|
dateien[0].audioSpuren = [fra, deuKommentar]
|
||||||
|
dateien[0].untertitelSpuren = []
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { spurenScannen: async () => scan }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(hb.aufrufe[0].audioNr).toEqual([3])
|
||||||
|
expect(hb.aufrufe[0].untertitelNr).toEqual([])
|
||||||
|
expect(ergebnis.hinweise.join(' ')).toContain('Tonspur French DTS-HD MA ist NICHT in der Roh-Datei')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Scan der Roh-Datei scheitert → Sprachen der gewählten Spuren als Liste (wie bisher)', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
dateien[0].audioSpuren = [jpnHd]
|
||||||
|
dateien[0].untertitelSpuren = [deuPgs]
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { spurenScannen: async () => null }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(hb.aufrufe[0].audioNr).toBeUndefined()
|
||||||
|
expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
|
||||||
|
expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu'])
|
||||||
|
expect(hb.aufrufe[0].untertitelStandard).toBe(true)
|
||||||
|
expect(ergebnis.hinweise.join(' ')).toContain('ließen sich nicht messen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keine gewählte Tonspur findbar → Sprachlisten statt stummer Datei', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' }
|
||||||
|
dateien[0].audioSpuren = [fra]
|
||||||
|
dateien[0].untertitelSpuren = [deuPgs]
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { spurenScannen: async () => scan }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(hb.aufrufe[0].audioNr).toBeUndefined()
|
||||||
|
expect(hb.aufrufe[0].audioSprachen).toEqual(['fra'])
|
||||||
|
expect(ergebnis.hinweise.join(' ')).toContain('keine der gewählten Tonspuren gefunden')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -160,4 +160,19 @@ describe('Schlüsselkette (§ 9.1)', () => {
|
|||||||
expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026')
|
expect(extractGueltigBis('The key is valid until September 30th, 2026 or so')).toBe('September 30, 2026')
|
||||||
expect(extractGueltigBis('nichts')).toBe('')
|
expect(extractGueltigBis('nichts')).toBe('')
|
||||||
})
|
})
|
||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F1): So steht es WIRKLICH auf der Forum-
|
||||||
|
// Seite — der September-Key trägt ein @, und das Datum heißt „end of".
|
||||||
|
// Das alte Muster endete am @ und lieferte 62 von 68 Zeichen.
|
||||||
|
it('der echte September-2026-Key mit @ kommt ganz an', () => {
|
||||||
|
const key = 'T-vwvnrmF87JuRPLYYO8HnIrZR_LCAF7JXs9iJEm10moGpjEgkvmPTsf6Ox4BI@N5rPp'
|
||||||
|
const html = `<div class="content">The current beta key is:<br><code>${key}</code><br>The key is valid until end of September 2026. Please check back for updated key on this page.</div>`
|
||||||
|
expect(extractKey(html)).toBe(key)
|
||||||
|
expect(extractKey(html)).toHaveLength(68)
|
||||||
|
expect(extractGueltigBis(html)).toBe('end of September 2026')
|
||||||
|
})
|
||||||
|
it('der Key endet am HTML-Zeichen, nicht mitten im Wort', () => {
|
||||||
|
const key = 'T-' + 'a'.repeat(66)
|
||||||
|
expect(extractKey(`<code>${key}</code><p>valid</p>`)).toBe(key)
|
||||||
|
expect(extractKey(`key: ${key} (valid until end of October 2026)`)).toBe(key)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { KernKontext } from '../src/kern/kontext'
|
|||||||
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
|
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
|
||||||
|
|
||||||
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
|
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
|
||||||
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] } }
|
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] }, spuren: { audio: [], untertitel: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
|
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
|
||||||
@@ -144,16 +144,21 @@ describe('AutomatikDienst', () => {
|
|||||||
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
|
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
|
||||||
expect(a.gestartet).toHaveLength(0)
|
expect(a.gestartet).toHaveLength(0)
|
||||||
})
|
})
|
||||||
it('aus, CD, unbekannte Disc oder laufender Rip: gar nichts', async () => {
|
it('aus oder laufender Rip: gar nichts — CD und unbekannte Disc sagen, warum (5.7.0, F28)', async () => {
|
||||||
const a = aufbau()
|
const a = aufbau()
|
||||||
a.setAn(false)
|
a.setAn(false)
|
||||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||||
a.setAn(true)
|
|
||||||
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
|
|
||||||
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
|
|
||||||
a.setRipLaeuft(true)
|
a.setRipLaeuft(true)
|
||||||
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
||||||
expect(a.nachrichten).toHaveLength(0)
|
expect(a.nachrichten).toHaveLength(0)
|
||||||
|
a.setRipLaeuft(false)
|
||||||
|
a.setAn(true)
|
||||||
|
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
|
||||||
|
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
|
||||||
|
expect(a.nachrichten).toHaveLength(2)
|
||||||
|
expect(a.nachrichten[0].text).toContain('Audio-CD')
|
||||||
|
expect(a.nachrichten[1].text).toContain('unbekannt')
|
||||||
|
expect(a.gestartet).toHaveLength(0)
|
||||||
})
|
})
|
||||||
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
|
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
|
||||||
const a = aufbau()
|
const a = aufbau()
|
||||||
|
|||||||
@@ -136,10 +136,11 @@ describe('dateiPruefen', () => {
|
|||||||
describe('makemkvInstallerHolen', () => {
|
describe('makemkvInstallerHolen', () => {
|
||||||
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
|
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
|
||||||
|
|
||||||
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt', async () => {
|
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt (mit Freigabe)', async () => {
|
||||||
const ordner = tempOrdner()
|
const ordner = tempOrdner()
|
||||||
const holen = (async (url: string | URL | Request) =>
|
const holen = (async (url: string | URL | Request) =>
|
||||||
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
||||||
|
const gefragt: string[] = []
|
||||||
const ergebnis = await makemkvInstallerHolen(
|
const ergebnis = await makemkvInstallerHolen(
|
||||||
'1.18.4',
|
'1.18.4',
|
||||||
() => {},
|
() => {},
|
||||||
@@ -147,16 +148,35 @@ describe('makemkvInstallerHolen', () => {
|
|||||||
holen,
|
holen,
|
||||||
ordner,
|
ordner,
|
||||||
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
|
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
|
||||||
|
async (quelle) => {
|
||||||
|
gefragt.push(quelle)
|
||||||
|
return true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
expect(gefragt).toEqual(['Internet Archive'])
|
||||||
expect(ergebnis.quelle).toBe('Internet Archive')
|
expect(ergebnis.quelle).toBe('Internet Archive')
|
||||||
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
|
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
|
||||||
|
expect(ergebnis.bytes).toBe(GROSS.length)
|
||||||
|
expect(ergebnis.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||||
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
|
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F16): Ohne Freigabe wird das Archiv NIE benutzt — es
|
||||||
|
// liefert eine unsignierte EXE, die mit Adminrechten läuft.
|
||||||
|
it('ohne Freigabe bleibt das Archiv aus — und der Bericht sagt es', async () => {
|
||||||
|
const ordner = tempOrdner()
|
||||||
|
const holen = (async (url: string | URL | Request) =>
|
||||||
|
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
||||||
|
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(/Internet Archive: nicht freigegeben/)
|
||||||
|
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => false)).rejects.toThrow(
|
||||||
|
/Internet Archive: nicht freigegeben/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
|
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
|
||||||
const ordner = tempOrdner()
|
const ordner = tempOrdner()
|
||||||
const holen = (async () => antwort('weg', 404)) as typeof fetch
|
const holen = (async () => antwort('weg', 404)) as typeof fetch
|
||||||
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(
|
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => true)).rejects.toThrow(
|
||||||
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
|
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -166,6 +186,8 @@ describe('Nachrichten-Schema der Update-Strecke', () => {
|
|||||||
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
|
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
|
||||||
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
|
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
|
||||||
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
|
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'beschaffung-antwort', ja: true })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'beschaffung-antwort' })).toBe(false)
|
||||||
})
|
})
|
||||||
it('Haupt → Kern: installer-ergebnis', () => {
|
it('Haupt → Kern: installer-ergebnis', () => {
|
||||||
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)
|
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)
|
||||||
|
|||||||
@@ -28,12 +28,46 @@ describe('Fund 1 — „Key korrekt, aber als ungültig angezeigt"', () => {
|
|||||||
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","x"\nMSG:5010,0,0,"Das Öffnen der Disk schlug fehl","x"',
|
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","x"\nMSG:5010,0,0,"Das Öffnen der Disk schlug fehl","x"',
|
||||||
)
|
)
|
||||||
expect(urteil.ok).toBe(true)
|
expect(urteil.ok).toBe(true)
|
||||||
|
expect(urteil.pruefbar).toBe(true)
|
||||||
expect(urteil.version).toBe('1.18.4')
|
expect(urteil.version).toBe('1.18.4')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('5020 allein bleibt „Schlüssel wird nicht angenommen"', () => {
|
it('5020 allein bleibt „Schlüssel wird nicht angenommen"', () => {
|
||||||
const urteil = urteilAusAusgabe('MSG:5020,516,0,"…","…"')
|
const urteil = urteilAusAusgabe('MSG:5020,516,0,"…","…"')
|
||||||
expect(urteil.code).toBe(5020)
|
expect(urteil.code).toBe(5020)
|
||||||
|
expect(urteil.pruefbar).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F5): OHNE angeschlossenes Laufwerk sagt
|
||||||
|
// makemkvcon zur Lizenz gar nichts — gemessen an diesem PC: nur 5042 und
|
||||||
|
// 5010. Das ist kein „angenommen", sondern „nicht angesehen".
|
||||||
|
it('5042 (kein Laufwerk) ohne Lizenzzeile heißt: nicht prüfbar', () => {
|
||||||
|
const urteil = urteilAusAusgabe(
|
||||||
|
[
|
||||||
|
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"',
|
||||||
|
'MSG:5042,0,0,"Das Programm konnte keine verwendbaren optischen Laufwerke finden.","…"',
|
||||||
|
'MSG:5010,0,0,"Das Öffnen der Disk schlug fehl ","…"',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
expect(urteil.pruefbar).toBe(false)
|
||||||
|
expect(urteil.code).toBe(5042)
|
||||||
|
expect(urteil.version).toBe('1.18.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5051 (Testzeitraum abgelaufen) ist prüfbar und abgelehnt — auch neben 5042', () => {
|
||||||
|
const urteil = urteilAusAusgabe('MSG:5042,0,0,"x","x"\nMSG:5054,0,1,"Evaluierungsversion. Der Testzeitraum lief vor 10 Tagen ab.","x","10"\nMSG:5051,1028,0,"Der Testzeitraum ist abgelaufen.","x"')
|
||||||
|
expect(urteil.ok).toBe(false)
|
||||||
|
expect(urteil.pruefbar).toBe(true)
|
||||||
|
expect(urteil.code).toBe(5051)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Schlüssel aus dem Register lesen (5.7.0)', () => {
|
||||||
|
it('keyAusQuery liest den Wert aus reg query', async () => {
|
||||||
|
const { keyAusQuery } = await import('../src/kern/werkzeuge/schluessel')
|
||||||
|
const ausgabe = '\r\nHKEY_CURRENT_USER\\Software\\MakeMKV\r\n app_Key REG_SZ T-vwvnrmF87JuRPLYYO8HnIrZR_LCAF7JXs9iJEm10moGpjEgkvmPTsf6Ox4BI@N5rPp\r\n\r\n'
|
||||||
|
expect(keyAusQuery(ausgabe)).toBe('T-vwvnrmF87JuRPLYYO8HnIrZR_LCAF7JXs9iJEm10moGpjEgkvmPTsf6Ox4BI@N5rPp')
|
||||||
|
expect(keyAusQuery('FEHLER: Der Registrierungsschlüssel oder -wert wurde nicht gefunden.')).toBe('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,17 @@ describe('rollenNummern', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F3): nie überschreiben — ein freier Name, wo einer
|
||||||
|
// nötig ist.
|
||||||
|
describe('freierName', () => {
|
||||||
|
it('frei bleibt frei, belegt bekommt (2), (3) …', async () => {
|
||||||
|
const { freierName } = await import('../src/kern/ablage/dateinamen')
|
||||||
|
const belegt = new Set(['E:\\S\\x.mkv', 'E:\\S\\x (2).mkv'])
|
||||||
|
expect(freierName('E:\\S\\y.mkv', (p) => belegt.has(p))).toBe('E:\\S\\y.mkv')
|
||||||
|
expect(freierName('E:\\S\\x.mkv', (p) => belegt.has(p))).toBe('E:\\S\\x (3).mkv')
|
||||||
|
expect(freierName('/a/b/x.mkv', () => true).startsWith('/a/b/x (')).toBe(true)
|
||||||
|
// Ein Punkt im Ordnernamen ist keine Endung.
|
||||||
|
expect(freierName('E:\\Akira (1988)\\ohne-endung', (p) => p === 'E:\\Akira (1988)\\ohne-endung')).toBe('E:\\Akira (1988)\\ohne-endung (2)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 5.7.0 (Durchsicht F10): Der Disc-Typ aus dem Dateisystem — BDMV/index.bdmv
|
||||||
|
// mit „INDX0300" ist UHD (am Akira-UHD-Abbild gemessen), „0200"/„0100" eine
|
||||||
|
// Blu-ray, VIDEO_TS eine DVD. Der Windows-Steuercode antwortet bei manchen
|
||||||
|
// USB-Brücken nicht, und die Größe allein machte aus einer 4K-Disc mit
|
||||||
|
// 48 GB Daten „bluray".
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { typAusIndexKopf } from '../src/kern/metadaten/discmerkmale'
|
||||||
|
|
||||||
|
let wegwerf: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('typAusIndexKopf', () => {
|
||||||
|
it('INDX0300 ist UHD, INDX0200/0100 Blu-ray, alles andere nichts', () => {
|
||||||
|
expect(typAusIndexKopf(Buffer.from('INDX0300\0\0\0N', 'latin1'))).toBe('uhd')
|
||||||
|
expect(typAusIndexKopf(Buffer.from('INDX0200xxxx', 'latin1'))).toBe('bluray')
|
||||||
|
expect(typAusIndexKopf(Buffer.from('INDX0100xxxx', 'latin1'))).toBe('bluray')
|
||||||
|
expect(typAusIndexKopf(Buffer.from('MOBJ0200', 'latin1'))).toBe(null)
|
||||||
|
expect(typAusIndexKopf(Buffer.from('IND', 'latin1'))).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('typAusDateisystem an einem nachgebauten Laufzeit-Ordner', () => {
|
||||||
|
// discWurzel() rechnet aus \\.\G: den Buchstaben — das geht nur unter
|
||||||
|
// Windows an einem echten Laufwerk. Hier wird deshalb die Kopf-Funktion
|
||||||
|
// über eine echte Datei geprüft, so wie typAusDateisystem sie liest.
|
||||||
|
it('liest die ersten acht Bytes einer echten index.bdmv', () => {
|
||||||
|
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmv-'))
|
||||||
|
wegwerf.push(wurzel)
|
||||||
|
mkdirSync(join(wurzel, 'BDMV'), { recursive: true })
|
||||||
|
const index = join(wurzel, 'BDMV', 'index.bdmv')
|
||||||
|
writeFileSync(index, Buffer.concat([Buffer.from('INDX0300', 'latin1'), Buffer.alloc(64)]))
|
||||||
|
const { openSync, readSync, closeSync } = require('node:fs') as typeof import('node:fs')
|
||||||
|
const fd = openSync(index, 'r')
|
||||||
|
const kopf = Buffer.alloc(8)
|
||||||
|
readSync(fd, kopf, 0, 8, 0)
|
||||||
|
closeSync(fd)
|
||||||
|
expect(typAusIndexKopf(kopf)).toBe('uhd')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F11): Die Laufwerks-Gesundheit zählt nur die
|
||||||
|
// Ereignisse DIESES Laufwerks. Vorher nahm der XPath Provider cdrom UND
|
||||||
|
// disk für ALLE Geräte — eine hustende Festplatte (\Device\Harddisk1)
|
||||||
|
// erzeugte „VERDACHT: DIE HARDWARE" in der Kachel des Blu-ray-Laufwerks.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { beurteilen, gehoertZuLaufwerk, type LaufwerkEreignis } from '../src/kern/laufwerk/ereignisse'
|
||||||
|
|
||||||
|
const e = (provider: string, id: number, geraet: string): LaufwerkEreignis => ({
|
||||||
|
zeit: '2026-09-02T18:32:00.000Z',
|
||||||
|
provider,
|
||||||
|
id,
|
||||||
|
geraet,
|
||||||
|
})
|
||||||
|
|
||||||
|
const PLATTE = e('disk', 11, '\\Device\\Harddisk1\\DR1')
|
||||||
|
const PLATTE_BLOCK = e('disk', 7, '\\Device\\Harddisk1\\DR1')
|
||||||
|
const CDROM0 = e('cdrom', 7, '\\Device\\CdRom0')
|
||||||
|
const CDROM1 = e('cdrom', 11, '\\Device\\CdRom1')
|
||||||
|
|
||||||
|
describe('gehoertZuLaufwerk', () => {
|
||||||
|
it('mit Gerätename zählt nur dieses Gerät', () => {
|
||||||
|
expect(gehoertZuLaufwerk(CDROM0, '\\Device\\CdRom0')).toBe(true)
|
||||||
|
expect(gehoertZuLaufwerk(CDROM1, '\\Device\\CdRom0')).toBe(false)
|
||||||
|
expect(gehoertZuLaufwerk(PLATTE, '\\Device\\CdRom0')).toBe(false)
|
||||||
|
// Groß/Klein ist Windows egal.
|
||||||
|
expect(gehoertZuLaufwerk(CDROM0, '\\device\\cdrom0')).toBe(true)
|
||||||
|
})
|
||||||
|
it('ohne Gerätename zählt nur der cdrom-Treiber — nie eine Festplatte', () => {
|
||||||
|
expect(gehoertZuLaufwerk(CDROM0, '')).toBe(true)
|
||||||
|
expect(gehoertZuLaufwerk(CDROM1, '')).toBe(true)
|
||||||
|
expect(gehoertZuLaufwerk(PLATTE, '')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('beurteilen je Gerät', () => {
|
||||||
|
it('Controllerfehler der Festplatte beschuldigen nicht das Laufwerk', () => {
|
||||||
|
const bericht = beurteilen([PLATTE, PLATTE_BLOCK, CDROM0], 24, undefined, '\\Device\\CdRom0')
|
||||||
|
expect(bericht.urteil).toBe('disc')
|
||||||
|
expect(bericht.controllerfehler).toBe(0)
|
||||||
|
expect(bericht.fehlerhafteBloecke).toBe(1)
|
||||||
|
expect(bericht.letzte).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('ein zweites optisches Laufwerk zählt nicht mit', () => {
|
||||||
|
const bericht = beurteilen([CDROM1, CDROM0], 24, undefined, '\\Device\\CdRom0')
|
||||||
|
expect(bericht.urteil).toBe('disc')
|
||||||
|
expect(bericht.controllerfehler).toBe(0)
|
||||||
|
})
|
||||||
|
it('nur Festplatten-Ereignisse → für das Laufwerk ist es ruhig, mit Gerätename im Text', () => {
|
||||||
|
const bericht = beurteilen([PLATTE, PLATTE_BLOCK], 24, undefined, '\\Device\\CdRom0')
|
||||||
|
expect(bericht.urteil).toBe('ruhig')
|
||||||
|
expect(bericht.text).toContain('\\Device\\CdRom0')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -171,11 +171,14 @@ describe('presets', () => {
|
|||||||
expect(komprimierenFuer('dvd', { transcodePresetUhd: PRESET_KEINE })).toBe(true)
|
expect(komprimierenFuer('dvd', { transcodePresetUhd: PRESET_KEINE })).toBe(true)
|
||||||
expect(komprimierenFuer('bluray', {})).toBe(true)
|
expect(komprimierenFuer('bluray', {})).toBe(true)
|
||||||
})
|
})
|
||||||
it('presetFuer: Typ-Preset → allgemein → Default, nie „keine"', () => {
|
it('presetFuer: Typ-Preset → Empfehlung → Default, nie „keine" (5.7.0: kein allgemeines Preset mehr, A5)', () => {
|
||||||
expect(presetFuer('dvd', { transcodePresetDvd: 'Fast 480p' })).toBe('Fast 480p')
|
expect(presetFuer('dvd', { transcodePresetDvd: 'Fast 480p' })).toBe('Fast 480p')
|
||||||
expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe('Allgemein')
|
// Das alte „allgemeine Preset" ist seit 5.5.0 nicht mehr Teil der
|
||||||
|
// Pipeline-Einstellungen — es darf nichts mehr übersteuern.
|
||||||
|
expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe(DEFAULT_HB_PRESET)
|
||||||
expect(presetFuer('dvd', {})).toBe(DEFAULT_HB_PRESET)
|
expect(presetFuer('dvd', {})).toBe(DEFAULT_HB_PRESET)
|
||||||
expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE, transcodePreset: 'A' })).toBe('A')
|
expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE }, 'H.265 VCN 2160p 4K')).toBe('H.265 VCN 2160p 4K')
|
||||||
|
expect(presetFuer('uhd', {}, PRESET_KEINE)).toBe(DEFAULT_HB_PRESET)
|
||||||
})
|
})
|
||||||
it('sprachliste säubert, dedupliziert, erzwingt Kleinschreibung', () => {
|
it('sprachliste säubert, dedupliziert, erzwingt Kleinschreibung', () => {
|
||||||
expect(sprachliste('deu, eng , ,DEU')).toEqual(['deu', 'eng'])
|
expect(sprachliste('deu, eng , ,DEU')).toEqual(['deu', 'eng'])
|
||||||
@@ -220,3 +223,22 @@ describe('etaAusZeile — Restzeit aus der Encoding-Zeile (5.6.0, Format aus dem
|
|||||||
expect(a.verarbeite('Encoding: task 1 of 1, 11.00 %')).toEqual({ prozent: 11 })
|
expect(a.verarbeite('Encoding: task 1 of 1, 11.00 %')).toEqual({ prozent: 11 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F9): HandBrake schreibt auf einen Zwischennamen; eine
|
||||||
|
// halbe Datei trägt nie den echten Namen.
|
||||||
|
describe('Zwischenname (5.7.0)', () => {
|
||||||
|
it('zwischenname und endname gehören zusammen', async () => {
|
||||||
|
const { zwischenname, endname } = await import('../src/kern/komprimieren/handbrake')
|
||||||
|
expect(zwischenname('E:\\Filme\\Akira (1988)\\Akira (1988).mkv')).toBe('E:\\Filme\\Akira (1988)\\Akira (1988).teil.mkv')
|
||||||
|
expect(endname('E:\\Filme\\Akira (1988)\\Akira (1988).teil.mkv')).toBe('E:\\Filme\\Akira (1988)\\Akira (1988).mkv')
|
||||||
|
expect(endname('E:\\x\\title_t00.teil.mp4')).toBe('E:\\x\\title_t00.mp4')
|
||||||
|
expect(zwischenname('ohne-endung')).toBe('ohne-endung.teil')
|
||||||
|
})
|
||||||
|
it('zwischenReste räumt die Zwischen-Datei und ihre Datei daneben', async () => {
|
||||||
|
const { zwischenReste } = await import('../src/kern/komprimieren/handbrake')
|
||||||
|
const da = new Set(['E:\\x\\a.teil.mkv', 'E:\\x\\a.teil.mp4'])
|
||||||
|
const weg: string[] = []
|
||||||
|
expect(zwischenReste('E:\\x\\a.teil.mkv', (p) => weg.push(p), (p) => da.has(p))).toEqual(['E:\\x\\a.teil.mkv', 'E:\\x\\a.teil.mp4'])
|
||||||
|
expect(zwischenReste('E:\\x\\b.teil.mkv', (p) => weg.push(p), (p) => da.has(p))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function fakeApi(verhalten: FakeVerhalten): LaufwerkApi & { protokoll: string[]
|
|||||||
protokoll,
|
protokoll,
|
||||||
laufwerksbuchstaben: () => verhalten.buchstaben ?? ['C', 'G'],
|
laufwerksbuchstaben: () => verhalten.buchstaben ?? ['C', 'G'],
|
||||||
laufwerkstyp: (b) => verhalten.typen?.[b] ?? (b === 'G' ? DRIVE_CDROM : 3),
|
laufwerkstyp: (b) => verhalten.typen?.[b] ?? (b === 'G' ? DRIVE_CDROM : 3),
|
||||||
|
geraeteName: (b) => (b === 'G' ? 'GERAET-CDROM0' : ''),
|
||||||
oeffnen(pfad, zugriff) {
|
oeffnen(pfad, zugriff) {
|
||||||
protokoll.push(`oeffnen ${pfad} zugriff=${zugriff}`)
|
protokoll.push(`oeffnen ${pfad} zugriff=${zugriff}`)
|
||||||
if (verhalten.oeffnenFehler !== undefined) throw new Win32Fehler('CreateFileW', verhalten.oeffnenFehler)
|
if (verhalten.oeffnenFehler !== undefined) throw new Win32Fehler('CreateFileW', verhalten.oeffnenFehler)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// 5.7.1 — Commander-Fund 12.09.2026 spät: „Der Patriot" (DVD, IFO-Datei für
|
||||||
|
// VTS #1 beschädigt, MakeMKV scannt die VOB-Datei). Rippy 5.7.0 brach nach
|
||||||
|
// 15 Minuten ab, das MakeMKV-Programm las die Disc fertig. Jetzt gilt die
|
||||||
|
// Grenze für STILLE: Solange makemkvcon Fortschritt oder Meldungen schickt,
|
||||||
|
// wartet Rippy — und der Dialog zeigt, was MakeMKV gerade tut.
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { titelLaufZwischenstand } from '../src/kern/dienste/erkennung'
|
||||||
|
import { buildInfoArgs, titelInfoLesen, VOB_SCAN_HOECHSTENS_MS, type Lebenszeichen } from '../src/kern/rip/makemkv'
|
||||||
|
import { aufgabeAusPrg, istVobScanMeldung } from '../src/kern/rip/parser'
|
||||||
|
|
||||||
|
let wegwerf: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Fortschritts-Zeilen des Info-Laufs', () => {
|
||||||
|
it('der Info-Lauf verlangt --progress=-same', () => {
|
||||||
|
expect(buildInfoArgs('\\\\.\\G:')).toContain('--progress=-same')
|
||||||
|
})
|
||||||
|
it('PRGC ist der Schritt, PRGT die Aufgabe — mit Komma und Anführungszeichen im Namen', () => {
|
||||||
|
expect(aufgabeAusPrg('PRGC:5018,0,"Analysieren von seamless segments"')).toEqual({ art: 'schritt', name: 'Analysieren von seamless segments' })
|
||||||
|
expect(aufgabeAusPrg('PRGT:5017,0,"Öffnen der DVD-Disc"')).toEqual({ art: 'aufgabe', name: 'Öffnen der DVD-Disc' })
|
||||||
|
expect(aufgabeAusPrg('PRGC:1,2,"Lesen, Teil \\"A\\""')).toEqual({ art: 'schritt', name: 'Lesen, Teil "A"' })
|
||||||
|
expect(aufgabeAusPrg('PRGV:10,20,65536')).toBeNull()
|
||||||
|
expect(aufgabeAusPrg('MSG:1005,0,1,"x","x"')).toBeNull()
|
||||||
|
})
|
||||||
|
it('erkennt die VOB-Scan-Ankündigung auf Deutsch und Englisch', () => {
|
||||||
|
expect(istVobScanMeldung('IFO-Datei für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden. Das kann viel Zeit in Anspruch nehmen, bitte haben Sie Geduld.')).toBe(true)
|
||||||
|
expect(istVobScanMeldung('IFO file for VTS #1 is corrupt, VOB file needs to be scanned. This may take a long time, please be patient.')).toBe(true)
|
||||||
|
expect(istVobScanMeldung('Using LibreDrive mode (v02.1 id=F39BF9BC17BE)')).toBe(false)
|
||||||
|
expect(istVobScanMeldung('Titel #1 wurde hinzugefügt (31 cell(s), 2:47:40)')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Zwischenstand im Dialog', () => {
|
||||||
|
const still: Lebenszeichen = { prozent: -1, schritt: '', meldung: '', vobScan: false }
|
||||||
|
it('nennt Dauer, Schritt, Prozent, letzte Meldung und die Geduld', () => {
|
||||||
|
const text = titelLaufZwischenstand(17 * 60_000, 20_000, 15, { prozent: 43, schritt: 'Öffnen der DVD-Disc', meldung: 'Using LibreDrive mode', vobScan: false })
|
||||||
|
expect(text).toContain('MakeMKV liest seit 17 min')
|
||||||
|
expect(text).toContain('Öffnen der DVD-Disc 43 %')
|
||||||
|
expect(text).toContain('letzte Meldung: „Using LibreDrive mode"')
|
||||||
|
expect(text).toContain('ohne Lebenszeichen bricht es nach 15 min ab')
|
||||||
|
expect(text).toContain('„Abbrechen" beendet den Lauf')
|
||||||
|
expect(text).not.toContain('still seit')
|
||||||
|
})
|
||||||
|
it('sagt, wie lange MakeMKV schon still ist', () => {
|
||||||
|
expect(titelLaufZwischenstand(9 * 60_000, 3 * 60_000 + 5000, 15, still)).toContain('still seit 3 min')
|
||||||
|
})
|
||||||
|
it('beim VOB-Scan: die Erklärung statt der Stille-Grenze', () => {
|
||||||
|
const text = titelLaufZwischenstand(23 * 60_000, 0, 15, { prozent: 12, schritt: '', meldung: 'IFO-Datei für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden.', vobScan: true })
|
||||||
|
expect(text).toContain('Fortschritt 12 %')
|
||||||
|
expect(text).toContain('DVD-Struktur ist beschädigt')
|
||||||
|
expect(text).toContain('oft 20–60 min')
|
||||||
|
expect(text).not.toContain('bricht es nach')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ein nachgebautes makemkvcon (Node-Skript, aufruf.ts startet es über die
|
||||||
|
// eigene Laufzeit): meldet erst die VOB-Ankündigung, dann alle 300 ms
|
||||||
|
// Fortschritt — länger als die Stille-Grenze von 1 s — und liefert am Ende
|
||||||
|
// einen Titel. Ein zweites schweigt nach der ersten Zeile.
|
||||||
|
function fakeMakemkv(inhalt: string): string {
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), 'rippy-lebens-'))
|
||||||
|
wegwerf.push(ordner)
|
||||||
|
const pfad = join(ordner, 'fake-makemkvcon.cjs')
|
||||||
|
writeFileSync(pfad, inhalt)
|
||||||
|
return pfad
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDSELIG = `
|
||||||
|
const out = (z) => process.stdout.write(z + '\\n')
|
||||||
|
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"')
|
||||||
|
out('PRGT:5017,0,"Öffnen der DVD-Disc"')
|
||||||
|
out('MSG:3300,0,1,"IFO-Datei für VTS #1 ist beschädigt, die VOB-Datei muss gescannt werden. Das kann viel Zeit in Anspruch nehmen, bitte haben Sie Geduld.","x"')
|
||||||
|
let i = 0
|
||||||
|
const tick = () => {
|
||||||
|
i += 1
|
||||||
|
out('PRGV:' + i * 100 + ',' + i * 100 + ',1000')
|
||||||
|
if (i < 8) return setTimeout(tick, 300)
|
||||||
|
out('TCOUNT:1')
|
||||||
|
out('TINFO:0,8,0,"28"')
|
||||||
|
out('TINFO:0,9,0,"2:47:40"')
|
||||||
|
out('TINFO:0,11,0,"5800000000"')
|
||||||
|
out('SINFO:0,0,1,6201,"Video"')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
setTimeout(tick, 300)
|
||||||
|
`
|
||||||
|
|
||||||
|
const STUMM = `
|
||||||
|
process.stdout.write('MSG:1005,0,1,"MakeMKV gestartet","x"\\n')
|
||||||
|
setTimeout(() => process.exit(0), 60_000)
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('titelInfoLesen — Geduld nach Lebenszeichen', () => {
|
||||||
|
it('wartet über die Stille-Grenze hinaus, solange Fortschritt kommt; meldet Lebenszeichen', { timeout: 20_000 }, async () => {
|
||||||
|
const zeichen: Lebenszeichen[] = []
|
||||||
|
const start = Date.now()
|
||||||
|
const info = await titelInfoLesen(fakeMakemkv(REDSELIG), '\\\\.\\G:', { stillMs: 1000, aufLebenszeichen: (s) => zeichen.push(s) })
|
||||||
|
expect(Date.now() - start).toBeGreaterThan(2000)
|
||||||
|
expect(info.titel).toHaveLength(1)
|
||||||
|
expect(info.titel[0].dauerS).toBe(2 * 3600 + 47 * 60 + 40)
|
||||||
|
const letztes = zeichen.at(-1)!
|
||||||
|
expect(letztes.vobScan).toBe(true)
|
||||||
|
expect(letztes.meldung).toContain('VOB-Datei muss gescannt werden')
|
||||||
|
expect(zeichen.some((z) => z.prozent === 50)).toBe(true)
|
||||||
|
expect(zeichen.some((z) => z.schritt === '' && z.prozent === -1 && z.meldung.includes('gestartet'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beendet einen stummen makemkvcon nach der Stille-Grenze — mit klarem Grund', { timeout: 20_000 }, async () => {
|
||||||
|
const start = Date.now()
|
||||||
|
await expect(titelInfoLesen(fakeMakemkv(STUMM), '\\\\.\\G:', { stillMs: 1000 })).rejects.toThrow('kein Lebenszeichen seit 1 s')
|
||||||
|
expect(Date.now() - start).toBeLessThan(10_000)
|
||||||
|
// Die letzten Zeilen reisen im Fehler mit — fürs Protokoll.
|
||||||
|
await expect(titelInfoLesen(fakeMakemkv(STUMM), '\\\\.\\G:', { stillMs: 1000 })).rejects.toThrow('letzte Zeilen: MSG:1005')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beim VOB-Scan gilt nur die Obergrenze (4 h Vorgabe)', { timeout: 20_000 }, async () => {
|
||||||
|
expect(VOB_SCAN_HOECHSTENS_MS).toBe(4 * 60 * 60_000)
|
||||||
|
// Stille-Grenze 200 ms, aber nach der VOB-Ankündigung kommt 300 ms lang
|
||||||
|
// nichts — der Lauf darf trotzdem weiterleben und liefert den Titel.
|
||||||
|
const info = await titelInfoLesen(fakeMakemkv(REDSELIG), '\\\\.\\G:', { stillMs: 200 })
|
||||||
|
expect(info.titel).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beim VOB-Scan beendet die Obergrenze den Lauf', { timeout: 20_000 }, async () => {
|
||||||
|
await expect(titelInfoLesen(fakeMakemkv(REDSELIG), '\\\\.\\G:', { stillMs: 60_000, vobScanHoechstensMs: 1200 })).rejects.toThrow('VOB-Scan nach 0 min nicht fertig')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,3 +29,72 @@ describe.skipIf(!messen)('Spuren einer echten Roh-Datei', () => {
|
|||||||
expect(spuren!.audio.length).toBeGreaterThan(0)
|
expect(spuren!.audio.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Spur-Schalter am mitgelieferten HandBrakeCLI ────────────────────
|
||||||
|
// Baut sich die Quelle selbst (ffmpeg muss im PATH sein): jpn 5.1 / deu 2.0 /
|
||||||
|
// deu 2.0 „Kommentar" als AC3, deu/eng SRT — und prüft die Ausgabe wieder
|
||||||
|
// über HandBrakes eigenen Scan. Anlass: `--audio 1,3`, `--subtitle 2,1`,
|
||||||
|
// `--subtitle-default=1` und die Kopiermaske (F31: ohne `--audio-copy-mask`
|
||||||
|
// wurde AC3 zu AAC). Läuft nur mit RIPPY_MESSUNG=1 unter Windows.
|
||||||
|
//
|
||||||
|
// RIPPY_MESSUNG=1 npx vitest run test/messung.spuren.test.ts --reporter=verbose
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const spurMessung = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
|
||||||
|
|
||||||
|
describe.skipIf(!spurMessung)('Spur-Schalter am echten HandBrakeCLI (5.7.0)', () => {
|
||||||
|
it('--audio 1,3 --subtitle 2,1 --subtitle-default=1 mit Kopiermaske: AC3 bleibt AC3, eng vor deu', { timeout: 10 * 60_000 }, async () => {
|
||||||
|
const ffmpeg = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8', windowsHide: true })
|
||||||
|
if (ffmpeg.status !== 0) {
|
||||||
|
console.log('MESSUNG übersprungen: kein ffmpeg im PATH')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||||
|
const { buildHandbrakeArgs } = await import('../src/kern/komprimieren/handbrake')
|
||||||
|
const { spurenScannen } = await import('../src/kern/komprimieren/scan')
|
||||||
|
const handbrake = finden('handbrake', '')
|
||||||
|
expect(handbrake.length, 'HandBrakeCLI nicht gefunden').toBeGreaterThan(0)
|
||||||
|
const ordner = mkdtempSync(join(tmpdir(), 'rippy-spuren-'))
|
||||||
|
const deu = join(ordner, 'deu.srt')
|
||||||
|
const eng = join(ordner, 'eng.srt')
|
||||||
|
writeFileSync(deu, '1\n00:00:00,500 --> 00:00:02,000\nHallo\n\n')
|
||||||
|
writeFileSync(eng, '1\n00:00:00,500 --> 00:00:02,000\nHello\n\n')
|
||||||
|
const quelle = join(ordner, 'quelle.mkv')
|
||||||
|
const bau = spawnSync(
|
||||||
|
'ffmpeg',
|
||||||
|
[
|
||||||
|
'-y', '-loglevel', 'error',
|
||||||
|
'-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=25',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=48000',
|
||||||
|
'-i', deu, '-i', eng, '-t', '3',
|
||||||
|
'-map', '0:v', '-map', '1:a', '-map', '2:a', '-map', '3:a', '-map', '4:s', '-map', '5:s',
|
||||||
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'ac3', '-ac:a:0', '6', '-ac:a:1', '2', '-ac:a:2', '2', '-c:s', 'srt',
|
||||||
|
'-metadata:s:a:0', 'language=jpn', '-metadata:s:a:1', 'language=deu', '-metadata:s:a:2', 'language=deu', '-metadata:s:a:2', 'title=Kommentar',
|
||||||
|
'-metadata:s:s:0', 'language=deu', '-metadata:s:s:1', 'language=eng',
|
||||||
|
quelle,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', windowsHide: true, timeout: 120_000 },
|
||||||
|
)
|
||||||
|
expect(bau.status, `ffmpeg: ${bau.stderr}`).toBe(0)
|
||||||
|
const ausgabe = join(ordner, 'ausgabe.mkv')
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: quelle, ausgabe, preset: 'H.265 MKV 1080p30', audioNr: [1, 3], untertitelNr: [2, 1], untertitelStandardNr: 1 })
|
||||||
|
const lauf = spawnSync(handbrake, args, { encoding: 'utf8', windowsHide: true, timeout: 8 * 60_000 })
|
||||||
|
expect(lauf.status, `HandBrake: ${(lauf.stderr ?? '').slice(-600)}`).toBe(0)
|
||||||
|
const passthru = /Auto Passthru: allowed codecs are ([^\n]*)/.exec(lauf.stderr ?? '')
|
||||||
|
console.log(`MESSUNG Passthru: ${passthru?.[1] ?? '(Zeile fehlt)'}`)
|
||||||
|
const spuren = await spurenScannen(handbrake, ausgabe)
|
||||||
|
expect(spuren).not.toBeNull()
|
||||||
|
for (const a of spuren!.audio) console.log(`MESSUNG Ton ${a.nr}: ${a.lang} ${a.codec} ${a.kanaele}ch — ${a.beschreibung}`)
|
||||||
|
for (const u of spuren!.untertitel) console.log(`MESSUNG Untertitel ${u.nr}: ${u.lang} ${u.codec} — ${u.beschreibung}`)
|
||||||
|
expect(spuren!.audio.map((a) => [a.lang, a.codec, a.kanaele])).toEqual([
|
||||||
|
['jpn', 'ac3', 6],
|
||||||
|
['deu', 'ac3', 2],
|
||||||
|
])
|
||||||
|
expect(spuren!.untertitel.map((u) => u.lang)).toEqual(['eng', 'deu'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 5.7.0 (Durchsicht 12.09.2026, F14): Der wörtliche Vergleich kennt jetzt
|
||||||
|
// Akzente und den Originaltitel. Die drei Filme des Commanders (Der
|
||||||
|
// Patriot, Déjà Vu, Training Day) tragen englische Labels, TMDb antwortet
|
||||||
|
// auf Deutsch — vorher ein Vorschlag mit 60 %, die Automatik wartete.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { gleichBedeutend, ohneAkzente, wortUeberlappung, zuordnen } from '../src/kern/metadaten/zuordnung'
|
||||||
|
|
||||||
|
const keinOmdb = { verfuegbar: false, lookup: async () => null }
|
||||||
|
|
||||||
|
function tmdb(filme: Array<{ id: number; title: string; original_title?: string }>) {
|
||||||
|
return {
|
||||||
|
verfuegbar: true,
|
||||||
|
searchMovie: async () => filme,
|
||||||
|
searchTv: async () => [],
|
||||||
|
movieDetails: async (id: number) => {
|
||||||
|
const f = filme.find((x) => x.id === id)
|
||||||
|
return f === undefined ? null : { id, title: f.title, release_date: '2000-06-28', overview: 'Ü', vote_count: 100 }
|
||||||
|
},
|
||||||
|
tvDetails: async () => null,
|
||||||
|
findByImdb: async () => null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ohneAkzente und gleichBedeutend', () => {
|
||||||
|
it('Déjà Vu ist Deja Vu', () => {
|
||||||
|
expect(ohneAkzente('Déjà Vu')).toBe('Deja Vu')
|
||||||
|
expect(gleichBedeutend('Déjà Vu', 'Deja Vu')).toBe(true)
|
||||||
|
expect(gleichBedeutend('Amélie', 'AMELIE')).toBe(true)
|
||||||
|
expect(wortUeberlappung('Déjà Vu', 'Deja Vu')).toBe(1)
|
||||||
|
})
|
||||||
|
it('Umlaute bleiben unterscheidbar genug (ä → a, ß → ss)', () => {
|
||||||
|
expect(gleichBedeutend('Der Baader Meinhof Komplex', 'Der Baader Meinhof Komplex')).toBe(true)
|
||||||
|
expect(gleichBedeutend('Das Boot', 'Das Bot')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zuordnen mit Originaltitel', () => {
|
||||||
|
it('Label „The Patriot" trifft „Der Patriot" (original_title) wörtlich — 0,95', async () => {
|
||||||
|
const z = await zuordnen('The Patriot', tmdb([{ id: 8489, title: 'Der Patriot', original_title: 'The Patriot' }]), keinOmdb)
|
||||||
|
expect(z.confidence).toBe(0.95)
|
||||||
|
expect(z.titel).toBe('Der Patriot')
|
||||||
|
})
|
||||||
|
it('Label „Deja Vu" trifft „Déjà Vu – Wettlauf gegen die Zeit" über den Originaltitel', async () => {
|
||||||
|
const z = await zuordnen(
|
||||||
|
'Deja Vu',
|
||||||
|
tmdb([
|
||||||
|
{ id: 7551, title: 'Déjà Vu – Wettlauf gegen die Zeit', original_title: 'Deja Vu' },
|
||||||
|
{ id: 1, title: 'Déjà Vu', original_title: 'Déjà Vu' },
|
||||||
|
]),
|
||||||
|
keinOmdb,
|
||||||
|
)
|
||||||
|
expect(z.confidence).toBe(0.95)
|
||||||
|
expect(z.titel).toBe('Déjà Vu – Wettlauf gegen die Zeit')
|
||||||
|
})
|
||||||
|
it('ohne Originaltitel und ohne wörtlichen Treffer bleibt es ein Vorschlag', async () => {
|
||||||
|
const z = await zuordnen('The Patriot', tmdb([{ id: 1, title: 'Der Patriot' }, { id: 2, title: 'Patriot Games' }, { id: 3, title: 'Der Patriot 2' }, { id: 4, title: 'Patrioten' }]), keinOmdb)
|
||||||
|
expect(z.confidence).toBe(0.6)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -259,3 +259,79 @@ describe('Pipeline — die Rip-Phase (5.4.0)', () => {
|
|||||||
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
|
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ─────────────────────────
|
||||||
|
describe('Pipeline — Durchsicht 5.7.0', () => {
|
||||||
|
// F2: Vorher warf mkdirSync aus starten() heraus, der Aufrufer schluckte
|
||||||
|
// die Ablehnung — kein Fehler im Fenster, keine Zeile im Protokoll
|
||||||
|
// (Sonde 10). Jetzt: eine Fehler-Meldung, keine Ausnahme.
|
||||||
|
it('F2: Ablage nicht erreichbar → Fehler-Meldung statt stiller Ausnahme', async () => {
|
||||||
|
// Ein Ordner UNTER einer Datei — anlegen scheitert sofort, auf jeder
|
||||||
|
// Plattform (ENOTDIR bzw. EEXIST). Nicht /proc: dort antwortet mkdir mit
|
||||||
|
// ENOENT, obwohl der Elternordner da ist, und Nodes rekursives mkdirSync
|
||||||
|
// pendelt dann endlos — die Ampel hing daran (Lauf 279, 12.09.2026).
|
||||||
|
const sperre = mkdtempSync(join(tmpdir(), 'rippy-sperre-'))
|
||||||
|
wegwerf.push(sperre)
|
||||||
|
const datei = join(sperre, 'keine-ablage.txt')
|
||||||
|
writeFileSync(datei, 'x')
|
||||||
|
const weg = join(datei, 'rippy-nicht-da')
|
||||||
|
const { pipeline, meldungen, endet } = umgebung({ ablage: () => weg })
|
||||||
|
await pipeline.starten('G', AUFTRAG)
|
||||||
|
expect(meldungen).toHaveLength(1)
|
||||||
|
expect(meldungen[0].phase).toBe('fehler')
|
||||||
|
expect(meldungen[0].text).toContain('nicht erreichbar')
|
||||||
|
expect(meldungen[0].text).toContain(weg)
|
||||||
|
expect(endet).toHaveLength(0) // kein Vorgang angelegt, also keiner beendet
|
||||||
|
expect(pipeline.laeuft('G')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2: eine Ausnahme mitten im Lauf wird zur Fehler-Meldung des Laufwerks', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
|
||||||
|
const { pipeline, meldungen } = umgebung({
|
||||||
|
vorgangBeginnt: () => {
|
||||||
|
throw new Error('Datenbank gesperrt')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(pipeline.starten('G', AUFTRAG)).resolves.toBeUndefined()
|
||||||
|
expect(meldungen.at(-1)!.phase).toBe('fehler')
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('Datenbank gesperrt')
|
||||||
|
expect(pipeline.laeuft('G')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// F10: Ein Titel-Auftrag auf einer unbekannt gemeldeten Disc ist ein
|
||||||
|
// Video-Rip, kein ISO — makemkvcon hat die Titel ja gerade geliefert.
|
||||||
|
it('F10: Typ unbekannt MIT Titel-Auftrag rippt mit MakeMKV (Typ nach Größe)', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
|
||||||
|
const { pipeline, meldungen, eingereiht } = umgebung({ geraet: () => ({ ...GERAET, typ: 'unknown' }) })
|
||||||
|
await pipeline.starten('G', [AUFTRAG[0]])
|
||||||
|
expect(meldungen.some((m) => m.text.includes('1:1-Abbild'))).toBe(false)
|
||||||
|
expect(eingereiht).toHaveLength(1)
|
||||||
|
expect(eingereiht[0].discTyp).toBe('bluray')
|
||||||
|
})
|
||||||
|
|
||||||
|
// F8: Der Roh-Ordner ohne brauchbare Datei wird geräumt — das Rettungs-
|
||||||
|
// Abbild aber nie: dafür wurde die Disc stundenlang gelesen.
|
||||||
|
it('F8: rettung.iso überlebt einen leer ausgegangenen Rip aus dem Abbild', async () => {
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'MakeMKV endete mit Code 1' }, { status: 'error', fehler: 'MakeMKV endete mit Code 1' }]
|
||||||
|
const { pipeline, ablage, meldungen } = umgebung({
|
||||||
|
rettungsAbbild: async (_geraet, ziel) => {
|
||||||
|
writeFileSync(ziel, 'ABBILD')
|
||||||
|
writeFileSync(`${ziel}.rettung.txt`, 'Bericht')
|
||||||
|
return { status: 'success', bytes: 6, unlesbareSektoren: 1, stellen: [{ offset: 0, bytes: 2048 }] }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Erst ein normaler Rip, der Lesefehler sieht — nur dann gibt es die Rettung.
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'Lesefehler', lesefehler: true }]
|
||||||
|
await pipeline.starten('G', [AUFTRAG[0]])
|
||||||
|
expect(pipeline.rettungGrund('G')).not.toBe('')
|
||||||
|
plan.schritte = [{ status: 'error', fehler: 'MakeMKV endete mit Code 1' }]
|
||||||
|
await pipeline.rettungStarten('G')
|
||||||
|
const roh = join(ablage, 'roh')
|
||||||
|
const ordner = readdirSync(roh)
|
||||||
|
expect(ordner.length).toBeGreaterThan(0)
|
||||||
|
const inhalt = ordner.flatMap((o) => readdirSync(join(roh, o)))
|
||||||
|
expect(inhalt).toContain('rettung.iso')
|
||||||
|
expect(inhalt).toContain('rettung.iso.rettung.txt')
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('Rettungs-Abbild bleibt')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { buildInfoArgs, buildRipArgs, RipAuswertung, ZeilenLeser } from '../src/
|
|||||||
|
|
||||||
describe('Kommandobau', () => {
|
describe('Kommandobau', () => {
|
||||||
it('info: Robot-Mode ohne Geräte-Scan', () => {
|
it('info: Robot-Mode ohne Geräte-Scan', () => {
|
||||||
expect(buildInfoArgs('\\\\.\\G:')).toEqual(['-r', '--noscan', 'info', 'dev:G:'])
|
expect(buildInfoArgs('\\\\.\\G:')).toEqual(['-r', '--noscan', '--progress=-same', 'info', 'dev:G:'])
|
||||||
})
|
})
|
||||||
it('mkv: Fortschritt in denselben Strom', () => {
|
it('mkv: Fortschritt in denselben Strom', () => {
|
||||||
expect(buildRipArgs('\\\\.\\G:', 'C:\\ziel', 'all')).toEqual([
|
expect(buildRipArgs('\\\\.\\G:', 'C:\\ziel', 'all')).toEqual([
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 5.6.1 — Commander-Fund vom 03.09.2026: Bei der Blu-ray „Digimon Adventure:
|
||||||
|
// Last Evolution Kizuna" fehlten im Auswahl-Dialog die Untertitel-Chips,
|
||||||
|
// obwohl die Disc drei deutsche PGS-Spuren hat. Gemessen mit
|
||||||
|
// makemkvcon64 -r info auf diesem PC: MakeMKV (deutsch eingestellt) schreibt
|
||||||
|
// den Stream-Typ als „Untertitel", der Parser prüfte auf „Subtitles". Der
|
||||||
|
// Typ-Code im vierten Feld (6203) ist sprachunabhängig.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { STREAM_CODE_AUDIO, STREAM_CODE_UNTERTITEL, parseStreamInfo, sprachenFuerTitel } from '../src/kern/rip/parser'
|
||||||
|
|
||||||
|
// Wörtlich aus der Messung (gekürzt auf Typ, Sprache, Name, Beschreibung).
|
||||||
|
const KIZUNA = [
|
||||||
|
'SINFO:1,0,1,6201,"Video"',
|
||||||
|
'SINFO:1,0,6,0,"Mpeg4"',
|
||||||
|
'SINFO:1,1,1,6202,"Audio"',
|
||||||
|
'SINFO:1,1,3,0,"deu"',
|
||||||
|
'SINFO:1,1,4,0,"German"',
|
||||||
|
'SINFO:1,1,30,0,"DTS-HD MA Surround 5.1 German"',
|
||||||
|
'SINFO:1,2,1,6202,"Audio"',
|
||||||
|
'SINFO:1,2,3,0,"deu"',
|
||||||
|
'SINFO:1,2,4,0,"German"',
|
||||||
|
'SINFO:1,3,1,6202,"Audio"',
|
||||||
|
'SINFO:1,3,3,0,"jpn"',
|
||||||
|
'SINFO:1,3,4,0,"Japanese"',
|
||||||
|
'SINFO:1,4,1,6202,"Audio"',
|
||||||
|
'SINFO:1,4,3,0,"jpn"',
|
||||||
|
'SINFO:1,4,4,0,"Japanese"',
|
||||||
|
'SINFO:1,5,1,6203,"Untertitel"',
|
||||||
|
'SINFO:1,5,3,0,"deu"',
|
||||||
|
'SINFO:1,5,4,0,"German"',
|
||||||
|
'SINFO:1,5,30,0,"PGS German"',
|
||||||
|
'SINFO:1,6,1,6203,"Untertitel"',
|
||||||
|
'SINFO:1,6,3,0,"deu"',
|
||||||
|
'SINFO:1,6,4,0,"German"',
|
||||||
|
'SINFO:1,6,22,0,"6144"',
|
||||||
|
'SINFO:1,6,30,0,"PGS German (nur erzwungene)"',
|
||||||
|
'SINFO:1,7,1,6203,"Untertitel"',
|
||||||
|
'SINFO:1,7,3,0,"deu"',
|
||||||
|
'SINFO:1,7,4,0,"German"',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
describe('parseStreamInfo — Stream-Typ per Code, nicht per Wort (5.6.1)', () => {
|
||||||
|
it('deutsches MakeMKV schreibt „Untertitel": die drei PGS-Spuren werden erkannt', () => {
|
||||||
|
const streams = parseStreamInfo(KIZUNA).get(1)!
|
||||||
|
expect(streams.audio.map((s) => s.lang)).toEqual(['deu', 'deu', 'jpn', 'jpn'])
|
||||||
|
expect(streams.untertitel.map((s) => s.lang)).toEqual(['deu', 'deu', 'deu'])
|
||||||
|
expect(streams.untertitel[0].typCode).toBe(STREAM_CODE_UNTERTITEL)
|
||||||
|
expect(streams.audio[0].typCode).toBe(STREAM_CODE_AUDIO)
|
||||||
|
expect(streams.untertitel[1].beschreibung).toBe('PGS German (nur erzwungene)')
|
||||||
|
})
|
||||||
|
it('für den Dialog: deu×2 Ton, jpn×2 Ton, deu×3 Untertitel', () => {
|
||||||
|
const sprachen = sprachenFuerTitel(parseStreamInfo(KIZUNA).get(1))
|
||||||
|
expect(sprachen.audio).toEqual([
|
||||||
|
{ lang: 'deu', sprache: 'German', spuren: 2 },
|
||||||
|
{ lang: 'jpn', sprache: 'Japanese', spuren: 2 },
|
||||||
|
])
|
||||||
|
expect(sprachen.untertitel).toEqual([{ lang: 'deu', sprache: 'German', spuren: 3 }])
|
||||||
|
})
|
||||||
|
it('jede Anzeigesprache: der Code zählt — französisch, englisch, und ohne Code das Wort als Notnagel', () => {
|
||||||
|
expect(parseStreamInfo('SINFO:0,1,1,6203,"Sous-titres"\nSINFO:0,1,3,0,"fra"').get(0)!.untertitel).toHaveLength(1)
|
||||||
|
expect(parseStreamInfo('SINFO:0,1,1,6203,"Subtitles"\nSINFO:0,1,3,0,"eng"').get(0)!.untertitel).toHaveLength(1)
|
||||||
|
expect(parseStreamInfo('SINFO:0,1,1,0,"Subtitles"\nSINFO:0,1,3,0,"eng"').get(0)!.untertitel).toHaveLength(1)
|
||||||
|
expect(parseStreamInfo('SINFO:0,1,1,0,"Untertitel"\nSINFO:0,1,3,0,"deu"').get(0)!.untertitel).toHaveLength(1)
|
||||||
|
expect(parseStreamInfo('SINFO:0,1,1,0,"Audio"\nSINFO:0,1,3,0,"deu"').get(0)!.audio).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('Video bleibt draußen', () => {
|
||||||
|
const streams = parseStreamInfo('SINFO:0,0,1,6201,"Video"\nSINFO:0,0,3,0,"und"').get(0)!
|
||||||
|
expect(streams.audio).toHaveLength(0)
|
||||||
|
expect(streams.untertitel).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// 5.7.0 — Spuren einzeln (Commander-Entscheid 12.09.2026: „Jede Spur einzeln
|
||||||
|
// anhaken"). Pur und ohne Laufwerk: der Parser liefert je Titel jede Spur mit
|
||||||
|
// Codec, Kanälen und dem Abgeleitet-Flag; der Dialog belegt vor und
|
||||||
|
// beschriftet; die Ablage findet die gewählten MakeMKV-Spuren in HandBrakes
|
||||||
|
// Scan der Roh-Datei wieder und baut daraus `--audio 1,3 --subtitle 2`.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { istRipAuftragTitel, istSpurAngabe, type SpurAngabe } from '../src/gemeinsam/nachrichten'
|
||||||
|
import { angeboten, codecFamilie, spurLabel, spurenVorbelegt, spurenZuordnen, sprachenDerSpuren } from '../src/gemeinsam/spuren'
|
||||||
|
import { auftragAusJson, spurwahl, sprachlistenAusSpuren } from '../src/kern/ablauf/auftrag'
|
||||||
|
import { buildHandbrakeArgs, KOPIERMASKE } from '../src/kern/komprimieren/handbrake'
|
||||||
|
import { spurenAusScan } from '../src/kern/komprimieren/scan'
|
||||||
|
import { parseStreamInfo, spurenFuerTitel, STREAM_FLAG_ABGELEITET } from '../src/kern/rip/parser'
|
||||||
|
import { regelUrteil, RIPPY_REGEL, RIPPY_REGELN_ALT } from '../src/kern/werkzeuge/spurauswahl'
|
||||||
|
|
||||||
|
// Wie fake-makemkvcon (bau/klick) und die Kizuna-Messung: DTS-HD MA mit
|
||||||
|
// abgeleitetem Kern (Flag 2304 = 0x900), DD 5.1, DD Stereo Kommentar,
|
||||||
|
// PGS deu, PGS deu „nur erzwungene" (6144 = 0x1800), PGS eng.
|
||||||
|
const SINFO = [
|
||||||
|
'SINFO:0,0,1,6201,"Video"',
|
||||||
|
'SINFO:0,1,1,6202,"Audio"',
|
||||||
|
'SINFO:0,1,2,0,"Surround 5.1"',
|
||||||
|
'SINFO:0,1,3,0,"jpn"',
|
||||||
|
'SINFO:0,1,4,0,"Japanese"',
|
||||||
|
'SINFO:0,1,6,0,"DTS-HD MA"',
|
||||||
|
'SINFO:0,1,14,0,"6"',
|
||||||
|
'SINFO:0,1,22,0,"1024"',
|
||||||
|
'SINFO:0,1,30,0,"DTS-HD MA Surround 5.1 Japanese"',
|
||||||
|
'SINFO:0,2,1,6202,"Audio"',
|
||||||
|
'SINFO:0,2,2,0,"Surround 5.1"',
|
||||||
|
'SINFO:0,2,3,0,"jpn"',
|
||||||
|
'SINFO:0,2,4,0,"Japanese"',
|
||||||
|
'SINFO:0,2,6,0,"DTS"',
|
||||||
|
'SINFO:0,2,14,0,"6"',
|
||||||
|
'SINFO:0,2,22,0,"2304"',
|
||||||
|
'SINFO:0,3,1,6202,"Audio"',
|
||||||
|
'SINFO:0,3,2,0,"Surround 5.1"',
|
||||||
|
'SINFO:0,3,3,0,"deu"',
|
||||||
|
'SINFO:0,3,4,0,"German"',
|
||||||
|
'SINFO:0,3,6,0,"DD"',
|
||||||
|
'SINFO:0,3,14,0,"6"',
|
||||||
|
'SINFO:0,3,22,0,"0"',
|
||||||
|
'SINFO:0,4,1,6202,"Audio"',
|
||||||
|
'SINFO:0,4,2,0,"Stereo"',
|
||||||
|
'SINFO:0,4,3,0,"deu"',
|
||||||
|
'SINFO:0,4,4,0,"German"',
|
||||||
|
'SINFO:0,4,6,0,"DD"',
|
||||||
|
'SINFO:0,4,14,0,"2"',
|
||||||
|
'SINFO:0,4,30,0,"DD Stereo German (Kommentar)"',
|
||||||
|
'SINFO:0,5,1,6203,"Untertitel"',
|
||||||
|
'SINFO:0,5,3,0,"deu"',
|
||||||
|
'SINFO:0,5,4,0,"German"',
|
||||||
|
'SINFO:0,5,6,0,"PGS"',
|
||||||
|
'SINFO:0,6,1,6203,"Untertitel"',
|
||||||
|
'SINFO:0,6,3,0,"deu"',
|
||||||
|
'SINFO:0,6,4,0,"German"',
|
||||||
|
'SINFO:0,6,6,0,"PGS"',
|
||||||
|
'SINFO:0,6,22,0,"6144"',
|
||||||
|
'SINFO:0,6,30,0,"PGS German (nur erzwungene)"',
|
||||||
|
'SINFO:0,7,1,6203,"Untertitel"',
|
||||||
|
'SINFO:0,7,3,0,"eng"',
|
||||||
|
'SINFO:0,7,4,0,"English"',
|
||||||
|
'SINFO:0,7,6,0,"PGS"',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const spuren = spurenFuerTitel(parseStreamInfo(SINFO).get(0))
|
||||||
|
|
||||||
|
describe('Parser: jede Spur einzeln, abgeleitete markiert', () => {
|
||||||
|
it('liest Anordnung (2), Kanäle (14), Flags (22) und erkennt den DTS-Kern am Bit 0x800', () => {
|
||||||
|
expect(STREAM_FLAG_ABGELEITET).toBe(0x800)
|
||||||
|
expect(spuren.audio.map((s) => [s.nr, s.lang, s.codec, s.kanaele, s.layout, s.abgeleitet])).toEqual([
|
||||||
|
[1, 'jpn', 'DTS-HD MA', '6', 'Surround 5.1', false],
|
||||||
|
[2, 'jpn', 'DTS', '6', 'Surround 5.1', true],
|
||||||
|
[3, 'deu', 'DD', '6', 'Surround 5.1', false],
|
||||||
|
[4, 'deu', 'DD', '2', 'Stereo', false],
|
||||||
|
])
|
||||||
|
expect(spuren.untertitel.map((s) => [s.nr, s.lang, s.abgeleitet])).toEqual([
|
||||||
|
[5, 'deu', false],
|
||||||
|
[6, 'deu', true],
|
||||||
|
[7, 'eng', false],
|
||||||
|
])
|
||||||
|
expect(spuren.audio.every(istSpurAngabe)).toBe(true)
|
||||||
|
expect(spurenFuerTitel(undefined)).toEqual({ audio: [], untertitel: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dialog: Beschriftung, Angebot, Vorbelegung', () => {
|
||||||
|
it('beschriftet mit Sprache, Codec, Anordnung und dem Klammer-Zusatz der Beschreibung', () => {
|
||||||
|
expect(spurLabel(spuren.audio[0])).toBe('Japanese · DTS-HD MA · Surround 5.1')
|
||||||
|
expect(spurLabel(spuren.audio[3])).toBe('German · DD · Stereo (Kommentar)')
|
||||||
|
expect(spurLabel(spuren.untertitel[0])).toBe('German · PGS')
|
||||||
|
expect(spurLabel(spuren.untertitel[1])).toBe('German · PGS (nur erzwungene)')
|
||||||
|
expect(spurLabel({ ...spuren.audio[0], layout: '', sprache: '' })).toBe('jpn · DTS-HD MA · 5.1')
|
||||||
|
expect(spurLabel({ ...spuren.audio[0], lang: '', sprache: '', codec: '', kanaele: '', layout: '', beschreibung: '' })).toBe('ohne Sprache')
|
||||||
|
})
|
||||||
|
it('bietet abgeleitete TONspuren nicht an, abgeleitete Untertitel schon', () => {
|
||||||
|
expect(spuren.audio.filter((s) => angeboten(s, 'audio')).map((s) => s.nr)).toEqual([1, 3, 4])
|
||||||
|
expect(spuren.untertitel.filter((s) => angeboten(s, 'untertitel')).map((s) => s.nr)).toEqual([5, 6, 7])
|
||||||
|
})
|
||||||
|
it('belegt vor: je Wunschsprache die erste Tonspur, alle Untertitel der Wunschsprachen', () => {
|
||||||
|
expect(spurenVorbelegt(spuren.audio, ['deu', 'eng'], 'audio')).toEqual([3])
|
||||||
|
expect(spurenVorbelegt(spuren.audio, ['jpn', 'deu'], 'audio')).toEqual([1, 3])
|
||||||
|
// Keine Wunschsprache da → die erste angebotene Spur (nie der Kern).
|
||||||
|
expect(spurenVorbelegt(spuren.audio, ['fra'], 'audio')).toEqual([1])
|
||||||
|
expect(spurenVorbelegt(spuren.audio, [], 'audio')).toEqual([1])
|
||||||
|
expect(spurenVorbelegt(spuren.untertitel, ['deu'], 'untertitel')).toEqual([5, 6])
|
||||||
|
expect(spurenVorbelegt(spuren.untertitel, ['fra'], 'untertitel')).toEqual([])
|
||||||
|
})
|
||||||
|
it('Sprachen der gewählten Spuren ohne Doppel', () => {
|
||||||
|
expect(sprachenDerSpuren([spuren.audio[2], spuren.audio[3], spuren.audio[0]])).toEqual(['deu', 'jpn'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Codec-Familien: MakeMKV-Wort und HandBrake-CodecName auf einen Schlüssel', () => {
|
||||||
|
it('DTS-HD MA ↔ dtshd, DTS ↔ dca, DD ↔ ac3, TrueHD, DVD-Untertitel ↔ VOBSUB', () => {
|
||||||
|
expect(codecFamilie('DTS-HD MA')).toBe('dtshd')
|
||||||
|
expect(codecFamilie('dtshd')).toBe('dtshd')
|
||||||
|
expect(codecFamilie('DTS')).toBe('dts')
|
||||||
|
expect(codecFamilie('dca')).toBe('dts')
|
||||||
|
expect(codecFamilie('DD')).toBe('ac3')
|
||||||
|
expect(codecFamilie('ac3')).toBe('ac3')
|
||||||
|
expect(codecFamilie('DD+')).toBe('eac3')
|
||||||
|
expect(codecFamilie('eac3')).toBe('eac3')
|
||||||
|
expect(codecFamilie('TrueHD Atmos')).toBe('truehd')
|
||||||
|
expect(codecFamilie('PGS')).toBe('pgs')
|
||||||
|
expect(codecFamilie('DVD Subtitles')).toBe('vobsub')
|
||||||
|
expect(codecFamilie('VOBSUB')).toBe('vobsub')
|
||||||
|
expect(codecFamilie('UTF-8')).toBe('text')
|
||||||
|
expect(codecFamilie('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// HandBrakes Scan der Roh-Datei (Felder wie am 1.11.2 gemessen).
|
||||||
|
const SCAN = `JSON Title Set: {
|
||||||
|
"TitleList": [ {
|
||||||
|
"AudioList": [
|
||||||
|
{ "ChannelCount": 6, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "LanguageCode": "jpn", "TrackNumber": 1 },
|
||||||
|
{ "ChannelCount": 6, "CodecName": "ac3", "Description": "Deutsch (AC3, 5.1 ch)", "LanguageCode": "deu", "TrackNumber": 2 },
|
||||||
|
{ "ChannelCount": 2, "CodecName": "ac3", "Description": "Deutsch (AC3, 2.0 ch)", "LanguageCode": "deu", "TrackNumber": 3 }
|
||||||
|
],
|
||||||
|
"Duration": { "Hours": 1, "Minutes": 30, "Seconds": 0 },
|
||||||
|
"SubtitleList": [
|
||||||
|
{ "Format": "bitmap", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },
|
||||||
|
{ "Format": "bitmap", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 2 },
|
||||||
|
{ "Format": "bitmap", "LanguageCode": "eng", "Source": 5, "SourceName": "PGS", "TrackNumber": 3 }
|
||||||
|
]
|
||||||
|
} ] }
|
||||||
|
HandBrake has exited.`
|
||||||
|
|
||||||
|
describe('Ablage: gewählte MakeMKV-Spuren in HandBrakes Scan wiederfinden', () => {
|
||||||
|
const scan = spurenAusScan(SCAN)!
|
||||||
|
it('der Scan liefert Codec und Kanäle', () => {
|
||||||
|
expect(scan.audio.map((a) => [a.nr, a.lang, a.codec, a.kanaele])).toEqual([
|
||||||
|
[1, 'jpn', 'dtshd', 6],
|
||||||
|
[2, 'deu', 'ac3', 6],
|
||||||
|
[3, 'deu', 'ac3', 2],
|
||||||
|
])
|
||||||
|
expect(scan.untertitel[0].codec).toBe('PGS')
|
||||||
|
})
|
||||||
|
it('ordnet gestuft zu — Sprache + Familie + Kanäle zuerst, jede Spur nur einmal', () => {
|
||||||
|
// Kommentar (deu DD 2.0) vor dem 5.1 gewählt: trotzdem landet er auf Spur 3.
|
||||||
|
const z = spurenZuordnen([spuren.audio[3], spuren.audio[2], spuren.audio[0]], scan.audio)
|
||||||
|
expect(z.nummern).toEqual([3, 2, 1])
|
||||||
|
expect(z.fehlend).toEqual([])
|
||||||
|
})
|
||||||
|
it('meldet, was nicht in der Datei ist — und fällt nur bei sprachlosen Dateien auf die Reihenfolge zurück', () => {
|
||||||
|
const fra: SpurAngabe = { ...spuren.audio[0], nr: 9, lang: 'fra', sprache: 'French' }
|
||||||
|
expect(spurenZuordnen([fra], scan.audio).fehlend).toEqual([fra])
|
||||||
|
const stumm = scan.audio.map((a) => ({ ...a, lang: '' }))
|
||||||
|
expect(spurenZuordnen([spuren.audio[3]], stumm).nummern).toEqual([3])
|
||||||
|
expect(spurenZuordnen([fra], stumm).nummern).toEqual([1])
|
||||||
|
})
|
||||||
|
it('spurwahl: HandBrake-Nummern, Vorwahl per Regel (fremder Ton → Wunschsprache nach vorn)', () => {
|
||||||
|
const wahl = spurwahl({ audioSpuren: [spuren.audio[0]], untertitelSpuren: [spuren.untertitel[2], spuren.untertitel[0]] }, scan, 'deu')
|
||||||
|
expect(wahl).toEqual({ audioNr: [1], untertitelNr: [1, 3], untertitelStandard: 1, fehlend: [] })
|
||||||
|
})
|
||||||
|
it('spurwahl: Vorwahl von Hand (Spur) oder ausdrücklich keine; deutscher Ton → keine Regel-Vorwahl', () => {
|
||||||
|
const deutsch = spurwahl({ audioSpuren: [spuren.audio[2]], untertitelSpuren: [spuren.untertitel[0], spuren.untertitel[2]] }, scan, 'deu')
|
||||||
|
expect(deutsch).toEqual({ audioNr: [2], untertitelNr: [1, 3], untertitelStandard: 0, fehlend: [] })
|
||||||
|
const hand = spurwahl({ audioSpuren: [spuren.audio[2]], untertitelSpuren: [spuren.untertitel[0], spuren.untertitel[2]], untertitelVorwahlSpur: 7 }, scan, 'deu')
|
||||||
|
expect(hand).toEqual({ audioNr: [2], untertitelNr: [3, 1], untertitelStandard: 1, fehlend: [] })
|
||||||
|
const keine = spurwahl({ audioSpuren: [spuren.audio[0]], untertitelSpuren: [spuren.untertitel[0]], untertitelVorwahlSpur: null }, scan, 'deu')
|
||||||
|
expect(keine!.untertitelStandard).toBe(0)
|
||||||
|
})
|
||||||
|
it('spurwahl: ohne gewählte Spuren null (die Sprachwahl gilt); Fehlendes im Klartext', () => {
|
||||||
|
expect(spurwahl({}, scan, 'deu')).toBeNull()
|
||||||
|
const fra: SpurAngabe = { ...spuren.audio[0], nr: 9, lang: 'fra', sprache: 'French' }
|
||||||
|
const wahl = spurwahl({ audioSpuren: [fra, spuren.audio[2]], untertitelSpuren: [] }, scan, 'deu')
|
||||||
|
expect(wahl!.audioNr).toEqual([2])
|
||||||
|
expect(wahl!.fehlend).toEqual(['Tonspur French DTS-HD MA'])
|
||||||
|
expect(sprachlistenAusSpuren({ audioSpuren: [fra, spuren.audio[2]], untertitelSpuren: [spuren.untertitel[2]] })).toEqual({
|
||||||
|
audio: ['fra', 'deu'],
|
||||||
|
untertitel: ['eng'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HandBrake-Schalter für einzelne Spuren (am 1.11.2 gemessen, 12.09.2026)', () => {
|
||||||
|
it('--audio 1,3 --subtitle 2,1 --subtitle-default=1, mit Kopiermaske — keine Sprachlisten', () => {
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioNr: [1, 3], untertitelNr: [2, 1], untertitelStandardNr: 1 })
|
||||||
|
expect(args[args.indexOf('--audio') + 1]).toBe('1,3')
|
||||||
|
expect(args[args.indexOf('--subtitle') + 1]).toBe('2,1')
|
||||||
|
expect(args).toContain('--subtitle-default=1')
|
||||||
|
expect(args[args.indexOf('--audio-copy-mask') + 1]).toBe(KOPIERMASKE)
|
||||||
|
expect(args).not.toContain('--audio-lang-list')
|
||||||
|
expect(args).not.toContain('--all-subtitles')
|
||||||
|
expect(args).not.toContain('--first-audio')
|
||||||
|
})
|
||||||
|
it('keine Untertitel gewählt → --subtitle none; keine Tonspur → HandBrakes erste (kein --audio)', () => {
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioNr: [], untertitelNr: [] })
|
||||||
|
expect(args[args.indexOf('--subtitle') + 1]).toBe('none')
|
||||||
|
expect(args).not.toContain('--audio')
|
||||||
|
expect(args).not.toContain('--subtitle-default=1')
|
||||||
|
})
|
||||||
|
it('F31: auch die Sprachwelt bekommt die Kopiermaske — sonst rechnet HandBrake AC3/DTS zu AAC um', () => {
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioSprachen: ['deu'] })
|
||||||
|
expect(args[args.indexOf('--audio-copy-mask') + 1]).toBe('aac,ac3,eac3,dtshd,dts,mp3,truehd,flac')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Auftrag: Spuren reisen durch Schema und JSON', () => {
|
||||||
|
it('istRipAuftragTitel nimmt Spuren und Vorwahl-Spur an, weist Unsinn ab', () => {
|
||||||
|
const titel = { nr: 0, rolle: 'hauptfilm', dauerS: 1, groesseBytes: 1, audioSprachen: [], untertitelSprachen: [] }
|
||||||
|
expect(istRipAuftragTitel({ ...titel, audioSpuren: [spuren.audio[0]], untertitelVorwahlSpur: null })).toBe(true)
|
||||||
|
expect(istRipAuftragTitel({ ...titel, audioSpuren: [{ nr: 'x' }] })).toBe(false)
|
||||||
|
expect(istRipAuftragTitel({ ...titel, untertitelVorwahlSpur: 'deu' })).toBe(false)
|
||||||
|
})
|
||||||
|
it('auftragAusJson liest die Spuren zurück, lässt Kaputtes weg', () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
vorgangId: 'v1',
|
||||||
|
discTyp: 'bluray',
|
||||||
|
rohOrdner: 'E:\\roh\\x',
|
||||||
|
dateien: [
|
||||||
|
{
|
||||||
|
pfad: 'E:\\roh\\x\\title_t00.mkv',
|
||||||
|
rolle: 'hauptfilm',
|
||||||
|
audioSpuren: [spuren.audio[0]],
|
||||||
|
untertitelSpuren: [spuren.untertitel[1]],
|
||||||
|
untertitelVorwahlSpur: 6,
|
||||||
|
},
|
||||||
|
{ pfad: 'E:\\roh\\x\\title_t01.mkv', rolle: 'extra', audioSpuren: ['kaputt'], untertitelVorwahlSpur: 'deu' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const a = auftragAusJson(json)!
|
||||||
|
expect(a.dateien[0].audioSpuren).toEqual([spuren.audio[0]])
|
||||||
|
expect(a.dateien[0].untertitelSpuren).toEqual([spuren.untertitel[1]])
|
||||||
|
expect(a.dateien[0].untertitelVorwahlSpur).toBe(6)
|
||||||
|
expect(a.dateien[1].audioSpuren).toBeUndefined()
|
||||||
|
expect(a.dateien[1].untertitelVorwahlSpur).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MakeMKV-Auswahlregel 5.7.0: Stereo-Doppel bleiben, nur Kerne weg', () => {
|
||||||
|
it('die neue Regel lässt havemulti stehen; die alte wird ersetzt, eine fremde bleibt', () => {
|
||||||
|
expect(RIPPY_REGEL).toBe('-sel:all,+sel:all,-sel:havecore,=100:all')
|
||||||
|
expect(regelUrteil(null)).toBe('setzen')
|
||||||
|
expect(regelUrteil('')).toBe('setzen')
|
||||||
|
expect(regelUrteil(RIPPY_REGEL)).toBe('rippy')
|
||||||
|
for (const alt of RIPPY_REGELN_ALT) expect(regelUrteil(alt)).toBe('ersetzen')
|
||||||
|
expect(regelUrteil('-sel:all,+sel:(favlang|nolang)')).toBe('eigen')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 5.7.0 — 4K nach der Leistung (Commander-Entscheid 12.09.2026): Grafik-
|
||||||
|
// karten-Encoder plus 4K-Preset → komprimieren; nur CPU → verlustfrei.
|
||||||
|
// Preset-Namen und Encoder am mitgelieferten HandBrake 1.11.2 gemessen
|
||||||
|
// (--preset-list, --help; vce_h265_10bit stürzt ab, AV1 10-Bit läuft).
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { DEFAULT_HB_PRESET, PRESET_KEINE, empfehlungText, presetEmpfehlung } from '../src/gemeinsam/preset-empfehlung'
|
||||||
|
import { komprimierenFuer, presetFuer } from '../src/kern/komprimieren/presets'
|
||||||
|
|
||||||
|
const PRESETS = ['H.265 MKV 1080p30', 'H.265 MKV 2160p60 4K', 'AV1 VCN 2160p 4K', 'H.265 VCN 2160p 4K', 'H.265 VCN 1080p', 'H.265 NVENC 1080p']
|
||||||
|
const NUR_CPU = ['cpu-x264', 'cpu-x265', 'cpu-av1']
|
||||||
|
const AMD = [...NUR_CPU, 'vce', 'vce-av1']
|
||||||
|
const NVIDIA_OHNE_4K = [...NUR_CPU, 'nvenc']
|
||||||
|
|
||||||
|
describe('presetEmpfehlung für 4K-UHD', () => {
|
||||||
|
it('Grafikkarte mit 4K-Preset → das 4K-Preset (8 Bit im Klartext)', () => {
|
||||||
|
expect(presetEmpfehlung('uhd', AMD, PRESETS)).toBe('H.265 VCN 2160p 4K')
|
||||||
|
expect(empfehlungText('uhd', 'H.265 VCN 2160p 4K')).toBe('H.265 VCN 2160p 4K (8 Bit)')
|
||||||
|
})
|
||||||
|
it('nur CPU → verlustfrei lassen, nie das 1080p-CPU-Preset', () => {
|
||||||
|
expect(presetEmpfehlung('uhd', NUR_CPU, PRESETS)).toBe(PRESET_KEINE)
|
||||||
|
expect(empfehlungText('uhd', PRESET_KEINE)).toContain('verlustfrei')
|
||||||
|
})
|
||||||
|
it('Grafikkarte ohne 4K-Preset → verlustfrei, nicht klein rechnen', () => {
|
||||||
|
expect(presetEmpfehlung('uhd', NVIDIA_OHNE_4K, PRESETS)).toBe(PRESET_KEINE)
|
||||||
|
})
|
||||||
|
it('DVD und Blu-ray wie bisher: Grafikkarte, sonst CPU-Default', () => {
|
||||||
|
expect(presetEmpfehlung('bluray', AMD, PRESETS)).toBe('H.265 VCN 1080p')
|
||||||
|
expect(presetEmpfehlung('dvd', NUR_CPU, PRESETS)).toBe(DEFAULT_HB_PRESET)
|
||||||
|
expect(presetEmpfehlung('bluray', NVIDIA_OHNE_4K, PRESETS)).toBe('H.265 NVENC 1080p')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('komprimierenFuer mit Empfehlung', () => {
|
||||||
|
it('automatisch + Empfehlung „keine" → nicht komprimieren', () => {
|
||||||
|
expect(komprimierenFuer('uhd', {}, PRESET_KEINE)).toBe(false)
|
||||||
|
expect(komprimierenFuer('uhd', { transcodePresetUhd: '' }, PRESET_KEINE)).toBe(false)
|
||||||
|
})
|
||||||
|
it('eigene Wahl schlägt die Empfehlung — in beide Richtungen', () => {
|
||||||
|
expect(komprimierenFuer('uhd', { transcodePresetUhd: 'AV1 VCN 2160p 4K' }, PRESET_KEINE)).toBe(true)
|
||||||
|
expect(komprimierenFuer('uhd', { transcodePresetUhd: PRESET_KEINE }, 'H.265 VCN 2160p 4K')).toBe(false)
|
||||||
|
expect(presetFuer('uhd', { transcodePresetUhd: 'AV1 VCN 2160p 4K' }, PRESET_KEINE)).toBe('AV1 VCN 2160p 4K')
|
||||||
|
})
|
||||||
|
it('Blu-ray bleibt unberührt', () => {
|
||||||
|
expect(komprimierenFuer('bluray', {}, 'H.265 VCN 1080p')).toBe(true)
|
||||||
|
expect(komprimierenFuer('bluray', {}, '')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -88,3 +88,32 @@ describe('DiscWache', () => {
|
|||||||
wache.stoppen()
|
wache.stoppen()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 5.7.0 (Durchsicht F6): runde() von außen — nach dem Auswurf — ersetzt die
|
||||||
|
// geplante Runde, statt eine zweite Takt-Kette anzulegen. Vorher: nach
|
||||||
|
// zwanzig Auswürfen zwanzig Ketten (Sonde 3, neun statt fünf Runden).
|
||||||
|
describe('DiscWache — eine Takt-Kette, egal wie oft runde() von außen kommt', () => {
|
||||||
|
it('drei Aufrufe von außen, danach nur EINE Kette', async () => {
|
||||||
|
let gelesen = 0
|
||||||
|
const wache = new DiscWache(
|
||||||
|
() => {
|
||||||
|
gelesen += 1
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
wache.starten()
|
||||||
|
wache.runde()
|
||||||
|
wache.runde()
|
||||||
|
const vorher = gelesen
|
||||||
|
await new Promise((f) => setTimeout(f, 130))
|
||||||
|
wache.stoppen()
|
||||||
|
const runden = gelesen - vorher
|
||||||
|
// Eine Kette im 20-ms-Takt schafft in 130 ms höchstens ~7 Runden; drei
|
||||||
|
// Ketten schafften vorher ~19.
|
||||||
|
expect(runden).toBeLessThanOrEqual(8)
|
||||||
|
expect(runden).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||