Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d70a81d194 | ||
|
|
7c489f8404 | ||
|
|
1f678c036d | ||
|
|
6d149212c9 | ||
|
|
9622f4e6ef | ||
|
|
306b090ccf | ||
|
|
7ce06889f8 | ||
|
|
043052b453 | ||
|
|
a8e8b2bef3 | ||
|
|
1253148652 | ||
|
|
2549b9643b | ||
|
|
663127f854 | ||
|
|
45402e887e | ||
|
|
7a4f92036d | ||
|
|
051cab3afa | ||
|
|
1d53e8526f | ||
|
|
827910fd63 | ||
|
|
0c38754c09 | ||
|
|
c0a7618e33 | ||
|
|
7d496cf654 | ||
|
|
4f943e2c4f | ||
|
|
38a3a87f70 | ||
|
|
673f7ff3e7 | ||
|
|
5522a50425 |
+964
-1
@@ -1,6 +1,969 @@
|
||||
# SAVEPOINT — Rippy
|
||||
|
||||
## Aktueller Stand: Einstellungen neu + Version 5.1.0 (31.08.2026, nachts)
|
||||
## ÜBERGABE an die nächste Session (01.09.2026, Sessionende)
|
||||
|
||||
### Wo die nächste Session startet
|
||||
|
||||
* **Arbeitsort:** `F:\Coding Stuff\rippy`, Branch
|
||||
`worktree-windows-electron` — `git pull` genügt. v5-Code in
|
||||
`rippy-windows/`; `main` ist reines Docker-Rippy.
|
||||
* **Bau-Vorbereitung:** `npm install` in `rippy-windows/`, danach
|
||||
`node node_modules/electron/install.js` (npm 11 blockt Electrons
|
||||
Install-Skript). Fürs Paketieren `vendor/` aus
|
||||
`%LOCALAPPDATA%\Rippy\tools` befüllen.
|
||||
* **Release veröffentlichen:** die VIER Dateien im Gitea-Tag `aktuell`
|
||||
ERSETZEN. **Token: `F:\Coding Stuff\Gittea Token\rippy.txt`** (40 Hex).
|
||||
`git credential fill` liefert NUR ein Passwort und taugt NICHT.
|
||||
* **Vorgehen, das sich bewährt hat:** erst lokal installieren, den
|
||||
Commander testen lassen, DANN veröffentlichen. 5.3.1 wäre sonst mit
|
||||
einem Fehler in den Kanal gegangen.
|
||||
|
||||
### Stand
|
||||
|
||||
**5.3.2 ist im Kanal und läuft beim Commander.** Ein vollständiger Rip
|
||||
ist erfolgreich durchgelaufen (seine Bestätigung). Titelauswahl,
|
||||
Serien-Ablage, Defekt-Wächter, Selbst-Update: alles am Gerät bewiesen.
|
||||
|
||||
**Committet, aber NICHT veröffentlicht:** `7c489f8` — der Dialog-Fix
|
||||
(Markieren schloss den Dialog). Kann mit der nächsten Fassung mitgehen.
|
||||
|
||||
### Was die nächste Session bauen soll (Reihenfolge = Empfehlung)
|
||||
|
||||
Der Commander hat alle Punkte gutgeheißen und zwei ergänzt.
|
||||
|
||||
1. **Roh-Dateien aufräumen** — jede Blu-ray lässt 30+ GB liegen, niemand
|
||||
räumt auf. Einstellung *behalten / nach Kompression löschen / nach X
|
||||
Tagen*, plus Anzeige der Belegung. Im Code steht seit W-3: „Löschen
|
||||
bekommt erst mit der Einstellung im UI eine Stimme."
|
||||
2. **Staffel und Folge von Hand setzen** — Rippy rät bewusst nicht; der
|
||||
Nutzer weiß aber, dass auf Disc 2 die Folgen 3–4 sind. Feld im
|
||||
Auswahl-Dialog: „Staffel 1, ab Folge 3".
|
||||
3. **Wiederholen ab Kompression** — die Roh-Dateien liegen genau dafür
|
||||
da. Heute kostet ein Preset-Wechsel 40 Minuten Neulesen.
|
||||
4. **Sprachwahl vor dem Rip** — `titelInfoLesen()` liefert Tonspuren und
|
||||
Untertitel je Titel bereits MIT, niemand liest sie aus (der vierte
|
||||
Fall von „gebaut, nicht verbunden"). Siehe auch Punkt 12.
|
||||
5. **Vorschau: welche Datei landet wo** — Rolle, Serie, Staffel und
|
||||
Extras entscheiden inzwischen einiges; zwei Zeilen Zielpfad im Dialog
|
||||
ersparen Überraschungen.
|
||||
6. **Warteschlange über mehrere Discs.**
|
||||
7. **Ein Smoke-Beweis, der KLICKT** — beide Fehler vom 01.09. waren
|
||||
Verkabelung; von 261 Unit-Tests sah sie keiner.
|
||||
8. **Laufwerks-Gesundheit aus dem Windows-Protokoll** — bei einem
|
||||
Ausfall die `cdrom`-Ereignisse lesen und sagen, ob Disc oder
|
||||
Hardware. (Am 01.09. von Hand gemacht, war entscheidend.)
|
||||
9. **Durchgehender Fortschrittsbalken** — seit Titel-für-Titel springt
|
||||
er je Titel zurück auf null. Gewichtet nach Dateigröße durchlaufen.
|
||||
10. **Aufräumen nach Abbruch** — halbe Dateien bleiben im Roh-Ordner.
|
||||
|
||||
**11. Metadaten v2 + Hero-Banner v2 (Commander-Wunsch, hoher Wert).**
|
||||
|
||||
*Erkennung:* Rippy rät heute allein am TITELTEXT. Seit 5.2.0 liegt
|
||||
bessere Evidenz ungenutzt herum:
|
||||
* **Laufzeit-Abgleich** — TMDb kennt die Laufzeit, der Info-Lauf misst
|
||||
sie. Dieser Vergleich findet NICHT statt. Stärkster Bestätiger, der
|
||||
zu haben ist; widerlegt auch falsche Treffer (95 vs. 158 Minuten).
|
||||
* **Struktur als Fingerabdruck** — steht wörtlich im KONZEPT: „1 langer
|
||||
+ viele kurze = Film; 6–13 gleich lange = Serienstaffel". Erkennt
|
||||
Serien auch ohne bdmt.
|
||||
* **Zeitpunkt** — erkannt wird beim EINLEGEN, also vor jeder Messung.
|
||||
Nach dem Info-Lauf noch einmal rechnen: aus „wahrscheinlich" wird
|
||||
„bestätigt", oder Rippy merkt selbst, dass es danebenlag.
|
||||
* `bdmt_deu.xml` als zweite Titelvariante (heute nur `bdmt_eng`).
|
||||
|
||||
*Banner:* TMDb liefert in DERSELBEN Anfrage (`append_to_response`) noch
|
||||
**Logo** (freigestellter Schriftzug — der größte optische Sprung),
|
||||
**Tagline**, **Bewertung**, **FSK** (aus den deutschen Freigaben) und
|
||||
**Besetzung** mit Portraits. Daraus ein Kino-Mix-Banner: Backdrop über
|
||||
die volle Breite, Verlauf ins Bühnen-Schwarz, Logo, Zeile
|
||||
`2011 · FSK 18 · 52 Min · Drama · ★ 7,9`, Beschreibung, Darsteller-Streifen.
|
||||
Bilder von TMDb lädt Rippy längst (Poster), eine CSP ist nicht gesetzt.
|
||||
|
||||
⚠ **Vor dem Bauen den TMDb-Schlüssel besorgen** und die Antworten
|
||||
MESSEN, statt Feldnamen zu raten (Regel D). Der Commander wurde gebeten,
|
||||
ihn nach `F:\Coding Stuff\Gittea Token\tmdb.txt` zu legen — beim
|
||||
Sessionstart nachsehen, ob er da ist.
|
||||
|
||||
**12. Sprachen je Titel im Auswahl-Dialog (Commander-Wunsch).**
|
||||
Wörtlich: *„Du hast Feature 1 auf der disc. das hat aber z. B.
|
||||
japanischen Ton und braucht dringend die deutschen untertitel. In der
|
||||
auswahl soll das direkt auswählbar sein."*
|
||||
|
||||
Heute gibt es nur GLOBALE Einstellungen (`audioSprachen`,
|
||||
`untertitelSprachen`) — dieselbe Vorgabe für jede Disc und jeden Titel.
|
||||
Nötig: je gewähltem Titel die gemessenen Tonspuren und Untertitel zum
|
||||
Anhaken. Die Daten sind schon da (`DiscInfo.sprachen` aus dem
|
||||
Info-Lauf, `parseStreamInfo`/`sprachenZusammenfassen`); es fehlt die
|
||||
Anzeige und der Weg in den Auftrag (`RipAuftragTitel` erweitern) bis in
|
||||
`komprimieren()`. Hängt eng mit Punkt 4 zusammen — zusammen bauen.
|
||||
|
||||
### Der Bug, der noch offen sein KÖNNTE
|
||||
|
||||
`7c489f8` behebt die wahrscheinliche Ursache (Markieren über den
|
||||
Dialogrand). **Nicht durch Klicken bestätigt** — ich kann das UI nicht
|
||||
bedienen. Falls es weiter auftritt, ist der zweite Verdacht die
|
||||
Kachelliste: sie hängt an `verbindung.laufwerke.length === 0`, ein
|
||||
einziger leerer Takt der Wache räumt Kachel UND Dialog ab. **Gemessen
|
||||
und vorerst widerlegt** (20 von 20 Abfragen lieferten das Laufwerk),
|
||||
aber die Fragilität bleibt. Sauber wäre, den Dialog-Zustand aus der
|
||||
Kachel herauszuheben.
|
||||
|
||||
### Hardware-Lage
|
||||
|
||||
* Das **BU40N hängt am USB** (`USBSTOR\CDROM&VEN_HL-DT-ST&PROD_BD-RE_BU40N`).
|
||||
Bei Aussetzern zuerst einen Port direkt am Mainboard probieren.
|
||||
* **Spartacus Disc 2 hat einen echten Defekt** (fester Offset 56881152 in
|
||||
Folge 1). Disc 1 liest sauber.
|
||||
* Der **MakeMKV-Beta-Key läuft Ende September ab** — Rippy holt den neuen
|
||||
selbst, nur beobachten.
|
||||
|
||||
### Auch noch offen
|
||||
|
||||
* `extras/`-Ordnername ist aus Wissen gesetzt, NICHT gemessen — Jellyfin
|
||||
muss bestätigen, dass es sie als Zugaben liest.
|
||||
* Auswurf-Messung, DVD- und Audio-CD-Durchlauf, Jellyfin-Sichtbarkeit.
|
||||
* main-Merge des Aufräum-Branches.
|
||||
|
||||
---
|
||||
|
||||
## Zwei eigene Fehler gefunden und behoben — Version 5.3.2 (01.09.2026, nachts)
|
||||
|
||||
> **Die Titelauswahl LÄUFT und ist am Gerät bestätigt.** Der Commander,
|
||||
> Screenshot: *„Serie: 2 Folgen erwartet, 2 gefunden — passt."*
|
||||
>
|
||||
> ```
|
||||
> [x] Titel 0 52:47 min 16,28 GB 7 Kap. [Folge]
|
||||
> [x] Titel 1 54:09 min 16,67 GB 7 Kap. [Folge]
|
||||
> 2 von 2 gewählt · zusammen 32,95 GB
|
||||
> ```
|
||||
>
|
||||
> Die Längen liegen 2,5 % auseinander — weit innerhalb von `AEHNLICH`
|
||||
> (15 %), deshalb „passt" ohne Unsicherheits-Warnung. Die Auslegung
|
||||
> stimmt also an echten Zahlen, nicht nur im Test.
|
||||
|
||||
### Fehler 1 (5.3.1): Der Kern verwarf `titel-lesen` STILL
|
||||
|
||||
Der Dialog blieb ewig bei „Rippy liest die Titel …". Gemessen am
|
||||
lebenden Objekt: `makemkvcon -r --noscan info dev:G:` antwortet in
|
||||
**0,8 s** — der Info-Lauf hing nie.
|
||||
|
||||
Der Fehler lag in MEINEM Code von 5.2.0: `istFensterNachricht()` kannte
|
||||
`'titel-lesen'` und `'rip-ueberspringen'` nicht. Beide standen in der
|
||||
Union, aber nicht im Prüfer, und der Kern warf sie mit
|
||||
`if (!istFensterNachricht(frage)) return` weg. **Ohne ein Wort.**
|
||||
|
||||
Auf drei Ebenen behoben:
|
||||
1. Der Prüfer kennt beide.
|
||||
2. Eine verworfene Nachricht ist LAUT (Protokoll + `kern-fehler` mit der
|
||||
verworfenen Art).
|
||||
3. **Neuer Wächter-Test:** liest das Schema, vergleicht jede `art:` der
|
||||
Unions mit dem jeweiligen Prüfer, und prüft, dass der Kern nicht mehr
|
||||
still verwirft. **GEGENPROBE gemacht** — mit zurückgenommener
|
||||
Reparatur schlägt er fehl (`expected [ 'titel-lesen' ]`), mit
|
||||
Reparatur grün. Kein Deko-Test.
|
||||
|
||||
Dazu: `DiscInfo.ursachen` reicht die kritischen MSG-Nummern durch. Beim
|
||||
5042-Zustand steht jetzt die Abhilfe im Dialog statt „keine Titel
|
||||
gefunden" — das zeigte auf die DISC, dabei fehlte das LAUFWERK.
|
||||
|
||||
### Fehler 2 (5.3.2): Ein alter Titel-Lauf legte sich über die Liste
|
||||
|
||||
*„Er liest es korrekt ein, ich bekomme auch die auswahl aber kurz danach
|
||||
kommt dieser fehler"* — „keine Antwort nach 300 s", MINUTEN nach der
|
||||
fertigen Liste. Mit EINEM Lauf unmöglich (der Timeout wird bei `close`
|
||||
gelöscht), also waren zwei unterwegs.
|
||||
|
||||
Ich hatte nie bedacht, was passiert, wenn jemand zweimal auf „Rippen"
|
||||
drückt — bei etwas, das zwei Minuten dauert und dabei nur „liest …"
|
||||
anzeigt, ist das der Normalfall, keine exotische Bedienung.
|
||||
|
||||
Behoben je Laufwerk: Ein neuer Lauf **löst den alten ab**
|
||||
(AbortController → `titelInfoLesen` killt das Kind, lehnt mit der Marke
|
||||
`ABGELOEST` ab). Nur die neueste Laufnummer darf antworten; ein
|
||||
abgelöster Lauf **schweigt**. Nebeneffekt: nie zwei `makemkvcon` auf
|
||||
derselben Disc.
|
||||
|
||||
### Setup 5.3.2
|
||||
|
||||
* **131 526 975 Bytes**, SHA-256
|
||||
`74BBB686BD7EF95C1EFF8076FCA564BF83E8836301C939C38596EA03C2D33DEE`
|
||||
* Paket-Smoke GRÜN, **261 Tests**, Ampel GRÜN (`6d14921`), im Release
|
||||
`aktuell` anonym gegengeprüft.
|
||||
* **Vorgehen war: erst lokal installieren, testen lassen, dann
|
||||
veröffentlichen** — auf Wunsch des Commanders. Hat sich bewährt: 5.3.1
|
||||
wäre sonst mit Fehler 2 in den Kanal gegangen.
|
||||
|
||||
### ⚠ Offen
|
||||
|
||||
* **Der komplette Durchlauf steht noch aus.** Auswahl-Dialog ist am Gerät
|
||||
bestätigt; ob die Folgen wirklich unter `Serien/…/Season 01/` landen
|
||||
und was Rippy zu den Episoden-Nummern sagt, ist ungeprüft.
|
||||
* Der `extras/`-Ordnername ist weiterhin aus Wissen gesetzt, nicht
|
||||
gemessen — Jellyfin muss das bestätigen.
|
||||
* Spartacus **Disc 2** hat einen echten Defekt (fester Offset 56881152 in
|
||||
Folge 1). Disc 1 liest sauber.
|
||||
|
||||
---
|
||||
|
||||
## Serien-Staffelablage + Film-Extras — Version 5.3.0 (01.09.2026, nachts)
|
||||
|
||||
> Commander: *„Mach das gerne noch mit den Serien."* und *„Für FILME
|
||||
> sollte so eine auswahl auch gelten z. B. Nur hauptfeature oder
|
||||
> hauptfeature + extras usw. Rippy muss das auch selbst zusammenbasteln."*
|
||||
|
||||
### DAS MUSTER DIESER SITZUNG — zum dritten Mal
|
||||
|
||||
Auch hier lagen die Teile **fertig und getestet** herum und waren nur
|
||||
nicht verbunden: `serienOrdner()`, `matcheEpisoden()`,
|
||||
`episodenUmbenennen()` (`ablage/struktur.ts`) und `tvStaffel()`
|
||||
(`metadaten/tmdb.ts`). Deshalb landeten Folgen unter `Filme/`, und
|
||||
Jellyfin sah lauter Einzelfilme statt einer Staffel.
|
||||
|
||||
Vorher schon so gefunden: `titelInfoLesen()` (rief niemand auf) und die
|
||||
Preset-Empfehlung (lief, war aber unsichtbar). **Lehre für die nächste
|
||||
Sitzung: Bevor etwas neu gebaut wird, erst nachsehen, ob es schon da ist
|
||||
und nur der Draht fehlt.** Der Docker-Zweig hat viel vorgelegt, was beim
|
||||
v5-Neubau mitkam, aber nie angeschlossen wurde.
|
||||
|
||||
### Die Rolle bestimmt das Ziel
|
||||
|
||||
Jeder Titel im Auftrag trägt eine `TitelRolle` (EINE Definition in
|
||||
`gemeinsam/nachrichten.ts`):
|
||||
|
||||
```
|
||||
hauptfilm -> Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
|
||||
extra -> Filme/<Titel> (Jahr)/extras/…
|
||||
folge -> Serien/<Titel>/Season NN/… S01E02.mkv
|
||||
```
|
||||
|
||||
* Rippy schlägt vor (Serie → Folge, Film → Hauptfilm, Rest → Extra), der
|
||||
Dialog lässt es je Titel ändern. Neuer Schnellwahl-Knopf
|
||||
„Hauptinhalt + Extras".
|
||||
* Die Zuordnung Datei→Rolle ist **exakt, nicht aus Dateinamen geraten**:
|
||||
Je makemkvcon-Lauf kommt genau ein Titel dazu, und genau der bekommt
|
||||
die Rolle seines Auftragseintrags (`rolleJeDatei`).
|
||||
* Der `extras/`-Ordner entsteht nur, wenn es wirklich Extras gibt.
|
||||
|
||||
### Episoden-Nummern: bewusst zurückhaltend
|
||||
|
||||
`RipAuftragTitel.dauerS` reist mit, die Staffel-Laufzeiten kommen über
|
||||
einen **injizierten** Rückruf (`episodenLaufzeiten`, im Kern aus
|
||||
`tvStaffel`) — so bleibt die Pipeline ohne Netz prüfbar.
|
||||
`matcheEpisoden()` benennt **nur bei EINDEUTIGER Zuordnung** um. Sonst
|
||||
behalten die Dateien ihre MakeMKV-Namen, und Rippy nennt den Grund
|
||||
(„keine Staffel-Laufzeiten von TMDb" bzw. „nicht eindeutig"). Lieber gar
|
||||
nicht als falsch — ARMs offene Wunde #395 war genau dieses Ratespiel.
|
||||
|
||||
Die Staffel kommt aus dem Disc-Titel (`discZusatzAbtrennen`, seit 5.1.1),
|
||||
sonst 1.
|
||||
|
||||
### Setup 5.3.0
|
||||
|
||||
* **131 526 532 Bytes**, SHA-256
|
||||
`A4613214F18481B03D6D11DDAE4344C8537A12825C086E89039380C62767043F`
|
||||
* Paket-Smoke GRÜN (`version=5.3.0`), **257 Tests**, Ampel GRÜN
|
||||
(`7ce0688`), im Release `aktuell` anonym gegengeprüft.
|
||||
|
||||
### ⚠ Offen — beides braucht Hand am Gerät
|
||||
|
||||
* **Weder Auswahl-Dialog noch Serien-Ablage sind je an echter Hardware
|
||||
gelaufen.** Logik getestet, Verkabelung typgeprüft; der erste echte
|
||||
Durchlauf steht aus. Das Laufwerk war im 5042-Zustand und sollte nach
|
||||
dem halbstündigen Hämmern nicht weiter belastet werden.
|
||||
* **Der `extras/`-Ordnername ist aus Wissen gesetzt, NICHT gemessen.**
|
||||
Ob Jellyfin ihn als Zugaben liest, kann nur die Bibliothek des
|
||||
Commanders beantworten. Falls nicht: `featurettes` oder
|
||||
`behind the scenes` sind die Alternativen.
|
||||
* Bei zwei ähnlich langen Spartacus-Folgen wird die Episoden-Zuordnung
|
||||
vermutlich NICHT eindeutig — dann liegen sie richtig einsortiert im
|
||||
Staffelordner, heißen aber noch wie MakeMKV sie nannte. Das ist das
|
||||
gewollte Verhalten, kein Fehler.
|
||||
|
||||
---
|
||||
|
||||
## Titelauswahl + Defekt-Wächter — Version 5.2.0 (01.09.2026, nachts)
|
||||
|
||||
> Commander: *„es fehlt generell noch komplett die auswahl WAS man rippen
|
||||
> möchte. Bei Serien eben genau das die folgen, bei filmen z. B. nur das
|
||||
> hauptfeature oder auch die extras."*
|
||||
>
|
||||
> Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte
|
||||
> **immer alles**, samt Trailer, Werbung und Alterskennzeichen.
|
||||
|
||||
### Der Anlass: eine defekte Blu-ray
|
||||
|
||||
Beim Rip der Spartacus-Disc hing MakeMKV eine halbe Stunde an EINER
|
||||
Stelle. Gemessen (01.09.2026):
|
||||
|
||||
```
|
||||
Offset '56881152' — immer derselbe, 39× „MEDIUM ERROR:L-EC UNCORRECTABLE"
|
||||
makemkvcon64 — 1,7 s CPU in 30 Minuten (es WARTETE)
|
||||
Windows-Systemlog — 41 cdrom-Ereignisse in 10 Minuten (ID 7 + ID 11)
|
||||
```
|
||||
|
||||
**Wichtige Korrektur einer eigenen Fehldiagnose:** Erst hielt ich die
|
||||
USB-Stromversorgung für die Ursache (das Laufwerk hängt am USB, ID 11 =
|
||||
Controllerfehler). Der FESTE Offset widerlegt das — Stromprobleme werfen
|
||||
Fehler an wechselnden Stellen. Es ist ein physischer Defekt auf der Disc;
|
||||
die Controllerfehler sind die FOLGE des halbstündigen Hämmerns. Der Code
|
||||
wusste es sogar schon: `makemkv.ts` notiert seit 30.08. „MakeMKV kann 1
|
||||
von 2 Titeln sichern … Spartacus Disc 2".
|
||||
|
||||
### Der Umbau: Titel für Titel statt `all`
|
||||
|
||||
Beide Wünsche brauchen denselben Unterbau, deshalb in einem Zug. Zwei
|
||||
Bausteine lagen fertig herum und waren nur **nicht verbunden**:
|
||||
`titelInfoLesen()` (Titel mit Dauer/Größe/Kapiteln) rief NIEMAND auf, und
|
||||
`buildRipArgs(…, titel)` konnte längst einen einzelnen Titel.
|
||||
|
||||
### Neu `kern/rip/titelwahl.ts` (pur, ohne Laufwerk)
|
||||
|
||||
* **Serie** (von der Disc BEZEUGT, bdmt nennt Folgen): so viele Titel wie
|
||||
Folgen erwartet; passen die Längen nicht zusammen, sagt Rippy das.
|
||||
* **Film**: der längste Titel.
|
||||
* **Sammeltitel-Falle** (`SAMMEL_TOLERANZ`): Ein Titel, der so lang ist
|
||||
wie die anderen ZUSAMMEN („alle Folgen am Stück"), wäre der längste und
|
||||
würde die Film-Auswahl gewinnen — Rippy hätte alles doppelt gesichert.
|
||||
* **Angel-Falle** (`FILM_GLEICHAUF`): Mehrere fast gleich lange Fassungen
|
||||
(Seamless Branching). Rippy nimmt die mit den meisten Kapiteln UND sagt,
|
||||
dass es unsicher ist.
|
||||
* **Unklar heißt: nichts vorausgewählt.** Lieber fragen als raten.
|
||||
|
||||
**Bewusst NICHT geraten:** Die bdmt-`titleNumber` (1, 2, 51, 101 …) sind
|
||||
Blu-ray-Playlist-Nummern, MakeMKVs Titelnummern sind eigene. Sie
|
||||
gleichzusetzen wäre Raterei — benutzt wird nur die ANZAHL als Prüfsumme.
|
||||
|
||||
### Der Defekt-Wächter
|
||||
|
||||
* `offsetAusMeldung()` liest die Stelle als **letzte gequotete Zahl** —
|
||||
damit sprachunabhängig (deutsch „bei Offset", englisch „at offset").
|
||||
* `RipAuswertung` zählt Wiederholungen derselben Stelle; ab `HAENGT_AB`
|
||||
(12 Meldungen ≈ 6 Versuche) gilt der Lauf als hängend. **Fortschritt
|
||||
räumt den Verdacht wieder ab.**
|
||||
* Das Fenster zeigt Klartext + Knopf **„Titel überspringen"** →
|
||||
`Pipeline.ueberspringen()` beendet nur den laufenden makemkvcon
|
||||
(eigener AbortController je Titel), der nächste Titel läuft weiter.
|
||||
* Ein gescheiterter Titel beendet den Auftrag NICHT mehr — die Gründe
|
||||
werden gesammelt und am Ende genannt.
|
||||
|
||||
### Ablauf im Fenster
|
||||
|
||||
„Rippen" → Info-Lauf (20–120 s, mit Ansage) → Auswahl-Dialog mit Dauer,
|
||||
Größe, Kapiteln und Begründung je Titel → Schnellwahl „Rippys Vorschlag /
|
||||
Alles / Nichts" → rippen. Der Info-Lauf startet **erst beim Klick**, nie
|
||||
beim Einlegen (der 30.08.-Fund „das Erkennen dauert sehr sehr lange").
|
||||
|
||||
### Setup 5.2.0
|
||||
|
||||
* **131 526 561 Bytes**, SHA-256
|
||||
`C426A8460DDBE43C899933956F2A7A97A96083638D1C910F482802D9FAD8946A`
|
||||
* Paket-Smoke GRÜN (`version=5.2.0`), **254 Tests** (vorher 237; 17 neue
|
||||
in `titelwahl.test.ts`, alle beim ersten Lauf grün), Ampel GRÜN
|
||||
(`a8e8b2b`), im Release `aktuell` anonym gegengeprüft.
|
||||
|
||||
### ⚠ Offen
|
||||
|
||||
* **Der Auswahl-Dialog ist noch nie an echter Hardware gelaufen** — das
|
||||
Laufwerk war im 5042-Zustand und sollte nicht weiter belastet werden.
|
||||
Logik getestet, Verkabelung typgeprüft, erster echter Durchlauf steht aus.
|
||||
* **Serien-Ablage fehlt weiterhin:** Folgen landen unter `Filme/`. Für
|
||||
Jellyfin & Co. bräuchte es `Serien/<Titel>/Staffel X/` mit `SxxEyy`.
|
||||
Bewusst als eigener Brocken zurückgestellt.
|
||||
* **Das BU40N hängt am USB** (`USBSTOR\CDROM&VEN_HL-DT-ST&PROD_BD-RE_BU40N`)
|
||||
— bei künftigen Aussetzern zuerst Port direkt am Mainboard probieren.
|
||||
* Die Spartacus-Disc hat einen echten Defekt in Folge 1. Zum Prüfen der
|
||||
neuen Auswahl: NUR Folge 2 anhaken, dann wird der Defekt gar nicht erst
|
||||
angefahren.
|
||||
|
||||
---
|
||||
|
||||
## Windows-Rand, Hardware-Empfehlung, Wizard — Version 5.1.5 (01.09.2026, nachts)
|
||||
|
||||
> **Zuerst der Beweis, dass 5.1.1 wirkt.** Der Commander legte die
|
||||
> Spartacus-Disc mit dem neuen UI ein, im Protokoll steht:
|
||||
>
|
||||
> ```
|
||||
> Laufwerk G: erkannt als „Spartacus: Gods of the Arena" · 90 %
|
||||
> ```
|
||||
>
|
||||
> Nicht der Film von 1960, sondern die Serie — die vorhergesagten 90 %,
|
||||
> an der echten Disc. Der Fund ist damit am lebenden Objekt erledigt.
|
||||
|
||||
Vier Punkte auf Zuruf:
|
||||
|
||||
### 1. Der Windows-11-Rand — er kam wirklich von Windows
|
||||
|
||||
Ein rahmenloses Fenster behält unter Windows 11 den **DWM-Rand**: eine
|
||||
feine Linie, die der Fenstermanager zeichnet, nicht die Seite. Mit CSS
|
||||
nicht erreichbar. Neu `haupt/fensterrand.ts`:
|
||||
`DwmSetWindowAttribute(DWMWA_BORDER_COLOR = DWMWA_COLOR_NONE)`.
|
||||
**Gemessen: HRESULT 0**, auch im gepackten Programm (keine
|
||||
`Fensterrand bleibt`-Warnung im Paket-Smoke).
|
||||
|
||||
Runde Ecken bleiben (Windows-11-üblich); `eckenKantig = true` schaltet
|
||||
sie ab, wenn gewünscht. Ältere Windows antworten mit Fehler-HRESULT —
|
||||
wird als Wert zurückgegeben und protokolliert (R4), dort gibt es den
|
||||
Rand ohnehin nicht.
|
||||
|
||||
**Regeländerung, bewusst und gemeldet:** Das ist der DRITTE erlaubte
|
||||
koffi-Ort. Wächter R1 kennt jetzt `leine.ts`, `fensterrand.ts` und
|
||||
`kern/laufwerk/win32.ts`. Die Regel („Win32 nur an benannten Orten")
|
||||
bleibt und hält alles andere weiter dicht. Grund: `DwmSetWindowAttribute`
|
||||
braucht das Fensterhandle und kann nur im Haupt liegen.
|
||||
|
||||
### 2. Die Preset-Empfehlung gab es SCHON
|
||||
|
||||
Commander: *„Die alte Rippy version konnte automatisch anhand der
|
||||
gescannten hardware encoder das beste preset wählen."* — Diese auch.
|
||||
`presetEmpfehlung()` wählt seit dem Kino-Mix nach den gemessenen
|
||||
Backends (VCN → NVENC → QSV → CPU); in den Einstellungen stand
|
||||
„automatisch: H.265 VCN 1080p", also seine AMD-Karte.
|
||||
|
||||
**Das Problem war Sichtbarkeit, nicht Logik.** Die Erst-Einrichtung hat
|
||||
jetzt einen Schritt „Qualität": gemessene Encoder als Badges, die
|
||||
Empfehlung für DVD / Blu-ray / 4K daneben, und eine ehrliche Warnung,
|
||||
wenn nur CPU-Encoder da sind. Die Logik selbst blieb unangetastet.
|
||||
|
||||
### 3. Feld „Update-Adresse" ist raus
|
||||
|
||||
Der Schlüssel `updateUrl` gilt weiter (Datenbank), § 6.8 prüft ihn
|
||||
unverändert — nur das Eingabefeld ist weg.
|
||||
|
||||
### 4. Erst-Einrichtung geprüft — fünf Befunde
|
||||
|
||||
* **„Dieses Setup stellt vier Dinge ein"** — es stellte ZWEI ein.
|
||||
* Der Ablage-Schritt zeigte eine **leere Zeile**, wenn nichts gewählt
|
||||
war. Es gibt aber eine Vorgabe (`kern/index.ts`: `Videos\Rippy` im
|
||||
Benutzerprofil) — die steht jetzt da.
|
||||
* **Kein Wort zum TMDb-Schlüssel**, obwohl Schritt 1 „Rippy erkennt, was
|
||||
drauf ist" verspricht. Ohne Schlüssel heißen Filme wie das Disc-Label.
|
||||
Jetzt ein eigener Schritt mit Eingabefeld.
|
||||
* Rohe HTML-Checkbox statt des Kino-Mix-Schalters.
|
||||
* Kein Hinweis, dass Rippy im Infobereich weiterlebt.
|
||||
|
||||
Neu **sechs Schritte, drei Fragen** — und die Zahl im Text stimmt.
|
||||
|
||||
**Dazu `#einrichtung` als Beweis-Anker** (`RIPPY_START_BEREICH`): Der
|
||||
Wizard war nach dem ersten Start überhaupt nicht mehr erreichbar und
|
||||
damit nicht mehr prüfbar. Beweis: `beweise/ui-515-wizard.png`.
|
||||
|
||||
### Setup 5.1.5
|
||||
|
||||
* `dist-setup-515/RippySetup-5.1.5.exe`, **131 529 635 Bytes**, SHA-256
|
||||
`A7E8B51350D2C78AC6BDA2DF6881E640D3B4D84C935B4B395529433849B18D15`
|
||||
* Paket-Smoke GRÜN (`version=5.1.5`), 237 Tests, Ampel GRÜN (`663127f`)
|
||||
* Im Release `aktuell`, anonym gegengeprüft: Content-Length =
|
||||
`latest.yml`; Changelog vollständig mit Umlauten durchgereicht.
|
||||
|
||||
### ✅ DER SELBST-UPDATE-BEWEIS IST ERBRACHT
|
||||
|
||||
Der Commander, 01.09.2026: *„Also das upate funktioniert!"* — 5.1.4 fand
|
||||
5.1.5 selbst, lud sie, installierte sie und kam in der neuen Fassung
|
||||
zurück. **Ohne Handanlegen.**
|
||||
|
||||
Damit ist der offene Punkt 1 aus der Übergabe vom 31.08. erledigt, und
|
||||
die Kette ist zum ERSTEN Mal komplett bewiesen — nicht nur der Kanal
|
||||
(Download, Prüfsummen, Changelog), sondern auch der letzte Meter, das
|
||||
Installieren. Genau der Meter, an dem es von 5.1.2 bis 5.1.3 still
|
||||
gescheitert war.
|
||||
|
||||
**Ab jetzt gilt wieder die einfache Regel:** neues Release = die vier
|
||||
Dateien im Gitea-Tag `aktuell` ersetzen, fertig. Jede installierte Rippy
|
||||
ab 5.1.4 zieht selbst nach.
|
||||
|
||||
### Weitergabe an andere Rechner
|
||||
|
||||
Die Setup-EXE genügt — **außer MakeMKV**, das ist Pflicht und liegt aus
|
||||
Lizenzgründen nicht bei (makemkv.com/download; Rippy findet es danach
|
||||
selbst und holt den Beta-Schlüssel automatisch). HandBrakeCLI und FLAC
|
||||
sind im Paket. Nur **5.1.4 oder neuer** weitergeben: Ältere können sich
|
||||
nicht selbst aktualisieren.
|
||||
|
||||
---
|
||||
|
||||
## Die eigene Leine erschlug den Update-Installer — Version 5.1.4 (01.09.2026, nachts)
|
||||
|
||||
> Commander: *„Das Update auf Version 5.1.3 geht nicht. Rippy wird zwar
|
||||
> sauber beendet aber startet NICHT neu in der neuen version."*
|
||||
>
|
||||
> **Die Update-Kette hat ab 5.1.2 nie funktioniert.** Bewiesen war immer
|
||||
> nur der KANAL (Download, Prüfsummen, Changelog) — der letzte Meter, das
|
||||
> INSTALLIEREN, war nie gemessen. Genau die Sorte Annahme, vor der
|
||||
> AGENTS.md warnt.
|
||||
|
||||
### Die Ursache, auf drei Beinen belegt
|
||||
|
||||
1. **Bibliothek:** `electron-updater` startet den Installer per
|
||||
`spawn(…, { detached: true })` (`BaseUpdater.js`, an der installierten
|
||||
Fassung nachgelesen). `detached` setzt unter Windows **kein**
|
||||
Breakaway.
|
||||
2. **Leine:** `leine.ts` setzt `KILL_ON_JOB_CLOSE`, und Kinder erben die
|
||||
Mitgliedschaft. Der Installer war damit Mitglied der Arbeitsgruppe.
|
||||
3. **Platte:** `pending\RippySetup-5.1.3.exe` lag geladen da (18:11:47),
|
||||
installiert war weiter 5.1.2.
|
||||
|
||||
Der Installer starb also in genau der Sekunde, in der Rippy sich
|
||||
beendete, um ihm Platz zu machen.
|
||||
|
||||
### Der Fix — die Leine bleibt, der Installer klinkt sich aus
|
||||
|
||||
* `leine.ts` setzt zusätzlich `JOB_OBJECT_LIMIT_BREAKAWAY_OK`. Das ändert
|
||||
für sich genommen NICHTS (siehe Messung, Fall 2).
|
||||
* Neu `ausserhalbDerLeineStarten()` — `CreateProcessW` mit
|
||||
`CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW`. Liegt in `leine.ts`,
|
||||
weil dort die Arbeitsgruppe verwaltet wird und Wächter R1 koffi nur
|
||||
dort und in `kern/laufwerk/win32.ts` erlaubt.
|
||||
* `update.ts` installiert jetzt SELBST: `autoInstallOnAppQuit` ist AUS,
|
||||
der Pfad kommt aus `downloadUpdate()` (AppUpdater.d.ts: „Paths to
|
||||
downloaded files"), die Argumente sind die gemessenen
|
||||
(`--updated /S --force-run`). Gestartet bei `will-quit` und per Knopf.
|
||||
* Neu `kommandozeile.ts` (pur, ohne koffi/Electron): `CreateProcessW`
|
||||
nimmt EINE Zeichenkette — ohne die Regeln von `CommandLineToArgvW`
|
||||
zerfiele ein Pfad wie `C:\Users\Tobi Neu\…` in zwei Argumente.
|
||||
* **Warum nicht `shell.openPath` wie bei MakeMKV (§ 9)?** Der entkommt der
|
||||
Leine nur, WEIL er Adminrechte verlangt und über den AppInfo-Dienst
|
||||
startet. Rippys Installer ist bewusst benutzerlokal
|
||||
(`isAdminRightsRequired: false`) — er liefe wieder als Kind.
|
||||
|
||||
### Die Messung: `beweise/leine-breakaway.js`
|
||||
|
||||
```
|
||||
1) Leine wie bisher, Kind normal -> stirbt WIE ERWARTET
|
||||
2) Leine erlaubt Ausklinken, Kind normal -> stirbt WIE ERWARTET
|
||||
3) Leine erlaubt Ausklinken, Kind klinkt sich aus -> lebt WIE ERWARTET
|
||||
```
|
||||
|
||||
**Fall 2 ist der wichtige:** Das bloße Erlauben weicht § 3.3 NICHT auf —
|
||||
Kern, makemkvcon und HandBrakeCLI bleiben angeleint, die rc10-Waisen-
|
||||
Falle ist unverändert zu.
|
||||
|
||||
**Der erste Messlauf zeigte ein falsches Negativ** („Ausklinken wirkt
|
||||
nicht"). Grund: Der Enkel teilte sich die KONSOLE des Elternprozesses und
|
||||
starb mit ihr, nicht mit der Arbeitsgruppe. Erst `CREATE_NO_WINDOW` misst,
|
||||
was gemessen werden soll. **Lehre: Auch der Versuchsaufbau kann die
|
||||
falsche Sache messen** — der Flag steht deshalb auch im echten Aufruf.
|
||||
|
||||
### Setup 5.1.4 und Release
|
||||
|
||||
* `dist-setup-514/RippySetup-5.1.4.exe`, **131 528 605 Bytes**, SHA-256
|
||||
`2933CA098421A3C5C5807E32BB718B18C4A7FB9F1AB6D70C9B1D164A15F21B84`
|
||||
* Paket-Smoke GRÜN: `leine=gesetzt … version=5.1.4` — die Leine steht
|
||||
auch mit dem neuen Recht.
|
||||
* Im Release `aktuell`, anonym von außen gegengeprüft: Content-Length =
|
||||
`latest.yml` = lokale Datei; `releaseNotes` durchgereicht.
|
||||
* 237 Tests, Ampel GRÜN (`051cab3`).
|
||||
|
||||
### ⚠ Der Sprung AUF 5.1.4 ist einmalig Handarbeit
|
||||
|
||||
Eine installierte 5.1.0–5.1.3 kann sich nicht selbst aktualisieren — der
|
||||
Fehler steckt in IHR. `RippySetup-5.1.4.exe` einmal von Hand ausführen.
|
||||
**Ab 5.1.4 läuft die Kette von allein**; das ist dann der erste echte
|
||||
Selbst-Update-Beweis und steht noch aus (z. B. mit einer kleinen 5.1.5).
|
||||
|
||||
---
|
||||
|
||||
## Eigenes Fenster + Layout, das mitwächst — Version 5.1.3 (01.09.2026, abends)
|
||||
|
||||
### Zuerst: „hier zieht garnichts hoch" — doch, es zog
|
||||
|
||||
Der Commander meldete, das Update komme nicht an. **Es kam an.** Gemessen
|
||||
in `%LOCALAPPDATA%\rippy-updater\pending\`:
|
||||
|
||||
```
|
||||
RippySetup-5.1.2.exe 131 526 566 B geladen 17:57:10
|
||||
SHA-256 27D1BA58…6E3C03 = exakt die hochgeladene Datei
|
||||
update-info.json {"fileName":"RippySetup-5.1.2.exe", …}
|
||||
```
|
||||
|
||||
Der Kanal arbeitete also einwandfrei. Zwei Gründe, warum davon nichts zu
|
||||
sehen war:
|
||||
|
||||
1. Die installierte **5.1.0 kennt die Update-Karte nicht** — der Code
|
||||
dafür kommt erst mit 5.1.2 mit.
|
||||
2. **Installiert wird beim Beenden — und Rippy lebt im Tray.** Das
|
||||
Fenster zu machen beendet Rippy NICHT (§ 4.1 Punkt 3), also kam
|
||||
`autoInstallOnAppQuit` nie zum Zug. Nötig ist ein echtes Beenden
|
||||
über das Tray-Symbol.
|
||||
|
||||
**Lehre (passt zu AGENTS.md „nicht aus einem Zustandswert auf einen
|
||||
Mechanismus schließen"):** „Es passiert nichts" hieß hier nicht „der
|
||||
Kanal ist kaputt", sondern „die Wirkung ist an eine Bedingung geknüpft,
|
||||
die niemand kennt". Der Ordner `rippy-updater\pending` beantwortet die
|
||||
Frage in zehn Sekunden.
|
||||
|
||||
### Der UI-Umbau (`827910f`, Ampel GRÜN, 230 Tests)
|
||||
|
||||
Commander: *„Momentan ist alles nach links gerückt, nichts skaliert mit
|
||||
der fenstergröße und es ist alles gequetscht."*
|
||||
|
||||
**Ursache:** `<main>` hatte WEDER Zentrierung NOCH Maximalbreite — die
|
||||
Karten darin aber einen harten Deckel von `max-w-2xl` (672 px). In einem
|
||||
1280er Fenster klebte damit alles links, rechts stand nichts.
|
||||
|
||||
* `<main>` und Kopfzeile teilen sich `mx-auto max-w-[1600px]` mit
|
||||
mitwachsendem Innenabstand.
|
||||
* Der 672-px-Deckel der Einstellungs-Spalte ist weg — drei Encoder-Felder
|
||||
stehen jetzt nebeneinander statt untereinander.
|
||||
* Die Sektionswahl der Einstellungen wird im schmalen Fenster zur Zeile
|
||||
über den Karten (`lg:flex-row`), statt 176 px vom knappen Platz zu
|
||||
nehmen.
|
||||
* Info-Kacheln: zwei Spalten schmal, vier breit (vorher immer vier).
|
||||
* Startgröße 1000×700 → **1280×820**, Mindestmaß 720×480 → 860×560.
|
||||
|
||||
### Eigene Titelleiste (§ 4.2)
|
||||
|
||||
Commander: *„wäre sogar richtig cool wenn du den rahmen wegbekommst"*.
|
||||
|
||||
`frame: false`, und die Kopfzeile IST die Titelleiste: Minimieren /
|
||||
Maximieren / Schließen oben rechts in Windows-Maßen (46 × 32), gezogen
|
||||
wird an der Kopfzeile, Doppelklick maximiert.
|
||||
|
||||
* **Größe ändern bleibt möglich** — belegt aus Electrons eigener
|
||||
Typdefinition (`electron.d.ts`): `thickFrame` ist standardmäßig `true`,
|
||||
erst ein `false` würde „disable window resizing via dragging the window
|
||||
edges". Wir lassen den Standard.
|
||||
* **Schließen nimmt GENAU den Weg des alten Fensterkreuzes**
|
||||
(`fenster.close()` → der bestehende close-Horcher versteckt nur):
|
||||
Rippy lebt im Tray weiter, ein laufender Rip merkt nichts.
|
||||
* Ziehfläche ist `.ziehbar` in `stil.css`; alles Bedienbare darin trägt
|
||||
`.nicht-ziehbar`, sonst verschluckt der Griff den Klick.
|
||||
* `fensterMaximiert` im `HauptStatus` steuert das Knopf-Symbol und wird
|
||||
auch bei Doppelklick/Tastenkürzel nachgeführt (`maximize`/`unmaximize`).
|
||||
|
||||
Beweis-Bilder: `beweise/ui-513-uebersicht.png` (kein Rahmen, eigene
|
||||
Knöpfe, volle Breite), `beweise/ui-513-einstellungen.png`.
|
||||
|
||||
### Setup 5.1.3 und Release
|
||||
|
||||
* Gebaut nach `dist-setup-513/` (EBUSY-Sperre, BAUEN.md), Paket-Smoke
|
||||
GRÜN mit `version=5.1.3`
|
||||
* **131 527 863 Bytes**, SHA-256
|
||||
`53CC7A5A505D44149E833ED261ACAB5E8DB1ED47F27BB144D1A89B6F076E3C03`
|
||||
* Im Release `aktuell`, anonym von außen gegengeprüft: Content-Length =
|
||||
Zahl in `latest.yml` = lokale Datei; `releaseNotes` durchgereicht,
|
||||
Umlaute intakt (1561 B).
|
||||
|
||||
### ⚠ Offen für die nächste Sitzung
|
||||
|
||||
* **Der Selbst-Update-Beweis am lebenden Objekt.** Beim Beenden über das
|
||||
Tray installiert die wartende 5.1.2, beim nächsten Start findet sie
|
||||
5.1.3 — und DANN ist die goldene Karte zum ersten Mal zu sehen. Genau
|
||||
der Ablauf, den der Commander sehen wollte.
|
||||
* **Das Laufwerk ist wieder im 5042-Zustand** (`MediaLoaded: False`,
|
||||
`G:\` antwortet nicht) — für den Disc-Test Disc per Taste am Laufwerk
|
||||
auswerfen und neu einlegen.
|
||||
* Der Spartacus-Durchlauf mit dem neuen UI steht noch aus.
|
||||
|
||||
---
|
||||
|
||||
## Update sichtbar gemacht — Version 5.1.2 (01.09.2026, abends)
|
||||
|
||||
> Der Commander: *„irgendwie fehlt in rippy selbst der Button ‚Auf
|
||||
> updates prüfen' oder auch direkt beim start ne meldung ‚ey jo update
|
||||
> verfügbar' — wäre noch cool wenn das kommt inkl. changelog"*.
|
||||
>
|
||||
> Zu Recht: Der Kanal arbeitete korrekt, war aber **unsichtbar**. Der
|
||||
> Update-Stand lag im Haupt und ging nur beim Laden des Fensters EINMAL
|
||||
> mit. Fand die Prüfung etwas, änderte sich am Bildschirm nichts — der
|
||||
> einzige Hinweis war ein Satz unter „Update-Adresse", den man kennen
|
||||
> musste, um ihn zu finden.
|
||||
|
||||
### Was jetzt da ist (`c0a7618`, Ampel GRÜN, 230 Tests)
|
||||
|
||||
* **Goldene Karte oben in der Übersicht**, sobald etwas gefunden wird:
|
||||
neue Versionsnummer, laufende Version, Ladebalken, Änderungsnotizen.
|
||||
Dazu der Windows-Toast beim Fund.
|
||||
* **Knopf „Jetzt auf Updates prüfen"** in den Einstellungen. Während
|
||||
eines Rips AUS, mit Begründung im Tooltip — Rippy sucht dann bewusst
|
||||
nicht (§ 6.8), das ist keine Panne.
|
||||
* **Knopf „Jetzt neu starten und installieren"** auf der Karte, sobald
|
||||
das Paket geladen ist. Sagt NEIN mit Grund statt still nichts zu tun.
|
||||
* `setzen()` ruft IMMER `beiAenderung` → jede Änderung am Stand meldet
|
||||
sich sofort ans Fenster. Kein Zustand mehr, den niemand erfährt.
|
||||
* `UpdateStand` ist jetzt EINE Definition in `gemeinsam/nachrichten.ts`.
|
||||
|
||||
### Der Changelog — an der Bibliothek geprüft, nicht geraten (Regel D)
|
||||
|
||||
`app-builder-lib/out/publish/updateInfoBuilder.js`, `getReleaseInfo()`
|
||||
liest genau den Dateinamen `release-notes.md` und legt den Inhalt als
|
||||
`releaseNotes` in `latest.yml`; `builder-util-runtime` deklariert
|
||||
`UpdateInfo.releaseNotes` als `string | Array<ReleaseNoteInfo> | null`.
|
||||
`neuerungenText()` verträgt beide Formen, wirft HTML-Marken raus und
|
||||
deckelt auf 4000 Zeichen. Quelle des Textes: **`bau/release-notes.md`**
|
||||
— die ist bei jedem Release zu pflegen, sie IST die Meldung im Fenster.
|
||||
|
||||
Nachgemessen am fertigen Bau UND anonym vom Server: Der Block
|
||||
`releaseNotes: |` steht in `latest.yml` (1191 B statt 337 B), Umlaute
|
||||
unversehrt.
|
||||
|
||||
### Das Setup 5.1.2 und das Release
|
||||
|
||||
* `rippy-windows/dist-setup-neu/RippySetup-5.1.2.exe` — **wegen der
|
||||
EBUSY-Sperre in ein frisches Verzeichnis gebaut** (BAUEN.md), der
|
||||
Paket-Smoke auf `dist-setup/` hatte `app.asar` verriegelt.
|
||||
* Größe **131 526 566 Bytes**, SHA-256
|
||||
`27D1BA58BF613C43BBCBF8CB819401A6D3CD6F6E59F09AE76BDC350F0CC9DA8E`
|
||||
* Paket-Smoke GRÜN, meldet `version=5.1.2`
|
||||
* Im Release `aktuell`: EXE + blockmap neu, `latest.yml` und `w0.yml`
|
||||
ersetzt. Anonym von außen gegengeprüft — Server-Content-Length =
|
||||
Zahl in `latest.yml` = lokale Datei.
|
||||
* **Die EXEn von 5.1.0 und 5.1.1 liegen bewusst weiter im Release**
|
||||
(Rückfallebene). Wenn der Platz knapp wird: 5.1.0 kann weg, der
|
||||
Commander hat sie auf dem Desktop.
|
||||
|
||||
### ⚠ Zu wissen: Die Karte ist erst AB 5.1.2 sichtbar
|
||||
|
||||
Eine installierte 5.1.0/5.1.1 zeichnet die Karte noch nicht — der Code
|
||||
dafür kommt ja erst mit diesem Update mit. Sie aktualisiert sich still
|
||||
auf 5.1.2, wie bisher. **Erst ein Update VON 5.1.2 AUF etwas Neueres
|
||||
zeigt die Meldung.** Angeboten, noch nicht entschieden: eine winzige
|
||||
5.1.3 nachlegen, damit der Commander den Ablauf einmal komplett sieht.
|
||||
|
||||
---
|
||||
|
||||
## Disc-Erkennung repariert — Version 5.1.1 (01.09.2026)
|
||||
|
||||
> Der Commander legte **Disc 2 von „Spartacus: Gods of the Arena"** ein,
|
||||
> Rippy meldete den **Film „Spartacus" von 1960**. Er schlug vor, Discs
|
||||
> künftig über ihre EAN/Barcode-Nummer zu erkennen. Die Messung zeigte:
|
||||
> Das braucht es nicht — **die Disc trug die richtige Antwort die ganze
|
||||
> Zeit selbst mit sich, Rippy las nur eine Zeile davon.**
|
||||
|
||||
### Warum der Barcode nicht die Antwort war
|
||||
|
||||
Der Barcode steht auf der HÜLLE, nicht auf der Scheibe. Ein Laufwerk
|
||||
liest die Scheibe. Für DVD und Blu-ray gibt es keinen Weg an die EAN.
|
||||
(Ausnahme, notiert und nicht gebaut: Audio-CDs tragen im Subchannel eine
|
||||
Media Catalog Number — das IST der UPC/EAN. Als Auffangnetz für
|
||||
unbekannte MusicBrainz-Kennungen später denkbar; TMDb und OMDb können
|
||||
ohnehin keine Barcode-Suche, dafür bräuchte es eine Produktdatenbank als
|
||||
Zwischenstufe mit fraglicher Abdeckung deutscher Editionen.)
|
||||
|
||||
### Was tatsächlich kaputt war (Laufwerk G:, `SPARTACUS_GOTA_D2`, UDF, 33 759 100 928 B)
|
||||
|
||||
Rippy las korrekt `Spartacus: Gods Of The Arena - Disc 2` und baute
|
||||
daraus Suchvarianten — der Doppelpunkt-Vorspann stand auf **Platz 2**:
|
||||
|
||||
```
|
||||
[0] Spartacus: Gods Of The Arena - Disc 2
|
||||
[1] Spartacus <- hier war Schluss
|
||||
...
|
||||
[5] Spartacus: Gods Of The Arena <- der richtige, nie gefragt
|
||||
```
|
||||
|
||||
TMDb hat einen Film, der wörtlich „Spartacus" heißt → wörtlicher
|
||||
Treffer → **0,95** → fertig. Der Fix vom 30.08. (`Reihenfolge JE
|
||||
KANDIDAT`) griff nicht, weil er die Kandidaten-LISTE nicht anfasste; der
|
||||
damalige Test benutzte einen geschönten Titel (ohne Doppelpunkt, ohne
|
||||
`- Disc 2`) und lief deshalb grün.
|
||||
|
||||
### Die vier Ursachen (`38a3a87`, Ampel GRÜN, 225 Tests)
|
||||
|
||||
1. **Die Sicherheit maß die ART des Treffers, nie die QUALITÄT der
|
||||
Frage.** Neu: `abdeckung()` + `MINDEST_ABDECKUNG` (0,5) — wer mehr
|
||||
als die Hälfte des Titels wegwerfen musste, bekommt höchstens einen
|
||||
Vorschlag (0,60) und das UI fragt nach. Maßstab ist der Titel OHNE
|
||||
Verpackungs-Zusatz.
|
||||
2. **Verpackungs-Zusätze galten als Wortmüll.** Neu:
|
||||
`discZusatzAbtrennen()` trennt `- Disc 2` / `_D2` / `S1` ab UND merkt
|
||||
sie. Vier Fallen sind mit Tests abgesichert: `Rocky 2` (nackte Zahl),
|
||||
`Kill Bill Teil 2` (Teil/Part sind Titel-Wörter), `Ocean's 11`
|
||||
(Apostroph-Wortgrenze — die Ein-Buchstaben-Formen `d`/`s` verlangen
|
||||
deshalb einen Trenner davor), `Pulp Fiction`.
|
||||
3. **Der Titelvergleich war zeichengenau.** Ein Volume-Label KANN keinen
|
||||
Doppelpunkt tragen, TMDb schreibt ihn — der exakt richtige Treffer
|
||||
fiel an einem Satzzeichen durch. Neu: `gleichBedeutend()`. Vergleicht
|
||||
die Wort-FOLGE, nicht die Wort-MENGE (sonst wäre `New York, New York`
|
||||
gleich `New York`).
|
||||
4. **Rippy öffnete das Inhaltsverzeichnis der Disc und las nur die erste
|
||||
Zeile.** Neu: `inhaltAusBdmt()` liest auch `<di:titleName>`.
|
||||
`EP 1`/`EP 2` beweisen die Serie, `DUB GER 1`/`101 LOGO`/`104 GER FSK`
|
||||
nicht (`istFolgenName` ist bewusst eng). Ab zwei Folgen ist „Serie"
|
||||
BEWIESEN — dann sind die Film-Zweige der Kaskade AUS.
|
||||
|
||||
Disc-Nummer, Staffel und Folgenzahl stehen jetzt auch im Fenster
|
||||
(Kachel neben der Erkennung) und überleben eine eigene Titelwahl.
|
||||
|
||||
### Der Beweis an der echten Disc
|
||||
|
||||
Neu: `test/messung.disc-erkennung.test.ts` (nur Windows, nur auf
|
||||
Verlangen — `RIPPY_MESSUNG_DISC=G npx vitest run …`):
|
||||
|
||||
```
|
||||
Titel "Spartacus: Gods Of The Arena - Disc 2" (Quelle: bdmt)
|
||||
Disc 2 | Staffel — | Folgen 2 | Serie bewiesen: true
|
||||
Inhalt: EP 1 | EP 2 | MM ITA | ILSM | DUB GER 1 | DUB GER 2 | DUB FRE 1
|
||||
| DUB FRE 2 | DUB ITA | 101 LOGO | 102 DUTCH BREIN
|
||||
| 103 DUTCH AGE | 104 GER FSK | 105 WARNINGS
|
||||
Varianten: "…- Disc 2" -> "…Arena" -> "Spartacus Gods…" -> … -> "Spartacus"
|
||||
```
|
||||
|
||||
Deckt sich mit der Disc-Struktur: genau zwei große Ströme (`00047.m2ts`
|
||||
15,76 GB, `00048.m2ts` 14,89 GB), alles andere unter 200 MB.
|
||||
|
||||
**Geänderter Bestandstest:** Der alte Spartacus-Test erwartete 0,80. Er
|
||||
erwartet jetzt 0,90 — durch den satzzeichen-toleranten Vergleich ist es
|
||||
ein WÖRTLICHER Serientreffer. Die Absicht bleibt, die Prüfung wird
|
||||
strenger.
|
||||
|
||||
### Das Setup 5.1.1
|
||||
|
||||
* `rippy-windows/dist-setup/RippySetup-5.1.1.exe`
|
||||
* Größe **131 526 527 Bytes**
|
||||
* SHA-256 `04CB1D302616FB67DE217AE26D50B567350177CAE9801C3FDC6FE9EA04C26F88`
|
||||
* sha512 (in `latest.yml`) `GiUmrSDr/c/fTai0pJhZEr5dZuy1pUyApcFnGO1x9Kv1aKBLFXTZch8VU7rSp1wvOhZh687DhOklE4v5MIo/Pg==`
|
||||
* Paket-Smoke GRÜN: `fenster=geladen kern=pid,node:24.18.1 datenbank=ok
|
||||
leine=gesetzt pong=ok version=5.1.1`
|
||||
* `latest.yml` und `w0.yml` liegen daneben, beide auf 5.1.1
|
||||
|
||||
### Das Release ist DRAUSSEN und nachgemessen
|
||||
|
||||
Hochgeladen ins Release `aktuell` (ID 1, Ziel
|
||||
`worktree-windows-electron`), alle vier HTTP 201/204:
|
||||
`RippySetup-5.1.1.exe` + `.blockmap` neu, `latest.yml` und `w0.yml`
|
||||
gelöscht und ersetzt (Namenskollision zwingt zum Löschen zuerst).
|
||||
**Die 5.1.0-EXE liegt bewusst weiter im Release — sie ist die
|
||||
Rückfallebene**, und `latest.yml` verweist ohnehin namentlich.
|
||||
|
||||
Nachgemessen **anonym und von außen**, also genau so, wie eine
|
||||
installierte Rippy fragt:
|
||||
|
||||
```
|
||||
…/releases/download/aktuell/latest.yml HTTP 200, 337 B, version: 5.1.1
|
||||
…/releases/download/aktuell/RippySetup-5.1.1.exe
|
||||
HTTP 200, Content-Length 131 526 527
|
||||
(= die Zahl in latest.yml = die lokale Datei)
|
||||
…/releases/download/aktuell/…exe.blockmap HTTP 200, 137 490 B
|
||||
…/releases/download/aktuell/w0.yml HTTP 200, 337 B
|
||||
```
|
||||
|
||||
**§ 3.6 ⚠ ist beantwortet — mit NEIN:**
|
||||
`…/releases/latest/download/latest.yml` liefert **HTTP 404**. Gitea 1.27
|
||||
bedient das GitHub-Muster NICHT. Das feste Tag `aktuell` war also nicht
|
||||
die Rückfallebene, sondern der einzige Weg — und ist genau der, den die
|
||||
App über `electron-builder publish` eingebacken hat. Der Punkt kann aus
|
||||
den offenen Fragen gestrichen werden.
|
||||
|
||||
**Zugang:** Der Upload-Token liegt in `F:\Coding Stuff\Gittea Token\rippy.txt`
|
||||
(40 Hex, Recht `repository: Lesen und Schreiben`). Der über
|
||||
`git credential fill` erreichbare Zugang taugt NICHT für die API — das
|
||||
ist ein 13-stelliges Passwort, Gitea antwortet damit HTTP 401.
|
||||
|
||||
### ⚠ OFFEN: der Selbst-Update-Beweis am lebenden Objekt
|
||||
|
||||
Alles ist gemessen — außer dem einen Schritt, den nur die installierte
|
||||
App gehen kann: **Findet die installierte 5.1.0 das Update von selbst?**
|
||||
Sie prüft beim Start und einmal täglich, lädt im Hintergrund, NIE
|
||||
während eines Rips, und installiert beim nächsten Beenden
|
||||
(`autoInstallOnAppQuit`). Zu tun: Rippy einmal neu starten, kein Rip
|
||||
dabei, dann beenden und wieder starten — danach muss in den
|
||||
Einstellungen `5.1.1` stehen.
|
||||
|
||||
---
|
||||
|
||||
## ÜBERGABE an die nächste Session (31.08.2026, Sessionende)
|
||||
|
||||
> Diese Session ist abgeschlossen und ihr Arbeitsordner
|
||||
> (`.claude/worktrees/rippy-v5-windows-4e23db`) wird abgeräumt — ALLES
|
||||
> Wichtige liegt in Git, im Gitea-Release oder auf dem Desktop.
|
||||
|
||||
### Wo die nächste Session startet
|
||||
|
||||
* **Arbeitsort:** `F:\Coding Stuff\rippy` (Haupt-Checkout, Branch
|
||||
`worktree-windows-electron` MIT Upstream — `git pull` genügt).
|
||||
v5-Code in `rippy-windows/`; main ist reines Docker-Rippy.
|
||||
* **Bau-Vorbereitung einmalig** (der Session-Ordner samt node_modules,
|
||||
vendor/ und Bau-Caches ist weg): `npm install` in `rippy-windows/`
|
||||
(npm 11 blockt Electrons install.js — Ausweg in BAUEN.md), fürs
|
||||
PAKETIEREN `vendor/` aus `%LOCALAPPDATA%\Rippy\tools` befüllen
|
||||
(BAUEN.md § „Das Setup bauen").
|
||||
* **Neues Release veröffentlichen** = die VIER Dateien im Gitea-Tag
|
||||
`aktuell` ERSETZEN (exe, .blockmap, latest.yml, w0.yml) — die
|
||||
installierten Apps ziehen selbst nach. Schreiben an die Gitea-API:
|
||||
`git credential fill` (host git.tobisniceshomelab.ddnsfree.com)
|
||||
liefert Nutzer+Token.
|
||||
|
||||
### Was fertig und wo es ist
|
||||
|
||||
* **Rippy v5.1.0** komplett: W-0…W-7, drei Commander-Funde behoben,
|
||||
MakeMKV-Selbst-Update (§ 9), Kino-Mix-Design durchgängig,
|
||||
Einstellungen als Karten-Seite. 209 Tests, Ampel grün je Schritt.
|
||||
* **Setup:** `RippySetup-5.1.0.exe` liegt auf dem DESKTOP des
|
||||
Commanders UND im Gitea-Release `aktuell` (SHA-256 147D6C7E…C94F).
|
||||
* **Update-Kanal live und gemessen** (latest.yml anonym, sha512/size
|
||||
passen; w0.yml als Brücke für 5.0.0-w0-Altinstallationen).
|
||||
* **main bereinigt** (2b77ab0, −10.039 Zeilen Windows-Standalone) und
|
||||
auf die VM DEPLOYT (Container healthy, UI/API 200). Gitea hat nur
|
||||
noch `main` + `worktree-windows-electron`; fünf gemergte
|
||||
Alt-Branches gelöscht; lokaler Checkout synchron, ~545 MB Müll weg.
|
||||
|
||||
### Offene Punkte (Reihenfolge = Vorschlag)
|
||||
|
||||
1. **Erster echter Selbst-Update-Beweis:** 5.1.1 bauen (irgendeine
|
||||
kleine Änderung), die vier Dateien im Tag `aktuell` ersetzen — die
|
||||
installierte 5.1.0 muss es selbst finden, laden und beim nächsten
|
||||
Start installieren (§ 6.8-Livetest; misst auch, ob Gitea 1.27 das
|
||||
releases/latest/download-Muster bedient — § 3.6 ⚠).
|
||||
2. **Hand am Gerät:** voller Blu-ray-Durchlauf mit neuem UI (die Disc
|
||||
STECKT schon im Laufwerk, es ist wach), Auswurf-Messung
|
||||
(`messung.auswurf`), DVD- und Audio-CD-Durchlauf,
|
||||
Jellyfin-Sichtbarkeit.
|
||||
3. **MakeMKV-Beta-Key läuft Ende September ab** — Rippy prüft täglich
|
||||
und holt den neuen selbst; nur beobachten. Der Update-Knopf wird
|
||||
scharf, sobald der Hersteller eine Fassung > 1.18.4 ankündigt.
|
||||
4. UI-Feinschliff nach echter Nutzung (z. B. Bibliothek-Detailseite,
|
||||
Rip-Nachbearbeitung „Titel nachträglich ändern — Ablage wandert mit").
|
||||
|
||||
### Session-Ordner abräumen (nach dem Schließen dieser Session)
|
||||
|
||||
```
|
||||
git -C "F:/Coding Stuff/rippy" worktree remove --force ".claude/worktrees/rippy-v5-windows-4e23db"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DEPLOYT — Update-Kanal live, altes Windows-Rippy gelöscht (31.08.2026, nachts)
|
||||
|
||||
> Der Abschluss des Tages, alles gemessen:
|
||||
>
|
||||
> **1. Gitea-Release `aktuell` ist live** (Release-ID 1, Ziel
|
||||
> `worktree-windows-electron`): `RippySetup-5.1.0.exe` + `.blockmap` +
|
||||
> `latest.yml` + `w0.yml`, alle HTTP 201. Nachgemessen aus App-Sicht
|
||||
> (anonym, extern): `…/releases/download/aktuell/latest.yml` → 200 mit
|
||||
> `version: 5.1.0`, sha512 und `size: 131370618` — die EXE antwortet
|
||||
> per HEAD mit exakt dieser Content-Length. **`w0.yml` ist die Brücke
|
||||
> für die alten 5.0.0-w0-Installationen** (Vorab-Versionen suchen auf
|
||||
> dem w0-Kanal): gleiche 5.1.0-Daten, einmal mitziehen, danach läuft
|
||||
> alles über latest.yml.
|
||||
>
|
||||
> **2. Kein „unsichtbarer Nutzer", kein Token — mit Absicht:** Das
|
||||
> Repo ist ÖFFENTLICH lesbar (anonym HTTP 200, intern wie extern
|
||||
> gemessen). Die App fragt anonym nach Updates; ein Token im Code wäre
|
||||
> aus jeder installierten Rippy.exe auslesbar und damit ein Risiko
|
||||
> ohne Nutzen. Sollte das Repo je privat werden, ist der saubere Weg
|
||||
> ein eigener Lese-Nutzer + Token in der `updateUrl`-EINSTELLUNG (DB,
|
||||
> nie im Code/Repo) — notiert, nicht gebaut.
|
||||
>
|
||||
> **3. So updatet sich Rippy selbst** (§ 6.8, alles schon im Code):
|
||||
> Prüfung beim Start und täglich, NIE während eines Rips (nicht mal
|
||||
> der Download), Installation beim nächsten Beenden/Start
|
||||
> (`autoInstallOnAppQuit`). Die Standard-Adresse steckt via
|
||||
> electron-builder `publish` in der App; nur https:// wird akzeptiert.
|
||||
> Ab jetzt gilt: **neues Release = die vier Dateien im Tag `aktuell`
|
||||
> ERSETZEN**, fertig — jede installierte App zieht nach.
|
||||
>
|
||||
> **4. Altes Windows-Rippy GELÖSCHT:** `main` per Fast-Forward auf den
|
||||
> geprüften Aufräum-Stand `2b77ab0` (Ampel war grün; −10.039 Zeilen,
|
||||
> Teil A raus, Docker-Teil B unangetastet). Anschließend VM-Deploy
|
||||
> (`git pull --ff-only && docker compose up -d --build` auf Arcane) —
|
||||
> die VM hing zuvor auf `05ab655`.
|
||||
>
|
||||
> v5 selbst lebt weiter auf `worktree-windows-electron` (Spitze diese
|
||||
> Doku) — main bleibt reines Docker-Rippy (Entscheid 7/§ 11).
|
||||
|
||||
---
|
||||
|
||||
## Einstellungen neu + Version 5.1.0 (31.08.2026, nachts)
|
||||
|
||||
> Der Commander fand den Einstellungs-Tab „sehr mager" — jetzt ist er
|
||||
> eine erklärende Karten-Seite im Kino-Mix (`b64ddf5`, 209 Tests, Dev-
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
|
||||
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
|
||||
//
|
||||
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
|
||||
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
|
||||
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
|
||||
// electron-updater ihn per spawn(…, { detached: true }) startet und
|
||||
// `detached` unter Windows KEIN Breakaway setzt.
|
||||
//
|
||||
// Aufbau (drei Läufe, der äußere ruft die inneren):
|
||||
// node beweise/leine-breakaway.js
|
||||
// -> startet zweimal sich selbst als „innen":
|
||||
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
|
||||
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
|
||||
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
|
||||
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
|
||||
// welcher Enkel noch lebt.
|
||||
//
|
||||
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
|
||||
//
|
||||
// Ausführen: node beweise/leine-breakaway.js
|
||||
const { execFileSync, spawnSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
|
||||
|
||||
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||
const KILL_ON_JOB_CLOSE = 0x2000
|
||||
const BREAKAWAY_OK = 0x0800
|
||||
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
const CREATE_NO_WINDOW = 0x08000000
|
||||
const STRUKTUR_GROESSE = 144
|
||||
const LIMIT_FLAGS_OFFSET = 16
|
||||
const STARTUPINFO_GROESSE = 104
|
||||
const PROCESS_INFORMATION_GROESSE = 24
|
||||
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
|
||||
const SetInformationJobObject = kernel32.func(
|
||||
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
|
||||
)
|
||||
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
|
||||
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
const CreateProcessW = kernel32.func(
|
||||
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
|
||||
)
|
||||
|
||||
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
|
||||
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
|
||||
|
||||
function innen(jobErlaubtAusklinken, kindKlinktSichAus) {
|
||||
const job = CreateJobObjectW(null, null)
|
||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (jobErlaubtAusklinken ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
|
||||
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
|
||||
AssignProcessToJobObject(job, GetCurrentProcess())
|
||||
|
||||
const si = Buffer.alloc(STARTUPINFO_GROESSE)
|
||||
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
|
||||
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
|
||||
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
|
||||
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
|
||||
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
|
||||
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
|
||||
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
|
||||
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
|
||||
const flags = CREATE_NO_WINDOW | (kindKlinktSichAus ? CREATE_BREAKAWAY_FROM_JOB : 0)
|
||||
|
||||
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
|
||||
if (!ok) {
|
||||
console.log(`PID=0 FEHLER=${GetLastError()}`)
|
||||
return
|
||||
}
|
||||
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
|
||||
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
|
||||
}
|
||||
|
||||
function lebt(pid) {
|
||||
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
|
||||
return (ergebnis.stdout || '').includes(String(pid))
|
||||
}
|
||||
|
||||
function aussen() {
|
||||
const faelle = [
|
||||
['1) Leine wie bisher, Kind normal — die Ausgangslage', 'alt', false],
|
||||
// DIE Regressionsfrage: Weicht das blosse ERLAUBEN die Leine auf?
|
||||
// Wenn hier JA stuende, waere Paragraph 3.3 kaputt und makemkvcon
|
||||
// koennte als Waise das Laufwerk festhalten (der rc10-Fund).
|
||||
['2) Leine erlaubt Ausklinken, Kind normal — MUSS weiter sterben', 'erlaubt-normal', false],
|
||||
['3) Leine erlaubt Ausklinken, Kind klinkt sich aus — der Installer', 'erlaubt-aus', true],
|
||||
]
|
||||
for (const [name, modus, erwartetLebend] of faelle) {
|
||||
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
|
||||
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
|
||||
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
|
||||
// zugeschnappt, falls sie zuschnappt.
|
||||
const nochDa = pid > 0 ? lebt(pid) : false
|
||||
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
|
||||
console.log(
|
||||
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
|
||||
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
|
||||
)
|
||||
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
|
||||
}
|
||||
}
|
||||
|
||||
const modus = process.argv[2] ?? ''
|
||||
if (modus === 'alt') innen(false, false)
|
||||
else if (modus === 'erlaubt-normal') innen(true, false)
|
||||
else if (modus === 'erlaubt-aus') innen(true, true)
|
||||
else aussen()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 319 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
@@ -0,0 +1,20 @@
|
||||
Version 5.3.2
|
||||
|
||||
Die Titelliste bleibt jetzt stehen.
|
||||
In 5.3.1 kam die Auswahl korrekt — und Minuten spaeter legte sich ein
|
||||
"keine Antwort nach 300 s" darueber. Der Grund: Ein aelterer, laengst
|
||||
ueberholter Lese-Vorgang meldete verspaetet seinen Abbruch. Jetzt gilt je
|
||||
Laufwerk nur der NEUESTE Lauf; ein abgeloester gibt das Laufwerk sofort
|
||||
frei und schweigt. Nebenbei bremsen sich damit nicht mehr zwei Lesevorgaenge
|
||||
auf derselben Disc gegenseitig aus.
|
||||
|
||||
Und wenn es wirklich zu lange dauert, steht da jetzt ein Satz statt einer
|
||||
Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
|
||||
Minuten brauchen — Rippy beendet den Versuch dann und gibt das Laufwerk frei.
|
||||
|
||||
Aus 5.3.1: Die Titel-Auswahl funktioniert (der Kern verwarf die Anfrage
|
||||
vorher still), und Rippy nennt die Ursache, wenn MakeMKV kein Laufwerk findet.
|
||||
Aus 5.3.0: Serien unter Serien/<Titel>/Season NN, Film-Extras im Unterordner.
|
||||
Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter.
|
||||
|
||||
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
|
||||
Generated
+68
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"version": "5.0.0-w0",
|
||||
"version": "5.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rippy",
|
||||
"version": "5.0.0-w0",
|
||||
"version": "5.3.2",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
@@ -2229,6 +2229,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rippy",
|
||||
"productName": "Rippy",
|
||||
"version": "5.1.0",
|
||||
"version": "5.3.2",
|
||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||
"main": "out/haupt/index.js",
|
||||
"author": "KrBrZ",
|
||||
|
||||
@@ -56,4 +56,17 @@ contextBridge.exposeInMainWorld('rippy', {
|
||||
autostartSetzen(an: boolean): Promise<boolean> {
|
||||
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
|
||||
},
|
||||
/** Eigene Titelleiste (§ 4.2): minimieren | maximieren | schliessen.
|
||||
* „schliessen" versteckt nur — Rippy lebt im Tray weiter. */
|
||||
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean> {
|
||||
return ipcRenderer.invoke('fenster-steuern', String(was)) as Promise<boolean>
|
||||
},
|
||||
/** „Jetzt prüfen" (§ 6.8) — das Ergebnis kommt über den Haupt-Status. */
|
||||
updatePruefen(): Promise<unknown> {
|
||||
return ipcRenderer.invoke('update-pruefen') as Promise<unknown>
|
||||
},
|
||||
/** „Jetzt neu starten und installieren" — antwortet mit Grund, wenn nicht. */
|
||||
updateInstallieren(): Promise<{ ok: boolean; grund: string }> {
|
||||
return ipcRenderer.invoke('update-installieren') as Promise<{ ok: boolean; grund: string }>
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,8 +7,16 @@
|
||||
// Erst-Einrichtung, Dialoge). Die Regeln bleiben: EINE Einstellungs-Quelle
|
||||
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4
|
||||
// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte).
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
DiscInfoStand,
|
||||
DiscTitelVorschlag,
|
||||
GeraeteInfo,
|
||||
HauptStatus,
|
||||
RipStatus,
|
||||
TitelRolle,
|
||||
TitelStand,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
import {
|
||||
aufVerbindung,
|
||||
@@ -18,7 +26,9 @@ import {
|
||||
metadatenSuchen,
|
||||
ripAbbrechen,
|
||||
ripStarten,
|
||||
ripUeberspringen,
|
||||
schluesselPruefen,
|
||||
titelLesen,
|
||||
verbindungStarten,
|
||||
zuordnungSetzen,
|
||||
type Verbindung,
|
||||
@@ -83,6 +93,60 @@ const PHASEN_MONO: Record<RipStatus['phase'], string> = {
|
||||
|
||||
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
||||
|
||||
// ── Eigene Fensterknöpfe (§ 4.2, seit 5.1.3) ────────────────────────────
|
||||
// Bewusst schlicht und in Windows-Maßen (46 × 32) — wer sie sucht, findet
|
||||
// sie dort, wo er sie erwartet. Sie sitzen in der Ziehfläche und müssen
|
||||
// deshalb `nicht-ziehbar` sein, sonst verschluckt der Fenstergriff den Klick.
|
||||
const IconMinimieren = (): React.JSX.Element => (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<path d="M0 5h10" stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const IconMaximieren = (): React.JSX.Element => (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<rect x="0.6" y="0.6" width="8.8" height="8.8" stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const IconWiederherstellen = (): React.JSX.Element => (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<rect x="0.6" y="2.6" width="6.8" height="6.8" stroke="currentColor" strokeWidth="1.1" />
|
||||
<path d="M2.7 2.6V0.6h6.7v6.7h-2" stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const IconSchliessen = (): React.JSX.Element => (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||
<path d="M0.6 0.6l8.8 8.8M9.4 0.6l-8.8 8.8" stroke="currentColor" strokeWidth="1.1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
function FensterKnopf({
|
||||
titel,
|
||||
aufKlick,
|
||||
rot = false,
|
||||
children,
|
||||
}: {
|
||||
titel: string
|
||||
aufKlick: () => void
|
||||
rot?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={aufKlick}
|
||||
title={titel}
|
||||
aria-label={titel}
|
||||
className={`flex h-8 w-[46px] items-center justify-center text-dunst transition-colors ${
|
||||
rot ? 'hover:bg-rot hover:text-white' : 'hover:bg-white/10 hover:text-schnee'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function sicherheitsWort(confidence: number): string {
|
||||
if (confidence >= 0.9) return 'sehr wahrscheinlich'
|
||||
if (confidence >= 0.8) return 'wahrscheinlich'
|
||||
@@ -90,6 +154,18 @@ function sicherheitsWort(confidence: number): string {
|
||||
return 'unsicher'
|
||||
}
|
||||
|
||||
/** 'Staffel 1 · Disc 2 · 2 Folgen' — was die Disc über ihre Lage in der
|
||||
* Ausgabe sagt. Die Folgenzahl stammt aus ihrem eigenen
|
||||
* Inhaltsverzeichnis, ist also gemessen und nicht geraten. '' wenn die
|
||||
* Disc nichts davon verrät. */
|
||||
function discHerkunft(info: DiscInfoStand): string {
|
||||
const teile: string[] = []
|
||||
if (info.staffel !== null) teile.push(`Staffel ${info.staffel}`)
|
||||
if (info.discNr !== null) teile.push(`Disc ${info.discNr}`)
|
||||
if (info.folgen > 0) teile.push(info.folgen === 1 ? '1 Folge' : `${info.folgen} Folgen`)
|
||||
return teile.join(' · ')
|
||||
}
|
||||
|
||||
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
|
||||
* (eigene Wahl → allgemeines Preset → Empfehlung → Default). */
|
||||
function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
|
||||
@@ -196,6 +272,35 @@ function SegmentBalken({ prozent }: { prozent: number }) {
|
||||
}
|
||||
|
||||
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
|
||||
|
||||
/** Nur ein Klick, der WIRKLICH auf der Hintergrundfläche begann, schließt
|
||||
* einen Dialog.
|
||||
*
|
||||
* Commander-Fund (01.09.2026): „es gibt einen kleinen bug im UI wenn man
|
||||
* selbst den titel sucht das suchfenster schließt sich dann einfach."
|
||||
* Der Grund ist eine bekannte Falle: Wer im Eingabefeld Text markiert und
|
||||
* dabei über den Dialogrand hinauszieht, drückt INNEN und lässt AUSSEN
|
||||
* los. Der Browser meldet als Ziel des Klicks dann den gemeinsamen
|
||||
* Vorfahren — die Hintergrundfläche. Ein reiner onClick-Horcher darauf
|
||||
* schließt den Dialog also mitten im Tippen.
|
||||
*
|
||||
* Deshalb wird der Anfang gemerkt: Nur wenn Drücken UND Loslassen auf der
|
||||
* Fläche selbst passierten, ist es ein Klick daneben. */
|
||||
function useHintergrundKlick(schliessen: () => void): {
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
} {
|
||||
const begannDraussen = useRef(false)
|
||||
return {
|
||||
onMouseDown: (e) => {
|
||||
begannDraussen.current = e.target === e.currentTarget
|
||||
},
|
||||
onClick: (e) => {
|
||||
if (begannDraussen.current && e.target === e.currentTarget) schliessen()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function AendernDialog({
|
||||
id,
|
||||
startText,
|
||||
@@ -208,6 +313,7 @@ function AendernDialog({
|
||||
schliessen: () => void
|
||||
}) {
|
||||
const [text, setText] = useState(startText)
|
||||
const hintergrund = useHintergrundKlick(schliessen)
|
||||
useEffect(() => {
|
||||
metadatenSuchen(id, startText)
|
||||
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
|
||||
@@ -216,7 +322,7 @@ function AendernDialog({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
|
||||
onClick={schliessen}
|
||||
{...hintergrund}
|
||||
>
|
||||
<div
|
||||
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60"
|
||||
@@ -286,6 +392,186 @@ function AendernDialog({
|
||||
|
||||
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
|
||||
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
|
||||
|
||||
// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ───────────────────────────
|
||||
// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und
|
||||
// Alterskennzeichen inklusive. Der Info-Lauf dauert 20–120 s, deshalb
|
||||
// beginnt er erst hier und nicht schon beim Einlegen.
|
||||
function dauerText(s: number): string {
|
||||
const m = Math.floor(s / 60)
|
||||
return m >= 60
|
||||
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
|
||||
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
|
||||
}
|
||||
|
||||
function TitelWahlDialog({
|
||||
id,
|
||||
stand,
|
||||
schliessen,
|
||||
}: {
|
||||
id: string
|
||||
stand: TitelStand | undefined
|
||||
schliessen: () => void
|
||||
}) {
|
||||
const hintergrund = useHintergrundKlick(schliessen)
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
|
||||
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
|
||||
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
|
||||
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
|
||||
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
|
||||
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
|
||||
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
|
||||
const umschalten = (nr: number): void => {
|
||||
const neu = new Set(aktiv)
|
||||
if (neu.has(nr)) neu.delete(nr)
|
||||
else neu.add(nr)
|
||||
setGewaehlt(neu)
|
||||
}
|
||||
const alle = stand?.titel ?? []
|
||||
const gesamtBytes = alle.filter((t) => aktiv.has(t.nr)).reduce((s, t) => s + t.groesseBytes, 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
|
||||
{...hintergrund}
|
||||
>
|
||||
<div
|
||||
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
||||
|
||||
{stand === undefined || stand.laeuft ? (
|
||||
<p className="mt-4 font-mono text-xs text-nebel">
|
||||
Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
|
||||
</p>
|
||||
) : stand.fehler.length > 0 ? (
|
||||
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
|
||||
{stand.unsicher ? '⚠ ' : ''}
|
||||
{stand.einordnung}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
|
||||
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
|
||||
>
|
||||
Rippys Vorschlag
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setGewaehlt(
|
||||
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
|
||||
// Sammeltitel noch Logos/Warnungen.
|
||||
new Set(
|
||||
alle
|
||||
.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz'))
|
||||
.map((t) => t.nr),
|
||||
),
|
||||
)
|
||||
}
|
||||
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||||
>
|
||||
Hauptinhalt + Extras
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))}
|
||||
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||||
>
|
||||
Alles
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGewaehlt(new Set())}
|
||||
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||||
>
|
||||
Nichts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{alle.map((t) => (
|
||||
<label
|
||||
key={t.nr}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-2 ${
|
||||
aktiv.has(t.nr) ? 'border-gold/40 bg-gold/8' : 'border-white/8 bg-samt/40'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aktiv.has(t.nr)}
|
||||
onChange={() => umschalten(t.nr)}
|
||||
className="accent-[oklch(0.83_0.11_75)]"
|
||||
/>
|
||||
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
|
||||
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
|
||||
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
|
||||
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">
|
||||
{t.kapitel} Kap.
|
||||
</span>
|
||||
{aktiv.has(t.nr) ? (
|
||||
<select
|
||||
value={rolleVon(t)}
|
||||
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-none rounded-lg border border-white/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
|
||||
title="Bestimmt, WOHIN der Titel abgelegt wird"
|
||||
>
|
||||
<option value="hauptfilm">Hauptfilm</option>
|
||||
<option value="extra">Extra</option>
|
||||
<option value="folge">Folge</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{alle.length === 0 && (
|
||||
<p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 font-mono text-[11px] text-nebel">
|
||||
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-nebel">
|
||||
{alle.some((t) => aktiv.has(t.nr) && rolleVon(t) === 'folge')
|
||||
? 'Folgen landen unter Serien/… /Season NN. Die Episoden-Nummern setzt Rippy nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
|
||||
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-between gap-2">
|
||||
<button
|
||||
onClick={schliessen}
|
||||
className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
ripStarten(
|
||||
id,
|
||||
alle
|
||||
.filter((t) => aktiv.has(t.nr))
|
||||
.map((t) => ({ nr: t.nr, rolle: rolleVon(t), dauerS: t.dauerS })),
|
||||
)
|
||||
schliessen()
|
||||
}}
|
||||
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft}
|
||||
className="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)] hover:brightness-105 disabled:opacity-35"
|
||||
>
|
||||
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LaufwerkKachel({
|
||||
geraet,
|
||||
rip,
|
||||
@@ -294,6 +580,7 @@ function LaufwerkKachel({
|
||||
ripStartMs,
|
||||
protokoll,
|
||||
verbindung,
|
||||
titelStand,
|
||||
}: {
|
||||
geraet: GeraeteInfo
|
||||
rip: RipStatus | undefined
|
||||
@@ -302,8 +589,10 @@ function LaufwerkKachel({
|
||||
ripStartMs: number | undefined
|
||||
protokoll: string[]
|
||||
verbindung: Verbindung
|
||||
titelStand: TitelStand | undefined
|
||||
}) {
|
||||
const [aendernOffen, setAendernOffen] = useState(false)
|
||||
const [titelOffen, setTitelOffen] = useState(false)
|
||||
const [protokollOffen, setProtokollOffen] = useState(false)
|
||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||
const discDa = geraet.status === 'ready'
|
||||
@@ -350,10 +639,14 @@ function LaufwerkKachel({
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => ripStarten(geraet.id)}
|
||||
onClick={() => {
|
||||
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
|
||||
setTitelOffen(true)
|
||||
titelLesen(geraet.id)
|
||||
}}
|
||||
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"
|
||||
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
|
||||
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
|
||||
>
|
||||
<IconPlay />
|
||||
Rippen
|
||||
@@ -415,7 +708,9 @@ function LaufwerkKachel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid max-w-2xl grid-cols-4 gap-2.5">
|
||||
{/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im
|
||||
breiten — vorher immer vier, also im schmalen gequetscht. */}
|
||||
<div className="mt-3 grid max-w-3xl grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
|
||||
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
|
||||
<InfoKachel
|
||||
@@ -446,6 +741,9 @@ function LaufwerkKachel({
|
||||
ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'}
|
||||
/>
|
||||
)}
|
||||
{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" />
|
||||
)}
|
||||
@@ -479,6 +777,41 @@ function LaufwerkKachel({
|
||||
)}
|
||||
</div>
|
||||
{ripLaeuft && <SegmentBalken prozent={rip.prozent} />}
|
||||
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
|
||||
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
|
||||
Titel {rip.titelNr} von {rip.titelGesamt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
|
||||
wieder ist kein Fortschritt, sondern ein physischer
|
||||
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
|
||||
waehrend identische Zeilen durchs Protokoll rauschten. */}
|
||||
{rip.haengt != null && (
|
||||
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
|
||||
<p className="font-mono text-[11px] font-semibold text-warn">
|
||||
Die Disc hat an einer Stelle einen Defekt.
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
|
||||
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an
|
||||
derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht besser — der Rest der
|
||||
Disc ist davon nicht betroffen.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
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"
|
||||
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
|
||||
>
|
||||
Titel ueberspringen
|
||||
</button>
|
||||
<span className="font-mono text-[10.5px] text-nebel">
|
||||
oder weiter versuchen — abbrechen geht oben.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
|
||||
> {rip.text}
|
||||
</p>
|
||||
@@ -516,6 +849,10 @@ function LaufwerkKachel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{titelOffen && (
|
||||
<TitelWahlDialog id={geraet.id} stand={titelStand} schliessen={() => setTitelOffen(false)} />
|
||||
)}
|
||||
|
||||
{aendernOffen && (
|
||||
<AendernDialog
|
||||
id={geraet.id}
|
||||
@@ -767,6 +1104,9 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
const s = verbindung.schluessel
|
||||
const b = verbindung.beschaffung
|
||||
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
|
||||
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
|
||||
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
|
||||
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||
const [aktiv, setAktiv] = useState('ablage')
|
||||
|
||||
const SEKTIONEN: Array<[string, string]> = [
|
||||
@@ -789,9 +1129,12 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
if (ordner !== null) setzen('ablage', ordner)
|
||||
}
|
||||
|
||||
// Schmales Fenster: die Sektionswahl legt sich als Zeile über die Karten.
|
||||
// Ab lg steht sie wieder als mitlaufende Spalte daneben (Commander-Fund
|
||||
// 01.09.2026: „alles gequetscht").
|
||||
return (
|
||||
<div className="flex gap-8">
|
||||
<nav className="sticky top-4 flex h-fit w-44 flex-none flex-col gap-1">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
|
||||
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-4 lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
|
||||
{SEKTIONEN.map(([id, name]) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -807,7 +1150,9 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 max-w-2xl flex-1 space-y-5">
|
||||
{/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem
|
||||
breiten Fenster alles links klebte und rechts nichts stand. */}
|
||||
<div className="min-w-0 flex-1 space-y-5">
|
||||
<SektionsKarte
|
||||
id="ablage"
|
||||
titel="ABLAGE"
|
||||
@@ -1000,12 +1345,12 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
aufWechsel={(an) => void window.rippy.autostartSetzen(an)}
|
||||
label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)"
|
||||
/>
|
||||
<Feld
|
||||
name="Update-Adresse"
|
||||
hinweis="leer = Standard; NUR https:// wird angenommen"
|
||||
wert={werte['updateUrl'] ?? ''}
|
||||
aufAendern={(w) => setzen('updateUrl', w)}
|
||||
/>
|
||||
{/* Das Feld „Update-Adresse" ist am 01.09.2026 auf Zuruf des
|
||||
Commanders verschwunden: Updates kommen immer vom Gitea, ein
|
||||
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
|
||||
`updateUrl` gilt weiterhin — wer ihn wirklich braucht, setzt
|
||||
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
|
||||
https://). */}
|
||||
{haupt !== null && (
|
||||
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
<p>Selbst-Update: {haupt.update.text}</p>
|
||||
@@ -1017,6 +1362,25 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
||||
{verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`}
|
||||
</p>
|
||||
<p className="text-nebel/70">Updates warten, solange ein Rip läuft — nie mitten im Vorgang.</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => void window.rippy.updatePruefen()}
|
||||
disabled={ripLaeuftGerade || haupt.update.laeuft}
|
||||
className="rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-[11px] text-dunst hover:text-schnee disabled:opacity-35"
|
||||
title={
|
||||
ripLaeuftGerade
|
||||
? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).'
|
||||
: 'Fragt sofort beim Update-Server nach.'
|
||||
}
|
||||
>
|
||||
{haupt.update.laeuft ? 'sucht …' : 'Jetzt auf Updates prüfen'}
|
||||
</button>
|
||||
{haupt.update.neueVersion.length > 0 && (
|
||||
<span className="text-gold-hell">
|
||||
Version {haupt.update.neueVersion} gefunden — siehe Übersicht.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SektionsKarte>
|
||||
@@ -1163,10 +1527,24 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
||||
},
|
||||
]
|
||||
|
||||
const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Automatik', 'Fertig']
|
||||
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
|
||||
// Preset-Empfehlung. Stand bisher nur in den Einstellungen und war
|
||||
// deshalb unsichtbar (Commander-Fund 01.09.2026: „die alte Version konnte
|
||||
// automatisch anhand der gescannten hardware encoder das beste preset
|
||||
// wählen"). Sie konnte es, diese auch — man sah es nur nirgends.
|
||||
const backends = werkzeuge?.backends ?? []
|
||||
const presets = werkzeuge?.presets ?? []
|
||||
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
|
||||
const EMPFEHLUNGEN: ReadonlyArray<readonly [string, string]> = [
|
||||
['DVD', 'dvd'],
|
||||
['Blu-ray', 'bluray'],
|
||||
['4K-UHD', 'uhd'],
|
||||
]
|
||||
|
||||
const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Fertig']
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-7 pt-6">
|
||||
<div className="mx-auto max-w-2xl space-y-7 pt-6">
|
||||
<div className="flex gap-2">
|
||||
{schritte.map((name, i) => (
|
||||
<span
|
||||
@@ -1184,7 +1562,10 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
||||
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.</p>
|
||||
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und sagt Bescheid.</p>
|
||||
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
|
||||
<p>Dieses Setup stellt vier Dinge ein und dauert eine Minute.</p>
|
||||
<p>
|
||||
Rippy prüft gleich seine Werkzeuge und fragt dich danach drei Dinge: wohin, wie schnell, und
|
||||
woher die Titel kommen. Dauert eine Minute — ändern kannst du alles später.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1204,7 +1585,16 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
||||
{schritt === 2 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder?</p>
|
||||
<p className="break-all font-mono text-xs text-nebel">{verbindung.einstellungen['ablage'] ?? ''}</p>
|
||||
{(verbindung.einstellungen['ablage'] ?? '').length > 0 ? (
|
||||
<p className="break-all font-mono text-xs text-gruen">{verbindung.einstellungen['ablage']}</p>
|
||||
) : (
|
||||
// Vorher stand hier eine LEERE Zeile — man sah nicht, wohin
|
||||
// Rippy legt, wenn man nichts wählt. Es gibt aber eine Vorgabe
|
||||
// (kern/index.ts: Videos\Rippy im Benutzerprofil).
|
||||
<p className="font-mono text-xs text-nebel">
|
||||
noch nichts gewählt — dann nimmt Rippy „Videos\Rippy" in deinem Benutzerordner
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
void window.rippy.ordnerWaehlen('Ablage-Ordner wählen').then((ordner) => {
|
||||
@@ -1215,31 +1605,98 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
||||
>
|
||||
Ordner wählen … (auch Netzlaufwerke)
|
||||
</button>
|
||||
<p className="text-xs text-nebel">
|
||||
Rippy baut darin selbst die Struktur: Filme, Musik, ISO und roh. Ein Netzlaufwerk geht auch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 3 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-dunst">Wie viel soll Rippy allein entscheiden?</p>
|
||||
<label className="flex items-center gap-2 text-xs text-dunst">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={verbindung.einstellungen['transcodeEnabled'] !== 'false'}
|
||||
onChange={(e) => einstellungSetzen('transcodeEnabled', e.target.checked ? 'true' : 'false')}
|
||||
className="accent-[oklch(0.83_0.11_75)]"
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-dunst">
|
||||
Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung
|
||||
berechnen — die kostet Zeit, spart aber viel Platz.
|
||||
</p>
|
||||
<Schalter
|
||||
an={verbindung.einstellungen['transcodeEnabled'] !== 'false'}
|
||||
aufWechsel={(an) => einstellungSetzen('transcodeEnabled', an ? 'true' : 'false')}
|
||||
label="Nach dem Rip automatisch komprimieren"
|
||||
/>
|
||||
Nach dem Rip automatisch komprimieren (je Disc-Typ später fein einstellbar)
|
||||
</label>
|
||||
|
||||
{/* Die Hardware-Empfehlung — gemessen, nicht geraten. */}
|
||||
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
|
||||
{werkzeuge === null ? (
|
||||
<p className="font-mono text-xs text-nebel">Encoder werden gemessen …</p>
|
||||
) : backends.length === 0 ? (
|
||||
<p className="font-mono text-xs text-warn">
|
||||
Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] tracking-wider text-nebel">
|
||||
AUF DIESER MASCHINE GEMESSEN:
|
||||
</span>
|
||||
{backends.map((backend) => (
|
||||
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-dunst">
|
||||
Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt:
|
||||
</p>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{EMPFEHLUNGEN.map(([name, typ]) => (
|
||||
<div key={typ} className="rounded-lg border border-white/8 bg-buehne/60 p-2">
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{nurCpu && (
|
||||
<p className="mt-2 text-xs text-warn">
|
||||
Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der
|
||||
verlustfreie Rip ist davon nicht betroffen.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-nebel">
|
||||
Alles Weitere — Presets je Disc-Typ, Sprachen, Medienserver, API-Schlüssel — steht unter
|
||||
„Einstellungen".
|
||||
Du musst hier nichts wählen. Unter „Einstellungen → Kompression" kannst du je Disc-Typ ein
|
||||
eigenes Preset setzen — deine Wahl schlägt die Empfehlung immer.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 4 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-dunst">
|
||||
Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen
|
||||
kostenlosen TMDb-Schlüssel.
|
||||
</p>
|
||||
<Feld
|
||||
name="TMDb-API-Key"
|
||||
hinweis="kostenlos auf themoviedb.org → Einstellungen → API; in zwei Minuten geholt"
|
||||
wert={verbindung.einstellungen['tmdbApiKey'] ?? ''}
|
||||
aufAendern={(w) => einstellungSetzen('tmdbApiKey', w)}
|
||||
/>
|
||||
<p className="text-xs text-nebel">
|
||||
Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann so, wie die Disc sich selbst
|
||||
nennt, und es gibt keine Poster. Nachtragen geht jederzeit unter „Einstellungen →
|
||||
Metadaten".
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schritt === 5 && (
|
||||
<div className="space-y-3 text-sm text-dunst">
|
||||
<p>Das war's. Disc einlegen — Rippy meldet sich.</p>
|
||||
<p className="text-xs text-nebel">
|
||||
Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über
|
||||
das Tray-Menü.
|
||||
</p>
|
||||
{makemkvFehlt && (
|
||||
<p className="text-xs text-rot">
|
||||
Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt
|
||||
@@ -1281,6 +1738,9 @@ type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek'
|
||||
|
||||
export default function App() {
|
||||
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
|
||||
// Sagt ein Update-Knopf NEIN, steht der Grund daneben (R4) — ein Knopf,
|
||||
// der nichts tut und nichts sagt, ist schlimmer als kein Knopf.
|
||||
const [updateHinweis, setUpdateHinweis] = useState('')
|
||||
// #einstellungen / #bibliothek als Startanker — der Smoke-Beweis
|
||||
// fotografiert damit auch die anderen Bereiche.
|
||||
const [bereich, setBereich] = useState<Bereich>(() => {
|
||||
@@ -1294,6 +1754,7 @@ export default function App() {
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
discTitel: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
@@ -1325,8 +1786,13 @@ export default function App() {
|
||||
|
||||
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
|
||||
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
|
||||
//
|
||||
// `#einrichtung` erzwingt sie (RIPPY_START_BEREICH) — wie #einstellungen
|
||||
// und #bibliothek. Ohne diesen Anker wäre der Wizard nach dem ersten
|
||||
// Start nicht mehr zu fotografieren und damit nicht mehr zu prüfen.
|
||||
const setupOffen =
|
||||
Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true'
|
||||
window.location.hash === '#einrichtung' ||
|
||||
(Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true')
|
||||
|
||||
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
|
||||
<button
|
||||
@@ -1355,30 +1821,53 @@ export default function App() {
|
||||
<div className="buehnenlicht pointer-events-none fixed inset-0" />
|
||||
<div className="filmkorn pointer-events-none fixed inset-0" />
|
||||
|
||||
<header className="relative border-b border-white/8 px-7 py-3.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<IconDisc groesse={30} farbe="oklch(0.83 0.11 75)" />
|
||||
<h1 className="font-display text-[25px] font-bold tracking-tight">Rippy</h1>
|
||||
<span className="rounded-md border border-white/12 px-2 py-0.5 font-mono text-[10px] text-nebel">
|
||||
{/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des
|
||||
rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt
|
||||
`nicht-ziehbar`, sonst schluckt der Griff den Klick. */}
|
||||
<header className="ziehbar relative flex-none border-b border-white/8">
|
||||
<div className="mx-auto flex w-full max-w-[1600px] items-center gap-3 py-2 pl-5 pr-0 sm:pl-7">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<IconDisc groesse={28} farbe="oklch(0.83 0.11 75)" />
|
||||
<h1 className="font-display text-[22px] font-bold tracking-tight sm:text-[25px]">Rippy</h1>
|
||||
<span className="hidden rounded-md border border-white/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">
|
||||
v{haupt?.version ?? '…'}
|
||||
</span>
|
||||
{einRipLaeuft && (
|
||||
<span className="inline-flex items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
|
||||
<span className="inline-flex flex-none items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
|
||||
REC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex gap-1.5">
|
||||
<nav className="nicht-ziehbar ml-auto flex flex-wrap justify-end gap-1.5">
|
||||
{knopf('uebersicht', 'Übersicht')}
|
||||
{knopf('einstellungen', 'Einstellungen')}
|
||||
{knopf('bibliothek', `Bibliothek${verbindung.bibliothek.length > 0 ? ` (${verbindung.bibliothek.length})` : ''}`)}
|
||||
</nav>
|
||||
<div className="nicht-ziehbar ml-1 flex flex-none items-center self-start">
|
||||
<FensterKnopf titel="Minimieren" aufKlick={() => void window.rippy.fensterSteuern('minimieren')}>
|
||||
<IconMinimieren />
|
||||
</FensterKnopf>
|
||||
<FensterKnopf
|
||||
titel={haupt?.fensterMaximiert === true ? 'Wiederherstellen' : 'Maximieren'}
|
||||
aufKlick={() => void window.rippy.fensterSteuern('maximieren')}
|
||||
>
|
||||
{haupt?.fensterMaximiert === true ? <IconWiederherstellen /> : <IconMaximieren />}
|
||||
</FensterKnopf>
|
||||
{/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein
|
||||
laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */}
|
||||
<FensterKnopf
|
||||
titel="In den Infobereich (Rippy läuft weiter)"
|
||||
rot
|
||||
aufKlick={() => void window.rippy.fensterSteuern('schliessen')}
|
||||
>
|
||||
<IconSchliessen />
|
||||
</FensterKnopf>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative flex-1 space-y-7 p-7">
|
||||
<main className="relative mx-auto w-full max-w-[1600px] flex-1 space-y-6 px-5 py-6 sm:px-7 sm:py-7">
|
||||
{setupOffen && <ErstEinrichtung verbindung={verbindung} />}
|
||||
{!setupOffen && bereich === 'uebersicht' && (
|
||||
<>
|
||||
@@ -1392,6 +1881,62 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* § 6.8: Ein verfügbares Update ist eine SICHTBARE Karte, kein
|
||||
Satz in den Einstellungen. Der Commander musste es am
|
||||
01.09.2026 suchen — gefunden hat er es nicht. */}
|
||||
{haupt !== null && haupt.update.neueVersion.length > 0 && (
|
||||
<div className="rounded-2xl border border-gold/40 bg-gold/10 p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="font-display text-lg text-gold-hell">
|
||||
Update verfügbar — Version {haupt.update.neueVersion}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-nebel">du hast {haupt.version}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
|
||||
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gold transition-all"
|
||||
style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{haupt.update.neuerungen.length > 0 && (
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/60 p-3">
|
||||
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
|
||||
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">
|
||||
{haupt.update.neuerungen}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{haupt.update.bereit && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setUpdateHinweis('')
|
||||
void window.rippy.updateInstallieren().then((urteil) => {
|
||||
if (!urteil.ok) setUpdateHinweis(urteil.grund)
|
||||
})
|
||||
}}
|
||||
disabled={einRipLaeuft}
|
||||
className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/25 disabled:opacity-35"
|
||||
title={
|
||||
einRipLaeuft
|
||||
? 'Erst wenn der Rip fertig ist — Rippy startet dafür neu.'
|
||||
: 'Rippy beendet sich, installiert und startet wieder.'
|
||||
}
|
||||
>
|
||||
Jetzt neu starten und installieren
|
||||
</button>
|
||||
<span className="font-mono text-[11px] text-nebel">
|
||||
… oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{updateHinweis.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{updateHinweis}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
{verbindung.laufwerke.length === 0 ? (
|
||||
<div className="flex items-center gap-5 rounded-2xl border border-white/10 bg-karte/50 p-6">
|
||||
@@ -1412,6 +1957,7 @@ export default function App() {
|
||||
ripStartMs={verbindung.ripStart[geraet.id]}
|
||||
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
|
||||
verbindung={verbindung}
|
||||
titelStand={verbindung.discTitel[geraet.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+3
@@ -9,6 +9,9 @@ declare global {
|
||||
melden(art: string): void
|
||||
ordnerWaehlen(titel: string): Promise<string | null>
|
||||
autostartSetzen(an: boolean): Promise<boolean>
|
||||
updatePruefen(): Promise<unknown>
|
||||
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
|
||||
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type MetaVorschlag,
|
||||
type TitelStand,
|
||||
type RipAuftragTitel,
|
||||
type RipStatus,
|
||||
type WerkzeugAuskunft,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
@@ -30,6 +32,8 @@ export interface Verbindung {
|
||||
discInfos: Record<string, DiscInfoStand>
|
||||
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||||
vorschlaege: Record<string, MetaVorschlag[]>
|
||||
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
|
||||
discTitel: Record<string, TitelStand>
|
||||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||
ripStart: Record<string, number>
|
||||
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||
@@ -65,6 +69,7 @@ const stand: Verbindung = {
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
discTitel: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
@@ -140,6 +145,10 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'disc-titel':
|
||||
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
|
||||
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
|
||||
break
|
||||
case 'metadaten-vorschlaege':
|
||||
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
||||
break
|
||||
@@ -245,7 +254,7 @@ export function aufVerbindung(h: Horcher): () => void {
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
|
||||
function anKern(nachricht: { art: string; id: string; auftrag?: RipAuftragTitel[] }, wasWar: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
@@ -258,8 +267,19 @@ export function auswerfen(id: string): void {
|
||||
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||
}
|
||||
|
||||
export function ripStarten(id: string): void {
|
||||
anKern({ art: 'rip-start', id }, `Rip ${id}`)
|
||||
/** Den Info-Lauf anstoßen (20–120 s) — erst auf Zuruf, nie beim Einlegen. */
|
||||
export function titelLesen(id: string): void {
|
||||
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
||||
}
|
||||
|
||||
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
|
||||
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = []): void {
|
||||
anKern({ art: 'rip-start', id, auftrag }, `Rip ${id}`)
|
||||
}
|
||||
|
||||
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
|
||||
export function ripUeberspringen(id: string): void {
|
||||
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
|
||||
}
|
||||
|
||||
export function ripAbbrechen(id: string): void {
|
||||
|
||||
@@ -59,6 +59,18 @@
|
||||
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
|
||||
}
|
||||
|
||||
/* Eigene Titelleiste (§ 4.2): Das Fenster hat seit 5.1.3 keinen
|
||||
Windows-Rahmen mehr (frame:false) — gezogen wird an der Kopfzeile.
|
||||
`drag` macht eine Fläche zum Fenstergriff; ALLES Bedienbare darin
|
||||
braucht `nicht-ziehbar`, sonst verschluckt der Griff den Klick.
|
||||
Doppelklick auf den Griff maximiert, das macht Windows von selbst. */
|
||||
.ziehbar {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.nicht-ziehbar {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
||||
|
||||
@@ -57,6 +57,59 @@ export interface RipStatus {
|
||||
text: string
|
||||
dateien: string[]
|
||||
lesefehler: boolean
|
||||
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
|
||||
* insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
|
||||
* und in Fehler-Meldungen; die kennen keine Titel. */
|
||||
titelNr?: number
|
||||
titelGesamt?: number
|
||||
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
|
||||
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
|
||||
haengt?: { offset: string; wiederholungen: number } | null
|
||||
}
|
||||
|
||||
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
|
||||
* hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
|
||||
* extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
|
||||
* folge → Serien/<Titel>/Season NN/… S01E02.mkv
|
||||
* EINE Definition für Kern und Fenster. */
|
||||
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
|
||||
|
||||
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
|
||||
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
||||
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
||||
* bei eindeutiger Zuordnung um (§ 4.2). */
|
||||
export interface RipAuftragTitel {
|
||||
nr: number
|
||||
rolle: TitelRolle
|
||||
dauerS: number
|
||||
}
|
||||
|
||||
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
|
||||
export interface DiscTitelVorschlag {
|
||||
nr: number
|
||||
dauerS: number
|
||||
groesseBytes: number
|
||||
kapitel: number
|
||||
/** Von Rippy vorausgewählt. */
|
||||
gewaehlt: boolean
|
||||
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
|
||||
rolle: TitelRolle
|
||||
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
||||
grund: string
|
||||
}
|
||||
|
||||
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
||||
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
|
||||
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
|
||||
export interface TitelStand {
|
||||
id: string
|
||||
laeuft: boolean
|
||||
titel: DiscTitelVorschlag[]
|
||||
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
|
||||
einordnung: string
|
||||
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
|
||||
unsicher: boolean
|
||||
fehler: string
|
||||
}
|
||||
|
||||
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||||
@@ -75,6 +128,13 @@ export interface DiscInfoStand {
|
||||
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||||
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||||
schonGerippt: string | null
|
||||
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
|
||||
staffel: number | null
|
||||
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
|
||||
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
|
||||
folgen: number
|
||||
}
|
||||
|
||||
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
||||
@@ -129,6 +189,7 @@ export type KernNachricht =
|
||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||
| { art: 'rip-status'; status: RipStatus }
|
||||
| { art: 'disc-info'; info: DiscInfoStand }
|
||||
| { art: 'disc-titel'; stand: TitelStand }
|
||||
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
||||
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||
@@ -156,8 +217,16 @@ export type KernNachricht =
|
||||
export type FensterNachricht =
|
||||
| { art: 'ping'; zeit: number }
|
||||
| { art: 'auswerfen'; id: string }
|
||||
| { art: 'rip-start'; id: string }
|
||||
/** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 s, deshalb erst auf Zuruf
|
||||
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||
* sehr sehr lange"). */
|
||||
| { art: 'titel-lesen'; id: string }
|
||||
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
||||
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[] }
|
||||
| { art: 'rip-abbruch'; id: string }
|
||||
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
||||
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
||||
| { art: 'rip-ueberspringen'; id: string }
|
||||
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
||||
| { art: 'metadaten-suchen'; id: string; text: string }
|
||||
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
||||
@@ -182,8 +251,31 @@ export interface HauptStatus {
|
||||
leine: { ok: boolean; fehler?: string }
|
||||
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
||||
autostart: boolean
|
||||
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */
|
||||
update: { letzteErfolgreichePruefung: string; text: string }
|
||||
/** Selbst-Update (§ 6.8). */
|
||||
update: UpdateStand
|
||||
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
|
||||
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
|
||||
fensterMaximiert: boolean
|
||||
}
|
||||
|
||||
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
|
||||
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
|
||||
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
|
||||
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
|
||||
export interface UpdateStand {
|
||||
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||
letzteErfolgreichePruefung: string
|
||||
text: string
|
||||
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
|
||||
neueVersion: string
|
||||
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
|
||||
neuerungen: string
|
||||
/** Geladen und wartet auf den Neustart. */
|
||||
bereit: boolean
|
||||
/** Eine Prüfung oder ein Download läuft gerade. */
|
||||
laeuft: boolean
|
||||
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
|
||||
fortschritt: number
|
||||
}
|
||||
|
||||
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
||||
@@ -220,6 +312,13 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
||||
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
||||
)
|
||||
case 'disc-titel':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
|
||||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
|
||||
Array.isArray((wert.stand as Record<string, unknown>).titel)
|
||||
)
|
||||
case 'einstellungen':
|
||||
return istObjekt(wert.werte)
|
||||
case 'werkzeuge':
|
||||
@@ -277,7 +376,13 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||
if (!istObjekt(wert)) return false
|
||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
|
||||
if (
|
||||
wert.art === 'auswerfen' ||
|
||||
wert.art === 'titel-lesen' ||
|
||||
wert.art === 'rip-start' ||
|
||||
wert.art === 'rip-abbruch' ||
|
||||
wert.art === 'rip-ueberspringen'
|
||||
) {
|
||||
return typeof wert.id === 'string' && wert.id.length > 0
|
||||
}
|
||||
if (wert.art === 'einstellung-setzen') {
|
||||
|
||||
@@ -4,15 +4,25 @@
|
||||
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { fensterrandEntfernen } from './fensterrand'
|
||||
|
||||
export function fensterErstellen(): BrowserWindow {
|
||||
const fenster = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 720,
|
||||
minHeight: 480,
|
||||
width: 1280,
|
||||
height: 820,
|
||||
minWidth: 860,
|
||||
minHeight: 560,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
|
||||
// ist weg, Minimieren/Maximieren/Schließen sitzen im UI und gezogen
|
||||
// wird an der Kopfzeile (.ziehbar in fenster/stil.css).
|
||||
//
|
||||
// Wichtig: GRÖSSE ÄNDERN bleibt möglich. Electron setzt für rahmenlose
|
||||
// Fenster unter Windows weiterhin WS_THICKFRAME (Option `thickFrame`,
|
||||
// Standard true) — die Kanten lassen sich also weiter ziehen, und
|
||||
// Doppelklick auf die Ziehfläche maximiert wie gewohnt.
|
||||
frame: false,
|
||||
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
|
||||
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
|
||||
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
|
||||
@@ -30,6 +40,12 @@ export function fensterErstellen(): BrowserWindow {
|
||||
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
|
||||
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
|
||||
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
|
||||
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er
|
||||
// gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
|
||||
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
|
||||
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
|
||||
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`)
|
||||
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
|
||||
if (devUrl !== undefined && devUrl.length > 0) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Den Windows-11-Fensterrand loswerden.
|
||||
//
|
||||
// Commander-Fund (01.09.2026): „Ich hab noch den rand ich glaube der kommt
|
||||
// aber von windows selbst oder?" — Genau so ist es. Ein rahmenloses Fenster
|
||||
// (`frame: false`) behält unter Windows 11 trotzdem den DWM-Rand: eine
|
||||
// hauchfeine Linie in der Systemfarbe, gezeichnet vom Fenstermanager, nicht
|
||||
// von der Seite. Mit CSS ist da nichts zu holen, das liegt außerhalb des
|
||||
// Fensterinhalts.
|
||||
//
|
||||
// DwmSetWindowAttribute kennt dafür zwei Schalter (dwmapi.h, beide erst ab
|
||||
// Windows 11 Build 22000):
|
||||
// DWMWA_BORDER_COLOR (34) = DWMWA_COLOR_NONE (0xFFFFFFFE)
|
||||
// DWMWA_WINDOW_CORNER_PREFERENCE (33) = DWMWCP_DONOTROUND (1)
|
||||
//
|
||||
// Ältere Windows antworten mit einem Fehler-HRESULT. Das ist KEIN Problem
|
||||
// und wird als Wert zurückgegeben, nicht verschluckt (R4): Unter Windows 10
|
||||
// gibt es diesen Rand schlicht nicht.
|
||||
//
|
||||
// Dies ist neben `leine.ts` und `kern/laufwerk/win32.ts` der DRITTE erlaubte
|
||||
// koffi-Ort; der Wächter-Test R1 kennt genau diese drei. Begründung wie
|
||||
// dort: ein Win32-Aufruf, der nirgendwo sonst hingehört — und er MUSS im
|
||||
// Haupt liegen, weil nur dort das Fensterhandle existiert.
|
||||
import koffi from 'koffi'
|
||||
|
||||
const DWMWA_WINDOW_CORNER_PREFERENCE = 33
|
||||
const DWMWA_BORDER_COLOR = 34
|
||||
/** „Zeichne gar keinen Rand" — nicht zu verwechseln mit Schwarz. */
|
||||
const DWMWA_COLOR_NONE = 0xfffffffe
|
||||
const DWMWCP_DONOTROUND = 1
|
||||
|
||||
export interface RandErgebnis {
|
||||
ok: boolean
|
||||
/** '' wenn alles saß; sonst der HRESULT-Grund im Klartext. */
|
||||
fehler: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Rand entfernen. `eckenKantig` schaltet zusätzlich die runden Ecken ab —
|
||||
* standardmäßig AUS, weil runde Ecken unter Windows 11 das Übliche sind
|
||||
* und Rippy sich nicht fremder anfühlen soll, als es muss.
|
||||
*
|
||||
* @param handle Das native Fensterhandle (`BrowserWindow.getNativeWindowHandle()`).
|
||||
*/
|
||||
export function fensterrandEntfernen(handle: Buffer, eckenKantig = false): RandErgebnis {
|
||||
if (handle.length < 8) return { ok: false, fehler: 'Fensterhandle unbrauchbar' }
|
||||
try {
|
||||
const dwmapi = koffi.load('dwmapi.dll')
|
||||
// Das Handle steckt als Zeiger IM Puffer. Auf x64 wandert ein Zeiger im
|
||||
// selben Register wie ein uint64 — deshalb ist das hier gefahrlos.
|
||||
const DwmSetWindowAttribute = dwmapi.func(
|
||||
'int32_t __stdcall DwmSetWindowAttribute(uint64_t hwnd, uint32_t dwAttribute, void *pvAttribute, uint32_t cbAttribute)',
|
||||
)
|
||||
const hwnd = handle.readBigUInt64LE(0)
|
||||
|
||||
const farbe = Buffer.alloc(4)
|
||||
farbe.writeUInt32LE(DWMWA_COLOR_NONE, 0)
|
||||
const randErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, farbe, 4)
|
||||
|
||||
let eckenErgebnis = 0
|
||||
if (eckenKantig) {
|
||||
const ecken = Buffer.alloc(4)
|
||||
ecken.writeUInt32LE(DWMWCP_DONOTROUND, 0)
|
||||
eckenErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ecken, 4)
|
||||
}
|
||||
|
||||
if (randErgebnis !== 0 || eckenErgebnis !== 0) {
|
||||
// Ein negatives HRESULT heißt hier fast immer: älteres Windows, das
|
||||
// diese Schalter nicht kennt. Rippy läuft weiter, nur mit Rand.
|
||||
return {
|
||||
ok: false,
|
||||
fehler: `DwmSetWindowAttribute: HRESULT 0x${(randErgebnis >>> 0).toString(16)} (Rand) / 0x${(eckenErgebnis >>> 0).toString(16)} (Ecken) — vermutlich Windows 10`,
|
||||
}
|
||||
}
|
||||
return { ok: true, fehler: '' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: `koffi/dwmapi nicht verfügbar: ${String(fehler)}` }
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { fensterErstellen } from './fenster'
|
||||
import { KernVerwaltung } from './kernstart'
|
||||
import { leineSetzen, type LeineStatus } from './leine'
|
||||
import { trayErstellen, trayHinweis } from './tray'
|
||||
import { updateEinrichten, updateStand } from './update'
|
||||
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
|
||||
|
||||
const smokeModus = process.argv.includes('--smoke')
|
||||
|
||||
@@ -83,6 +83,13 @@ function start(): void {
|
||||
nachricht.werte['updateUrl'] ?? '',
|
||||
() => aktiveVorgaenge.size > 0,
|
||||
(text) => toast('Rippy-Update', text),
|
||||
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
|
||||
// vorher lag sie still im Haupt und war erst beim nächsten
|
||||
// Laden zu sehen (Commander-Fund 01.09.2026).
|
||||
() => statusSenden(),
|
||||
// Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit
|
||||
// der close-Horcher das Fenster nicht bloß versteckt.
|
||||
() => beenden(),
|
||||
)
|
||||
}
|
||||
} else if (nachricht.art === 'installer-starten') {
|
||||
@@ -172,6 +179,9 @@ function start(): void {
|
||||
kern: kernVerwaltung.zustand(),
|
||||
autostart: autostartAktiv(),
|
||||
update: updateStand(),
|
||||
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
|
||||
// Fenster gerade maximiert ist.
|
||||
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
|
||||
}
|
||||
fenster.webContents.send('haupt-status', status)
|
||||
}
|
||||
@@ -185,6 +195,11 @@ function start(): void {
|
||||
statusSenden()
|
||||
smokePruefen()
|
||||
})
|
||||
// Die eigene Titelleiste zeichnet ihr Maximieren-Symbol nach diesem
|
||||
// Zustand — auch wenn per Doppelklick oder Windows-Tastenkürzel
|
||||
// maximiert wird, nicht nur über unseren Knopf.
|
||||
fenster.on('maximize', () => statusSenden())
|
||||
fenster.on('unmaximize', () => statusSenden())
|
||||
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
|
||||
fenster.on('close', (ereignis) => {
|
||||
if (beendenGewollt) return
|
||||
@@ -219,6 +234,41 @@ function start(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// Eigene Titelleiste (§ 4.2): Das Fenster hat keinen Windows-Rahmen
|
||||
// mehr, die drei Knöpfe sitzen im UI und melden sich hier.
|
||||
ipcMain.handle('fenster-steuern', (_ereignis, was: unknown) => {
|
||||
if (fenster === null || fenster.isDestroyed()) return false
|
||||
if (was === 'minimieren') {
|
||||
fenster.minimize()
|
||||
} else if (was === 'maximieren') {
|
||||
if (fenster.isMaximized()) fenster.unmaximize()
|
||||
else fenster.maximize()
|
||||
} else if (was === 'schliessen') {
|
||||
// Genau der Weg des alten Fensterkreuzes: Der close-Horcher weiter
|
||||
// unten versteckt nur — Rippy lebt im Tray weiter (§ 4.1 Punkt 3),
|
||||
// ein laufender Rip darf davon nichts merken.
|
||||
fenster.close()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return fenster.isDestroyed() ? false : fenster.isMaximized()
|
||||
})
|
||||
|
||||
// Der Knopf „Jetzt prüfen" (§ 6.8). Der Stand meldet sich über
|
||||
// beiAenderung von selbst ans Fenster — hier gibt es nichts zurück.
|
||||
ipcMain.handle('update-pruefen', () => {
|
||||
updatePruefenJetzt()
|
||||
return updateStand()
|
||||
})
|
||||
|
||||
// Der Knopf „Jetzt neu starten und installieren". Sagt NEIN mit Grund,
|
||||
// statt still nichts zu tun (R4).
|
||||
ipcMain.handle('update-installieren', () => {
|
||||
const urteil = updateInstallierenJetzt()
|
||||
if (!urteil.ok) statusSenden()
|
||||
return urteil
|
||||
})
|
||||
|
||||
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
|
||||
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
|
||||
autostartSetzen(an === true)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Windows-Kommandozeilen bauen — PUR, ohne koffi und ohne Electron, damit
|
||||
// die Ampel das auf Linux prüfen kann (BAUEN.md).
|
||||
//
|
||||
// CreateProcessW bekommt EINE Zeichenkette, keine Argumentliste. Wer sie
|
||||
// naiv mit Leerzeichen zusammenklebt, verliert jeden Pfad mit Leerzeichen:
|
||||
// Aus `C:\Users\Tobi Neu\…\RippySetup.exe` würden zwei Argumente, und der
|
||||
// Installer startet nie. Die Regeln sind die von CommandLineToArgvW —
|
||||
// dieselben, nach denen die Gegenseite die Zeichenkette wieder zerlegt.
|
||||
|
||||
/** Ein Argument nach den Regeln von CommandLineToArgvW einpacken. */
|
||||
export function argumentQuoten(arg: string): string {
|
||||
// Ohne Leerzeichen, Tabulator und Anführungszeichen bleibt alles, wie es
|
||||
// ist — auch der leere Fall braucht Anführungszeichen, sonst verschwindet er.
|
||||
if (arg.length > 0 && !/["\s]/.test(arg)) return arg
|
||||
|
||||
let ergebnis = '"'
|
||||
let rueckstriche = 0
|
||||
for (const zeichen of arg) {
|
||||
if (zeichen === '\\') {
|
||||
rueckstriche += 1
|
||||
continue
|
||||
}
|
||||
if (zeichen === '"') {
|
||||
// Vor einem Anführungszeichen zählen Rückstriche doppelt, und das
|
||||
// Anführungszeichen selbst bekommt einen eigenen davor.
|
||||
ergebnis += '\\'.repeat(rueckstriche * 2 + 1) + '"'
|
||||
rueckstriche = 0
|
||||
continue
|
||||
}
|
||||
ergebnis += '\\'.repeat(rueckstriche) + zeichen
|
||||
rueckstriche = 0
|
||||
}
|
||||
// Am Ende steht das schließende Anführungszeichen — die Rückstriche
|
||||
// davor zählen deshalb ebenfalls doppelt.
|
||||
return ergebnis + '\\'.repeat(rueckstriche * 2) + '"'
|
||||
}
|
||||
|
||||
/** Programm + Argumente zu der einen Zeichenkette, die CreateProcessW will. */
|
||||
export function kommandozeileBauen(programm: string, argumente: readonly string[]): string {
|
||||
return [programm, ...argumente].map(argumentQuoten).join(' ')
|
||||
}
|
||||
|
||||
/** Die Argumente, mit denen der NSIS-Installer still durchläuft und Rippy
|
||||
* danach wieder startet. Gemessen an der installierten Bibliothek
|
||||
* (electron-updater `NsisUpdater.js`, `doInstall`): `--updated` immer,
|
||||
* `/S` für „still", `--force-run` für „danach starten". Genau diese drei
|
||||
* benutzt electron-updater selbst — wir starten den Installer nur auf
|
||||
* einem anderen Weg (außerhalb der Prozess-Leine). */
|
||||
export const INSTALLER_ARGUMENTE: readonly string[] = ['--updated', '/S', '--force-run']
|
||||
@@ -17,11 +17,44 @@
|
||||
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
|
||||
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
|
||||
import koffi from 'koffi'
|
||||
import { kommandozeileBauen } from './kommandozeile'
|
||||
|
||||
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
|
||||
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
||||
|
||||
// Erlaubt AUSDRÜCKLICHES Ausklinken. Für sich genommen ändert dieses Recht
|
||||
// NICHTS an der Leine: Kinder erben die Mitgliedschaft weiterhin, es sei
|
||||
// denn, sie verlangen das Ausklinken beim Anlegen selbst
|
||||
// (CREATE_BREAKAWAY_FROM_JOB). Ohne das Recht scheitert genau das mit
|
||||
// ERROR_ACCESS_DENIED. makemkvcon, HandBrakeCLI und der Kern bleiben also
|
||||
// angeleint — die rc10-Waisen-Falle (§ 3.3) ist unverändert zu.
|
||||
//
|
||||
// WOFÜR (01.09.2026, am lebenden Objekt bezahlt): Der Update-Installer von
|
||||
// electron-updater wird per `spawn(…, { detached: true })` gestartet
|
||||
// (BaseUpdater.js, an der installierten Fassung nachgelesen). `detached`
|
||||
// setzt unter Windows KEIN Breakaway — der Installer wurde damit Mitglied
|
||||
// der Leine und starb in genau der Sekunde, in der Rippy sich beendete, um
|
||||
// ihm Platz zu machen. Befund des Commanders: „Rippy wird zwar sauber
|
||||
// beendet aber startet NICHT neu in der neuen version."
|
||||
const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
|
||||
|
||||
/** Nur ein Kind, das das ausdrücklich verlangt, verlässt die Leine. */
|
||||
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
|
||||
// Eigene (unsichtbare) Konsole statt der des Elternprozesses. Für den
|
||||
// NSIS-Installer (ein Fenster-Programm) wirkungslos, aber die Lehre aus
|
||||
// dem ersten Messlauf zu `beweise/leine-breakaway.js` (01.09.2026): Ein
|
||||
// Kind, das sich die Konsole des Elternprozesses teilt, stirbt MIT IHR —
|
||||
// und sieht dann aus, als hätte das Ausklinken nicht gewirkt. Die Messung
|
||||
// zeigte deshalb erst ein falsches Negativ.
|
||||
const CREATE_NO_WINDOW = 0x08000000
|
||||
|
||||
// STARTUPINFOW ist auf x64 104 Byte, `cb` steht als DWORD an Offset 0;
|
||||
// PROCESS_INFORMATION ist 24 Byte (zwei Handles + zwei DWORDs).
|
||||
const STARTUPINFO_GROESSE = 104
|
||||
const PROCESS_INFORMATION_GROESSE = 24
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
|
||||
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
|
||||
// bei Offset 16.
|
||||
@@ -63,7 +96,7 @@ export function leineSetzen(): LeineStatus {
|
||||
}
|
||||
|
||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET)
|
||||
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK, LIMIT_FLAGS_OFFSET)
|
||||
if (
|
||||
!SetInformationJobObject(
|
||||
job,
|
||||
@@ -90,3 +123,64 @@ export function leineSetzen(): LeineStatus {
|
||||
return gesetzt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Programm starten, das die Prozess-Leine AUSDRÜCKLICH verlässt.
|
||||
*
|
||||
* Gedacht für genau einen Fall: den Update-Installer. Er ist das einzige
|
||||
* Kind, das Rippys eigenen Tod überleben MUSS — er installiert ja gerade
|
||||
* über Rippy drüber und startet es danach wieder. Alles andere (Kern,
|
||||
* makemkvcon, HandBrakeCLI) bleibt angeleint, § 3.3 ist unverändert.
|
||||
*
|
||||
* Warum nicht `shell.openPath` wie beim MakeMKV-Installer (§ 9)? Der
|
||||
* entkommt der Leine nur, WEIL er Adminrechte verlangt und deshalb über
|
||||
* den AppInfo-Dienst startet. Rippys eigener Installer ist bewusst
|
||||
* benutzerlokal und ohne Erhöhung (Entscheid 5 / § 8; in der
|
||||
* `update-info.json` steht `isAdminRightsRequired: false`) — er liefe also
|
||||
* wieder als Kind und stürbe wieder mit.
|
||||
*/
|
||||
export function ausserhalbDerLeineStarten(
|
||||
programm: string,
|
||||
argumente: readonly string[],
|
||||
): { ok: boolean; fehler: string } {
|
||||
try {
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const CreateProcessW = kernel32.func(
|
||||
'bool __stdcall CreateProcessW(str16 lpApplicationName, void *lpCommandLine, void *lpProcessAttributes, void *lpThreadAttributes, bool bInheritHandles, uint32_t dwCreationFlags, void *lpEnvironment, str16 lpCurrentDirectory, void *lpStartupInfo, void *lpProcessInformation)',
|
||||
)
|
||||
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
|
||||
|
||||
const startupInfo = Buffer.alloc(STARTUPINFO_GROESSE)
|
||||
startupInfo.writeUInt32LE(STARTUPINFO_GROESSE, 0)
|
||||
const prozessInfo = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
|
||||
// CreateProcessW DARF diese Zeichenkette verändern — deshalb ein
|
||||
// beschreibbarer Puffer und nicht die Zeichenkette selbst.
|
||||
const zeile = Buffer.from(`${kommandozeileBauen(programm, argumente)}\0`, 'utf16le')
|
||||
|
||||
const gestartet = CreateProcessW(
|
||||
programm,
|
||||
zeile,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW,
|
||||
null,
|
||||
null,
|
||||
startupInfo,
|
||||
prozessInfo,
|
||||
)
|
||||
if (!gestartet) {
|
||||
// Win32-Fehler 5 (ERROR_ACCESS_DENIED) heißt hier: Das Ausklinken war
|
||||
// nicht erlaubt — dann fehlt JOB_OBJECT_LIMIT_BREAKAWAY_OK, oder eine
|
||||
// ÄUSSERE Arbeitsgruppe verbietet es (Sandbox, Fernwartung).
|
||||
return { ok: false, fehler: `CreateProcessW: Win32-Fehler ${GetLastError()}` }
|
||||
}
|
||||
// Die beiden Handles in prozessInfo bleiben offen: Rippy beendet sich
|
||||
// unmittelbar danach, Windows schließt sie dabei mit. Sie hier zu
|
||||
// schließen hieße, sie erst aus dem Puffer zurückzudecodieren — Aufwand
|
||||
// ohne Wirkung.
|
||||
return { ok: true, fehler: '' }
|
||||
} catch (fehler) {
|
||||
return { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,3 +18,44 @@ export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
|
||||
'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).',
|
||||
}
|
||||
}
|
||||
|
||||
/** So viel Änderungstext zeigt das Fenster höchstens — der Rest wird
|
||||
* abgeschnitten, damit eine ausufernde Datei die Karte nicht sprengt. */
|
||||
export const NEUERUNGEN_HOECHSTENS = 4000
|
||||
|
||||
/** Die Änderungsnotizen aus `latest.yml` in reinen Text.
|
||||
*
|
||||
* `UpdateInfo.releaseNotes` ist laut builder-util-runtime
|
||||
* `string | Array<ReleaseNoteInfo> | null` — bei uns ein String
|
||||
* (electron-builder legt den Inhalt von `bau/release-notes.md` hinein),
|
||||
* bei mehreren übersprungenen Versionen eine Liste. Beides muss hier
|
||||
* ankommen können, sonst steht im Fenster „[object Object]".
|
||||
*
|
||||
* HTML-Marken fliegen raus: Der Text kommt von einem Server, und auch
|
||||
* wenn React ihn ohnehin nur als Text setzt, wird hier nichts
|
||||
* weitergereicht, was wie Auszeichnung aussieht. */
|
||||
export function neuerungenText(roh: unknown): string {
|
||||
const saubern = (t: string): string =>
|
||||
t
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.split('\n')
|
||||
.map((zeile) => zeile.replace(/[ \t]+$/, ''))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
if (typeof roh === 'string') return saubern(roh).slice(0, NEUERUNGEN_HOECHSTENS)
|
||||
if (Array.isArray(roh)) {
|
||||
const teile: string[] = []
|
||||
for (const eintrag of roh) {
|
||||
if (eintrag === null || typeof eintrag !== 'object') continue
|
||||
const e = eintrag as { version?: unknown; note?: unknown }
|
||||
const version = typeof e.version === 'string' ? e.version : ''
|
||||
const notiz = typeof e.note === 'string' ? saubern(e.note) : ''
|
||||
if (notiz.length === 0) continue
|
||||
teile.push(version.length > 0 ? `${version}\n${notiz}` : notiz)
|
||||
}
|
||||
return teile.join('\n\n').slice(0, NEUERUNGEN_HOECHSTENS)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -2,42 +2,90 @@
|
||||
// öffentlichen Gitea (Entscheid 6). Die vier Regeln:
|
||||
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
|
||||
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
|
||||
// 2. NIE während eines Rips — weder laden noch installieren.
|
||||
// 2. NIE während eines Rips — weder prüfen, laden noch installieren.
|
||||
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
|
||||
// letzten erfolgreichen Prüfung steht im Status.
|
||||
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
|
||||
//
|
||||
// Ergänzt am 01.09.2026 auf Zuruf des Commanders („irgendwie fehlt der
|
||||
// Knopf ‚Auf Updates prüfen' und die Meldung beim Start"): Der Kanal
|
||||
// arbeitete korrekt, war aber UNSICHTBAR — der Stand lag im Haupt und
|
||||
// wurde nur beim Laden des Fensters einmal mitgeschickt. Jetzt trägt er
|
||||
// zusätzlich die gefundene Version, ihre Änderungsnotizen und den
|
||||
// Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster.
|
||||
import { app } from 'electron'
|
||||
import electronUpdater from 'electron-updater'
|
||||
import { updateUrlPruefen } from './update-regeln'
|
||||
import { INSTALLER_ARGUMENTE } from './kommandozeile'
|
||||
import { ausserhalbDerLeineStarten } from './leine'
|
||||
import { neuerungenText, updateUrlPruefen } from './update-regeln'
|
||||
|
||||
export { STANDARD_UPDATE_URL, updateUrlPruefen } from './update-regeln'
|
||||
export {
|
||||
NEUERUNGEN_HOECHSTENS,
|
||||
STANDARD_UPDATE_URL,
|
||||
neuerungenText,
|
||||
updateUrlPruefen,
|
||||
} from './update-regeln'
|
||||
|
||||
const { autoUpdater } = electronUpdater
|
||||
|
||||
export interface UpdateStand {
|
||||
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||
letzteErfolgreichePruefung: string
|
||||
text: string
|
||||
// EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle).
|
||||
export type { UpdateStand } from '../gemeinsam/nachrichten'
|
||||
import type { UpdateStand } from '../gemeinsam/nachrichten'
|
||||
|
||||
const ANFANG: UpdateStand = {
|
||||
letzteErfolgreichePruefung: '',
|
||||
text: 'noch nicht geprüft',
|
||||
neueVersion: '',
|
||||
neuerungen: '',
|
||||
bereit: false,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
}
|
||||
|
||||
const stand: UpdateStand = { letzteErfolgreichePruefung: '', text: 'noch nicht geprüft' }
|
||||
let stand: UpdateStand = { ...ANFANG }
|
||||
let aenderungMelden: () => void = () => {
|
||||
// Vor updateEinrichten() gibt es noch kein Fenster, das zuhören könnte.
|
||||
}
|
||||
let ripLaeuftJetzt: () => boolean = () => false
|
||||
let scharf = false
|
||||
/** Der geladene Installer — leer, solange nichts bereitliegt. */
|
||||
let installerPfad = ''
|
||||
/** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich
|
||||
* nicht gegenseitig zwei Installer auf den Hals hetzen. */
|
||||
let installerGestartet = false
|
||||
let rippyBeenden: () => void = () => app.quit()
|
||||
|
||||
export function updateStand(): UpdateStand {
|
||||
return { ...stand }
|
||||
}
|
||||
|
||||
/** Stand ändern UND das Fenster benachrichtigen — nie das eine ohne das
|
||||
* andere: Ein Stand, den niemand erfährt, ist der Zustand von vorher
|
||||
* (der 26.07.-Fund „ein still scheiternder Hintergrund-Prozess"). */
|
||||
function setzen(teil: Partial<UpdateStand>): void {
|
||||
stand = { ...stand, ...teil }
|
||||
aenderungMelden()
|
||||
}
|
||||
|
||||
export function updateEinrichten(
|
||||
eigeneUrl: string,
|
||||
ripLaeuft: () => boolean,
|
||||
melden: (text: string) => void,
|
||||
beiAenderung: () => void = () => {
|
||||
// ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar
|
||||
},
|
||||
beenden: () => void = () => app.quit(),
|
||||
): void {
|
||||
aenderungMelden = beiAenderung
|
||||
ripLaeuftJetzt = ripLaeuft
|
||||
rippyBeenden = beenden
|
||||
if (!app.isPackaged) {
|
||||
stand.text = 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.'
|
||||
setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
|
||||
return
|
||||
}
|
||||
const pruefung = updateUrlPruefen(eigeneUrl)
|
||||
if (!pruefung.ok) {
|
||||
stand.text = pruefung.grund
|
||||
setzen({ text: pruefung.grund })
|
||||
melden(pruefung.grund)
|
||||
return
|
||||
}
|
||||
@@ -45,39 +93,154 @@ export function updateEinrichten(
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
|
||||
}
|
||||
autoUpdater.autoDownload = false
|
||||
// Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip.
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
// WIR installieren, nicht electron-updater (01.09.2026, am lebenden
|
||||
// Objekt bezahlt): Sein eigener Weg startet den Installer per
|
||||
// `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN
|
||||
// Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3)
|
||||
// und starb in genau der Sekunde, in der Rippy sich für ihn beendete.
|
||||
// Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu."
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
scharf = true
|
||||
|
||||
autoUpdater.on('update-available', () => {
|
||||
// Beim Beenden — egal ob über das Tray, den Update-Knopf oder das
|
||||
// Herunterfahren von Windows — bekommt ein bereitliegendes Update noch
|
||||
// seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor
|
||||
// diese Zeile zurückkommt.
|
||||
app.on('will-quit', () => {
|
||||
if (!stand.bereit || ripLaeuftJetzt()) return
|
||||
installerAnwerfen()
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
const neuerungen = neuerungenText(info.releaseNotes)
|
||||
if (ripLaeuft()) {
|
||||
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
|
||||
// tägliche Takt versucht es wieder.
|
||||
stand.text = 'Update verfügbar — wird geladen, sobald kein Rip läuft.'
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: `Version ${info.version} ist da — sie wird geladen, sobald kein Rip mehr läuft.`,
|
||||
})
|
||||
melden(stand.text)
|
||||
return
|
||||
}
|
||||
void autoUpdater.downloadUpdate().catch((fehler: unknown) => {
|
||||
stand.text = `Update-Download fehlgeschlagen: ${String(fehler)}`
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen,
|
||||
laeuft: true,
|
||||
fortschritt: 0,
|
||||
text: `Version ${info.version} wird geladen …`,
|
||||
})
|
||||
// Der Commander wollte die Meldung sehen, nicht suchen müssen.
|
||||
melden(`Update verfügbar: Version ${info.version} wird geladen.`)
|
||||
void autoUpdater
|
||||
.downloadUpdate()
|
||||
.then((pfade: string[]) => {
|
||||
// downloadUpdate liefert die abgelegten Dateien zurück
|
||||
// (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin
|
||||
// ist der Installer, den wir gleich selbst starten. Liegt die Datei
|
||||
// vom letzten Lauf schon im Zwischenspeicher, kommt sie hier
|
||||
// ebenfalls heraus, ohne erneut zu laden.
|
||||
const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? ''
|
||||
if (exe.length > 0) installerPfad = exe
|
||||
})
|
||||
.catch((fehler: unknown) => {
|
||||
setzen({
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
autoUpdater.on('download-progress', (fortschritt) => {
|
||||
setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) })
|
||||
})
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
stand.text = `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`
|
||||
setzen({
|
||||
neueVersion: info.version,
|
||||
neuerungen: neuerungenText(info.releaseNotes) || stand.neuerungen,
|
||||
bereit: true,
|
||||
laeuft: false,
|
||||
fortschritt: 100,
|
||||
text: `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`,
|
||||
})
|
||||
melden(stand.text)
|
||||
})
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
stand.letzteErfolgreichePruefung = new Date().toISOString()
|
||||
stand.text = 'Rippy ist aktuell.'
|
||||
setzen({
|
||||
letzteErfolgreichePruefung: new Date().toISOString(),
|
||||
neueVersion: '',
|
||||
neuerungen: '',
|
||||
bereit: false,
|
||||
laeuft: false,
|
||||
fortschritt: -1,
|
||||
text: 'Rippy ist aktuell.',
|
||||
})
|
||||
})
|
||||
autoUpdater.on('error', (fehler) => {
|
||||
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
|
||||
stand.text = `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}`
|
||||
setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
|
||||
})
|
||||
|
||||
const pruefen = (): void => {
|
||||
if (ripLaeuft()) return
|
||||
void autoUpdater.checkForUpdates().catch(() => {
|
||||
// der error-Handler oben trägt den Grund ein
|
||||
})
|
||||
}
|
||||
pruefen()
|
||||
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
|
||||
}
|
||||
|
||||
function pruefen(): void {
|
||||
if (ripLaeuftJetzt()) return
|
||||
setzen({ laeuft: true, text: 'Suche nach Updates …' })
|
||||
void autoUpdater.checkForUpdates().catch(() => {
|
||||
// der error-Handler oben trägt den Grund ein
|
||||
})
|
||||
}
|
||||
|
||||
/** Der Knopf „Jetzt prüfen" im Fenster. Antwortet IMMER etwas — ein
|
||||
* Knopf, der nichts sagt, ist schlimmer als keiner. */
|
||||
export function updatePruefenJetzt(): void {
|
||||
if (!scharf) {
|
||||
// Im Entwicklungslauf steht der Grund schon im Text; ihn erneut zu
|
||||
// melden hält den Knopf ehrlich, statt ihn ins Leere laufen zu lassen.
|
||||
aenderungMelden()
|
||||
return
|
||||
}
|
||||
if (ripLaeuftJetzt()) {
|
||||
setzen({ text: 'Während eines Rips wird nicht nach Updates gesucht — das ist Absicht (§ 6.8).' })
|
||||
return
|
||||
}
|
||||
pruefen()
|
||||
}
|
||||
|
||||
/** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob
|
||||
* er wirklich läuft — „nichts passiert" darf nicht als Erfolg durchgehen. */
|
||||
function installerAnwerfen(): { ok: boolean; grund: string } {
|
||||
if (installerGestartet) return { ok: true, grund: '' }
|
||||
if (installerPfad.length === 0) {
|
||||
return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' }
|
||||
}
|
||||
const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE)
|
||||
if (!urteil.ok) {
|
||||
setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` })
|
||||
return { ok: false, grund: urteil.fehler }
|
||||
}
|
||||
installerGestartet = true
|
||||
return { ok: true, grund: '' }
|
||||
}
|
||||
|
||||
/** Der Knopf „Jetzt neu starten und installieren". Nur sinnvoll, wenn
|
||||
* wirklich etwas bereitliegt — und nie mitten in einem Rip. */
|
||||
export function updateInstallierenJetzt(): { ok: boolean; grund: string } {
|
||||
if (!scharf || !stand.bereit) {
|
||||
return { ok: false, grund: 'Es liegt kein geladenes Update bereit.' }
|
||||
}
|
||||
if (ripLaeuftJetzt()) {
|
||||
return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' }
|
||||
}
|
||||
// Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy
|
||||
// weg ist, und startet es hinterher wieder (`--force-run`).
|
||||
const urteil = installerAnwerfen()
|
||||
if (!urteil.ok) return urteil
|
||||
rippyBeenden()
|
||||
return { ok: true, grund: '' }
|
||||
}
|
||||
|
||||
@@ -10,11 +10,17 @@
|
||||
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { basename, join } from 'node:path'
|
||||
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
import type {
|
||||
BibliothekEintrag,
|
||||
GeraeteInfo,
|
||||
RipAuftragTitel,
|
||||
RipStatus,
|
||||
TitelRolle,
|
||||
} from '../../gemeinsam/nachrichten'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { posterSpeichern } from '../ablage/poster'
|
||||
import { zielordner } from '../ablage/struktur'
|
||||
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
@@ -27,7 +33,12 @@ import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
/** Beendet den GANZEN Auftrag. */
|
||||
abbruch: AbortController
|
||||
/** Beendet nur den gerade laufenden Titel (überspringen). */
|
||||
titelAbbruch?: AbortController
|
||||
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
|
||||
uebersprungen?: boolean
|
||||
status: RipStatus
|
||||
}
|
||||
|
||||
@@ -61,6 +72,14 @@ export interface PipelineUmgebung {
|
||||
zuordnung?: (id: string) => Zuordnung | null
|
||||
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
||||
fingerprint?: (id: string) => string | null
|
||||
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */
|
||||
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
|
||||
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden].
|
||||
* Injiziert, damit die Pipeline ohne Netz prüfbar bleibt. */
|
||||
episodenLaufzeiten?: (
|
||||
tmdbId: number,
|
||||
staffel: number,
|
||||
) => Promise<ReadonlyArray<readonly [number, number]>>
|
||||
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
|
||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||
@@ -86,8 +105,19 @@ export class Pipeline {
|
||||
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||
}
|
||||
|
||||
/** Startet die Kette für die ganze Disc ('all'). */
|
||||
async starten(id: string): Promise<void> {
|
||||
/** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
|
||||
* weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
|
||||
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
|
||||
* während Folge 2 heil daneben lag). */
|
||||
ueberspringen(id: string): void {
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
|
||||
vorgang.uebersprungen = true
|
||||
vorgang.titelAbbruch.abort()
|
||||
}
|
||||
|
||||
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
||||
async starten(id: string, auftrag: readonly RipAuftragTitel[] = []): Promise<void> {
|
||||
if (this.laufend.has(id)) {
|
||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||
return
|
||||
@@ -143,24 +173,63 @@ export class Pipeline {
|
||||
}
|
||||
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
mkdirSync(rohOrdner, { recursive: true })
|
||||
|
||||
const start: RipStatus = {
|
||||
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
|
||||
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
|
||||
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
|
||||
// Trailer, Werbung und Alterskennzeichen.
|
||||
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
||||
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
|
||||
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
|
||||
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
|
||||
const rolleJeDatei = new Map<string, TitelRolle>()
|
||||
const dauerJeDatei = new Map<string, number>()
|
||||
const alleDateien = new Set<string>()
|
||||
let lesefehlerGesamt = false
|
||||
const gescheitert: string[] = []
|
||||
const uebersprungen: string[] = []
|
||||
|
||||
this.laufend.set(id, {
|
||||
abbruch,
|
||||
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false },
|
||||
})
|
||||
|
||||
for (let i = 0; i < laeufe.length; i++) {
|
||||
const welcher = laeufe[i]
|
||||
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
||||
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
|
||||
const titelAbbruch = new AbortController()
|
||||
const weiterreichen = (): void => titelAbbruch.abort()
|
||||
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang !== undefined) {
|
||||
vorgang.titelAbbruch = titelAbbruch
|
||||
vorgang.uebersprungen = false
|
||||
}
|
||||
|
||||
const beschriftung =
|
||||
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})`
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
}
|
||||
this.laufend.set(id, { abbruch, status: start })
|
||||
this.melden(start)
|
||||
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
titelNr: i + 1,
|
||||
titelGesamt: laeufe.length,
|
||||
haengt: null,
|
||||
})
|
||||
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: 'all',
|
||||
abbruch: abbruch.signal,
|
||||
titel: welcher,
|
||||
abbruch: titelAbbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||
@@ -169,22 +238,79 @@ export class Pipeline {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
aufHaengt: (klemmt) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
|
||||
},
|
||||
})
|
||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
// Nur dieser Titel — oder der ganze Auftrag?
|
||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'abgebrochen',
|
||||
prozent: -1,
|
||||
text: 'Abgebrochen.',
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
})
|
||||
return
|
||||
}
|
||||
uebersprungen.push(beschriftung)
|
||||
continue
|
||||
}
|
||||
|
||||
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
|
||||
if (rip.status === 'error') {
|
||||
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
|
||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||
continue
|
||||
}
|
||||
// rippen() liefert den ganzen Zielordner zurück, also auch die
|
||||
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
||||
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
|
||||
for (const d of rip.dateien) alleDateien.add(d)
|
||||
// Ohne Auswahl ('all') gilt für alles die Film-Regel.
|
||||
const rolle: TitelRolle = auftrag[i]?.rolle ?? 'hauptfilm'
|
||||
for (const d of neu) {
|
||||
rolleJeDatei.set(d, rolle)
|
||||
dauerJeDatei.set(d, auftrag[i]?.dauerS ?? 0)
|
||||
}
|
||||
if (neu.length === 0) {
|
||||
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
||||
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
|
||||
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
|
||||
}
|
||||
}
|
||||
|
||||
const nachLauf = this.laufend.get(id)
|
||||
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
|
||||
|
||||
if (alleDateien.size === 0) {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fehler',
|
||||
prozent: -1,
|
||||
text: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.',
|
||||
dateien: [],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const lesefehlerHinweis = rip.lesefehler
|
||||
const rip = { dateien: [...alleDateien].sort(), lesefehler: lesefehlerGesamt }
|
||||
|
||||
const lesefehlerHinweis =
|
||||
(rip.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: ''
|
||||
: '') +
|
||||
(uebersprungen.length > 0 ? ` ⚠ Übersprungen: ${uebersprungen.join(', ')}.` : '') +
|
||||
(gescheitert.length > 0 ? ` ⚠ Nicht gesichert: ${gescheitert.join(' · ')}` : '')
|
||||
|
||||
// ── Kompression (W-3) ────────────────────────────────────────────────
|
||||
const einstellungen = this.umgebung.einstellungen()
|
||||
@@ -211,16 +337,40 @@ export class Pipeline {
|
||||
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
||||
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
const fertigOrdner =
|
||||
zuordnung !== null && zuordnung.titel.length > 0
|
||||
|
||||
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
|
||||
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
|
||||
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
|
||||
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
|
||||
const alsSerie = auftrag.some((a) => a.rolle === 'folge')
|
||||
const staffel = this.umgebung.discAngaben?.(id)?.staffel ?? 1
|
||||
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
|
||||
const fertigOrdner = alsSerie
|
||||
? serienOrdner(join(this.umgebung.ablage(), 'Serien'), serienTitel, staffel)
|
||||
: zuordnung !== null && zuordnung.titel.length > 0
|
||||
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
|
||||
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
|
||||
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so
|
||||
// lesen Jellyfin, Emby und Kodi sie als Zugaben und nicht als
|
||||
// zweiten Film. Der Ordner entsteht nur, wenn es wirklich Extras gibt.
|
||||
const extrasOrdner = join(fertigOrdner, 'extras')
|
||||
const zielFuer = (roh: string): string => {
|
||||
const rolle = rolleJeDatei.get(roh) ?? 'hauptfilm'
|
||||
return rolle === 'extra' ? extrasOrdner : fertigOrdner
|
||||
}
|
||||
if (rip.dateien.some((d) => zielFuer(d) === extrasOrdner)) {
|
||||
mkdirSync(extrasOrdner, { recursive: true })
|
||||
}
|
||||
|
||||
const fertigeDateien: string[] = []
|
||||
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||
// Episoden-Zuordnung weiter unten.
|
||||
const folgenSekunden: number[] = []
|
||||
for (let i = 0; i < rip.dateien.length; i++) {
|
||||
const eingabe = rip.dateien[i]
|
||||
const ausgabe = join(fertigOrdner, basename(eingabe))
|
||||
const ausgabe = join(zielFuer(eingabe), basename(eingabe))
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({
|
||||
@@ -270,6 +420,31 @@ export class Pipeline {
|
||||
return
|
||||
}
|
||||
fertigeDateien.push(encode.ausgabe)
|
||||
if ((rolleJeDatei.get(eingabe) ?? 'hauptfilm') === 'folge') {
|
||||
folgenSekunden.push(dauerJeDatei.get(eingabe) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
|
||||
// Umbenannt wird NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als
|
||||
// falsch (die offene Wunde #395 von ARM war genau dieses Ratespiel).
|
||||
// Die Laufzeiten der Staffel kommen von TMDb; ohne sie bleibt es beim
|
||||
// MakeMKV-Namen, und Rippy SAGT das.
|
||||
const ablageMeldungen: string[] = []
|
||||
if (alsSerie && folgenSekunden.length > 0) {
|
||||
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
|
||||
const laufzeiten =
|
||||
tmdbId !== null ? ((await this.umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
|
||||
const episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
||||
if (episoden !== null) {
|
||||
ablageMeldungen.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
|
||||
} else {
|
||||
ablageMeldungen.push(
|
||||
laufzeiten.length === 0
|
||||
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen.'
|
||||
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
|
||||
|
||||
@@ -26,12 +26,14 @@ import {
|
||||
listOptischeLaufwerke,
|
||||
} from './laufwerk/disc'
|
||||
import { DiscWache } from './laufwerk/wache'
|
||||
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
|
||||
import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale'
|
||||
import { OmdbClient } from './metadaten/omdb'
|
||||
import { TmdbClient } from './metadaten/tmdb'
|
||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
import { Datenbank } from './speicher/db'
|
||||
import { titelInfoLesen, ABGELOEST } from './rip/makemkv'
|
||||
import { titelVorauswahl } from './rip/titelwahl'
|
||||
import { finden } from './werkzeuge/katalog'
|
||||
|
||||
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||
@@ -94,15 +96,21 @@ let wache: DiscWache | null = null
|
||||
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
||||
const discZuordnungen = new Map<string, Zuordnung>()
|
||||
const discFingerprints = new Map<string, string>()
|
||||
// Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl) —
|
||||
// das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt.
|
||||
const discMerkmale = new Map<string, DiscTitel>()
|
||||
|
||||
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
||||
try {
|
||||
const gefunden = discTitel(geraetepfad)
|
||||
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
||||
discFingerprints.set(id, fingerprint)
|
||||
discMerkmale.set(id, gefunden)
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
||||
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
|
||||
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
|
||||
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen.
|
||||
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { istSerie: gefunden.istSerie })
|
||||
discZuordnungen.set(id, zuordnung)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
@@ -118,6 +126,9 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
|
||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
||||
discNr: gefunden.discNr,
|
||||
staffel: gefunden.staffel,
|
||||
folgen: gefunden.folgen,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
@@ -203,6 +214,10 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
|
||||
backdropPfad: zuordnung.meta.backdropPfad,
|
||||
beschreibung: zuordnung.meta.beschreibung,
|
||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
||||
// Eigene Titelwahl ändert nichts daran, welche Disc der Ausgabe hier liegt.
|
||||
discNr: discMerkmale.get(id)?.discNr ?? null,
|
||||
staffel: discMerkmale.get(id)?.staffel ?? null,
|
||||
folgen: discMerkmale.get(id)?.folgen ?? 0,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
@@ -227,6 +242,7 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
discZuordnungen.delete(ereignis.geraet.id)
|
||||
discFingerprints.delete(ereignis.geraet.id)
|
||||
discMerkmale.delete(ereignis.geraet.id)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
@@ -239,6 +255,9 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
||||
backdropPfad: '',
|
||||
beschreibung: '',
|
||||
schonGerippt: null,
|
||||
discNr: null,
|
||||
staffel: null,
|
||||
folgen: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -261,6 +280,95 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
||||
// ── Rippen (W-2) ────────────────────────────────────────────────────────
|
||||
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
|
||||
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
|
||||
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
|
||||
// Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und
|
||||
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
|
||||
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
|
||||
// Zeit nicht stumm dasteht (R2).
|
||||
// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf
|
||||
// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam
|
||||
// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf
|
||||
// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon
|
||||
// auf derselben Disc bremsen sich außerdem gegenseitig aus.
|
||||
const titelAbbrueche = new Map<string, AbortController>()
|
||||
let titelLaufZaehler = 0
|
||||
const titelLaufNummern = new Map<string, number>()
|
||||
|
||||
async function titelLesen(id: string): Promise<void> {
|
||||
const leer = { id, titel: [], einordnung: '', unsicher: false }
|
||||
if (ripVerwaltung.laeuft(id)) {
|
||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } })
|
||||
return
|
||||
}
|
||||
const geraet = geraetVon(id)
|
||||
if (geraet === null || laufwerkApi === null) {
|
||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } })
|
||||
return
|
||||
}
|
||||
const geraetepfad = geraeteInfo(laufwerkApi, geraet).pfad
|
||||
const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
if (programm.length === 0) {
|
||||
anFenster({
|
||||
art: 'disc-titel',
|
||||
stand: { ...leer, laeuft: false, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' },
|
||||
})
|
||||
return
|
||||
}
|
||||
// Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei,
|
||||
// und seine Antwort wird verworfen (nur die neueste Nummer zählt).
|
||||
titelAbbrueche.get(id)?.abort()
|
||||
const abbruch = new AbortController()
|
||||
titelAbbrueche.set(id, abbruch)
|
||||
const meineNummer = ++titelLaufZaehler
|
||||
titelLaufNummern.set(id, meineNummer)
|
||||
const nochAktuell = (): boolean => titelLaufNummern.get(id) === meineNummer
|
||||
|
||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: true, fehler: '' } })
|
||||
try {
|
||||
const info = await titelInfoLesen(programm, geraetepfad, 300_000, abbruch.signal)
|
||||
if (!nochAktuell()) return
|
||||
const merkmale = discMerkmale.get(id)
|
||||
const wahl = titelVorauswahl(info.titel, {
|
||||
istSerie: merkmale?.istSerie ?? false,
|
||||
folgen: merkmale?.folgen ?? 0,
|
||||
})
|
||||
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
|
||||
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
|
||||
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
|
||||
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
|
||||
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
|
||||
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
|
||||
anFenster({
|
||||
art: 'disc-titel',
|
||||
stand: {
|
||||
id,
|
||||
laeuft: false,
|
||||
titel: wahl.titel,
|
||||
einordnung: wahl.einordnung,
|
||||
unsicher: wahl.unsicher,
|
||||
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
|
||||
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
|
||||
// der alte Lauf überschrieb sonst die längst gelieferte Titelliste.
|
||||
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return
|
||||
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
|
||||
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
||||
const grund = String(fehler).includes('keine Antwort nach')
|
||||
? '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.'
|
||||
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: grund } })
|
||||
} finally {
|
||||
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
|
||||
if (nochAktuell()) {
|
||||
titelAbbrueche.delete(id)
|
||||
titelLaufNummern.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ablageOrdner(): string {
|
||||
const eingestellt = db?.einstellungLesen('ablage')
|
||||
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
|
||||
@@ -320,6 +428,21 @@ const ripVerwaltung = new Pipeline({
|
||||
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
||||
discAngaben: (id) => {
|
||||
const m = discMerkmale.get(id)
|
||||
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
|
||||
},
|
||||
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
|
||||
episodenLaufzeiten: async (tmdbId, staffel) => {
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
if (!tmdb.verfuegbar) return []
|
||||
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
|
||||
const paare: Array<[number, number]> = []
|
||||
for (const ep of antwort?.episodes ?? []) {
|
||||
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
|
||||
}
|
||||
return paare
|
||||
},
|
||||
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||
bibliothekEintragen: (eintrag) => {
|
||||
try {
|
||||
@@ -559,15 +682,31 @@ process.parentPort.on('message', (ereignis) => {
|
||||
if (fensterPort === null) return
|
||||
fensterPort.on('message', (portEreignis) => {
|
||||
const frage: unknown = portEreignis.data
|
||||
if (!istFensterNachricht(frage)) return
|
||||
if (!istFensterNachricht(frage)) {
|
||||
// R4: Eine verworfene Nachricht verschwindet NICHT still. Genau
|
||||
// das kostete am 01.09.2026 eine Runde — 'titel-lesen' fehlte im
|
||||
// Prüfer, der Kern warf sie weg, und im Fenster stand ewig
|
||||
// „Rippy liest die Titel …", ohne dass irgendwo etwas stand.
|
||||
const art =
|
||||
typeof frage === 'object' && frage !== null
|
||||
? String((frage as Record<string, unknown>).art)
|
||||
: typeof frage
|
||||
console.error(`[kern] Fenster-Nachricht verworfen (unbekannt): ${art}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Nachricht „${art}" wurde verworfen — sie ist dem Kern unbekannt.` })
|
||||
return
|
||||
}
|
||||
if (frage.art === 'ping') {
|
||||
anFenster({ art: 'pong', zeit: frage.zeit })
|
||||
} else if (frage.art === 'auswerfen') {
|
||||
void auswerfen(frage.id)
|
||||
} else if (frage.art === 'titel-lesen') {
|
||||
void titelLesen(frage.id)
|
||||
} else if (frage.art === 'rip-start') {
|
||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
|
||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? []))
|
||||
} else if (frage.art === 'rip-abbruch') {
|
||||
ripVerwaltung.abbrechen(frage.id)
|
||||
} else if (frage.art === 'rip-ueberspringen') {
|
||||
ripVerwaltung.ueberspringen(frage.id)
|
||||
} else if (frage.art === 'metadaten-suchen') {
|
||||
void metadatenSuchen(frage.id, frage.text)
|
||||
} else if (frage.art === 'zuordnung-setzen') {
|
||||
|
||||
@@ -105,29 +105,118 @@ export function udfVolumeLabel(geraetepfad: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — bdmt_eng bevorzugt,
|
||||
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
||||
export function titelAusBdmt(wurzel: string): string {
|
||||
/** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von
|
||||
* dieser Datei NUR den Titel — das Inhaltsverzeichnis daneben ist der
|
||||
* stärkere Hinweis und lag ungelesen da. An SPARTACUS_GOTA_D2 gemessen:
|
||||
*
|
||||
* <di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>
|
||||
* <di:titleName titleNumber="1">EP 1</di:titleName>
|
||||
* <di:titleName titleNumber="2">EP 2</di:titleName>
|
||||
* <di:titleName titleNumber="51">DUB GER 1</di:titleName> … usw.
|
||||
*
|
||||
* Die Disc sagt damit selbst, dass sie eine SERIE mit zwei Folgen trägt.
|
||||
* Das ist ein Beweis, keine Vermutung — und schlägt jede Titel-Raterei. */
|
||||
export interface BdmtInhalt {
|
||||
/** <di:name> — der Studio-Titel im Klartext; '' wenn keiner dasteht. */
|
||||
titel: string
|
||||
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
|
||||
eintraege: string[]
|
||||
}
|
||||
|
||||
/** Liest die bdmt-Datei EINMAL — bdmt_eng bevorzugt. Regex statt
|
||||
* XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
||||
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
|
||||
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
|
||||
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
|
||||
const leer: BdmtInhalt = { titel: '', eintraege: [] }
|
||||
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
||||
if (!existsSync(metaOrdner)) return ''
|
||||
if (!existsSync(metaOrdner)) return leer
|
||||
let kandidaten: string[]
|
||||
try {
|
||||
kandidaten = readdirSync(metaOrdner).sort()
|
||||
} catch {
|
||||
return ''
|
||||
return leer
|
||||
}
|
||||
const bdmt =
|
||||
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
||||
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||
if (bdmt === undefined) return ''
|
||||
if (bdmt === undefined) return leer
|
||||
let inhalt: string
|
||||
try {
|
||||
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||
} catch {
|
||||
return ''
|
||||
return leer
|
||||
}
|
||||
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||
return treffer === null ? '' : treffer[1].trim()
|
||||
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||
const eintraege: string[] = []
|
||||
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
|
||||
const text = t[1].trim()
|
||||
if (text.length > 0) eintraege.push(text)
|
||||
}
|
||||
return { titel: name === null ? '' : name[1].trim(), eintraege }
|
||||
}
|
||||
|
||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
|
||||
export function titelAusBdmt(wurzel: string): string {
|
||||
return inhaltAusBdmt(wurzel).titel
|
||||
}
|
||||
|
||||
/** Sieht dieser Inhaltseintrag aus wie eine Folge? Bewusst ENG: An der
|
||||
* echten Disc stehen neben `EP 1`/`EP 2` auch `DUB GER 1`, `MM ITA` und
|
||||
* `101 LOGO` — Tonspuren, Trailer und Warnhinweise sind keine Folgen. */
|
||||
export function istFolgenName(name: string): boolean {
|
||||
return /^(?:ep|episode|folge)\s*0*\d{1,3}$/i.test(name.trim())
|
||||
}
|
||||
|
||||
/** Wie viele Einträge des Inhaltsverzeichnisses sind Folgen? */
|
||||
export function folgenZahl(eintraege: readonly string[]): number {
|
||||
return eintraege.filter(istFolgenName).length
|
||||
}
|
||||
|
||||
// Verpackungs-Zusätze am ENDE eines Disc-Titels. Was hier NICHT steht,
|
||||
// steht mit Absicht nicht hier:
|
||||
// * `Teil`/`Part` — „Kill Bill Teil 2" ist ein Filmtitel, keine Disc-Nummer.
|
||||
// * eine nackte Zahl — sonst würde aus „Rocky 2" die „Rocky", Disc 2.
|
||||
// Die Ein-Buchstaben-Formen `d`/`s` verlangen einen TRENNER davor: ohne ihn
|
||||
// würde „Ocean's 11" an der Apostroph-Wortgrenze zu „Ocean'", Staffel 11.
|
||||
const TRENNER = String.raw`[\s\-–—_:,.]`
|
||||
const DISC_MUSTER = new RegExp(`${TRENNER}+(?:disc|disk|dvd|scheibe|d)\\s*0*(\\d{1,2})\\s*$`, 'i')
|
||||
const STAFFEL_MUSTER = new RegExp(`${TRENNER}+(?:season|staffel|series|s)\\s*0*(\\d{1,2})\\s*$`, 'i')
|
||||
|
||||
export interface DiscZusatz {
|
||||
/** Der Titel ohne die erkannten Verpackungs-Zusätze. */
|
||||
titel: string
|
||||
/** Nummer der Disc innerhalb der Ausgabe — null, wenn keine dasteht. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer — null, wenn keine dasteht. */
|
||||
staffel: number | null
|
||||
}
|
||||
|
||||
/** `Spartacus: Gods Of The Arena - Disc 2` → Titel + Disc 2 (pur).
|
||||
* Abtrennen UND merken: Der Suchtitel wird brauchbar, und die Nummer
|
||||
* braucht die Ablage später sowieso, um Folgen richtig einzusortieren. */
|
||||
export function discZusatzAbtrennen(titel: string): DiscZusatz {
|
||||
let rest = titel.trim()
|
||||
let discNr: number | null = null
|
||||
let staffel: number | null = null
|
||||
// Mehrfach, weil ein Label wie `SHOW_S1_D2` beide Zusätze hintereinander
|
||||
// trägt; die Schleife endet, sobald hinten nichts Bekanntes mehr steht.
|
||||
for (let runde = 0; runde < 4; runde++) {
|
||||
const disc = DISC_MUSTER.exec(rest)
|
||||
if (disc !== null && discNr === null && Number(disc[1]) > 0) {
|
||||
discNr = Number(disc[1])
|
||||
rest = rest.slice(0, disc.index)
|
||||
continue
|
||||
}
|
||||
const staffelTreffer = STAFFEL_MUSTER.exec(rest)
|
||||
if (staffelTreffer !== null && staffel === null && Number(staffelTreffer[1]) > 0) {
|
||||
staffel = Number(staffelTreffer[1])
|
||||
rest = rest.slice(0, staffelTreffer.index)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
|
||||
}
|
||||
|
||||
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||
@@ -138,22 +227,50 @@ export function discWurzel(geraetepfad: string): string {
|
||||
}
|
||||
|
||||
export interface DiscTitel {
|
||||
/** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */
|
||||
titel: string
|
||||
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||
quelle: 'bdmt' | 'label' | ''
|
||||
/** Nummer der Disc in der Ausgabe (aus Titel/Label) — null wenn keine. */
|
||||
discNr: number | null
|
||||
/** Staffel-Nummer aus Titel/Label — null wenn keine. */
|
||||
staffel: number | null
|
||||
/** Folgen im bdmt-Inhaltsverzeichnis (0, wenn es keines gibt). */
|
||||
folgen: number
|
||||
/** Die Disc bezeugt selbst, dass sie Folgen trägt — ab zwei ist es
|
||||
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
|
||||
istSerie: boolean
|
||||
}
|
||||
|
||||
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der
|
||||
* BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */
|
||||
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als
|
||||
* bewiesen gilt — eine einzelne könnte auch eine Zugabe sein. */
|
||||
export const FOLGEN_FUER_SERIE = 2
|
||||
|
||||
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): DiscTitel {
|
||||
const zusatz = discZusatzAbtrennen(titel)
|
||||
return {
|
||||
titel,
|
||||
quelle,
|
||||
discNr: zusatz.discNr,
|
||||
staffel: zusatz.staffel,
|
||||
folgen,
|
||||
istSerie: folgen >= FOLGEN_FUER_SERIE,
|
||||
}
|
||||
}
|
||||
|
||||
/** Alles, was die Disc über sich selbst sagt: BDMT zuerst, sonst Label.
|
||||
* Der BDMT-Titel wird NICHT normalisiert ('2.22' würde zu '2 22'). */
|
||||
export function discTitel(geraetepfad: string): DiscTitel {
|
||||
const wurzel = discWurzel(geraetepfad)
|
||||
let folgen = 0
|
||||
if (wurzel.length > 0) {
|
||||
const bdmt = titelAusBdmt(wurzel)
|
||||
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
|
||||
const bdmt = inhaltAusBdmt(wurzel)
|
||||
folgen = folgenZahl(bdmt.eintraege)
|
||||
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen)
|
||||
}
|
||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
|
||||
return { titel: '', quelle: '' }
|
||||
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
|
||||
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE }
|
||||
}
|
||||
|
||||
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
||||
@@ -163,25 +280,42 @@ export function discFingerprint(geraetepfad: string, groesseBytes: number): stri
|
||||
return `${label}|${groesseBytes}`
|
||||
}
|
||||
|
||||
/** Höchstens so viele Suchvarianten — jede kostet API-Anfragen. */
|
||||
export const HOECHSTENS_KANDIDATEN = 7
|
||||
|
||||
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
|
||||
* Klammern, dann hintere Worte strippen. */
|
||||
* ARM-Lehre) — von SPEZIFISCH nach vage:
|
||||
*
|
||||
* voller Titel → ohne Disc-/Staffel-Zusatz → ohne Doppelpunkt →
|
||||
* ohne Klammern → hintere Worte strippen → Doppelpunkt-Vorspann
|
||||
*
|
||||
* Die Reihenfolge ist teuer gelernt (01.09.2026, echte Disc): Der
|
||||
* Doppelpunkt-Vorspann stand auf Platz 2 und wirft am meisten weg
|
||||
* ('Spartacus: Gods Of The Arena' → 'Spartacus'). TMDb hat einen Film,
|
||||
* der wörtlich so heißt — die richtige Serie wurde nie gefragt. Er
|
||||
* steht jetzt GANZ hinten; die Sicherheit deckelt ihn zusätzlich
|
||||
* (MINDEST_ABDECKUNG in zuordnung.ts). */
|
||||
export function titelKandidaten(titel: string): string[] {
|
||||
const kandidaten: string[] = []
|
||||
const nachlaufend = new RegExp(`${TRENNER}+$`)
|
||||
const merke = (t: string): void => {
|
||||
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '')
|
||||
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(nachlaufend, '').replace(/^[ .]+/, '')
|
||||
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
|
||||
}
|
||||
merke(titel)
|
||||
if (titel.includes(':')) {
|
||||
merke(titel.split(':', 1)[0])
|
||||
merke(titel.replaceAll(':', ''))
|
||||
}
|
||||
merke(titel.replace(/\([^)]*\)/g, ''))
|
||||
let worte = titel.split(/\s+/).filter(Boolean)
|
||||
while (worte.length > 1 && kandidaten.length < 6) {
|
||||
// Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist
|
||||
// der Kandidat, der die Serie findet, nicht der gestutzte Vorspann.
|
||||
const ohneZusatz = discZusatzAbtrennen(titel).titel
|
||||
merke(ohneZusatz)
|
||||
// Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn:
|
||||
// beide Schreibweisen fragen.
|
||||
if (ohneZusatz.includes(':')) merke(ohneZusatz.replaceAll(':', ''))
|
||||
merke(ohneZusatz.replace(/\([^)]*\)/g, ''))
|
||||
let worte = ohneZusatz.split(/\s+/).filter(Boolean)
|
||||
while (worte.length > 1 && kandidaten.length < HOECHSTENS_KANDIDATEN - 1) {
|
||||
worte = worte.slice(0, -1)
|
||||
merke(worte.join(' '))
|
||||
}
|
||||
if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0])
|
||||
return kandidaten
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//
|
||||
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
||||
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
|
||||
import { titelKandidaten } from './discmerkmale'
|
||||
import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
|
||||
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
|
||||
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||
|
||||
@@ -94,17 +94,21 @@ const UNBEKANNT = (titel: string): Zuordnung => ({
|
||||
},
|
||||
})
|
||||
|
||||
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
||||
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
||||
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
||||
export function wortUeberlappung(a: string, b: string): number {
|
||||
const worte = (t: string): Set<string> =>
|
||||
new Set(
|
||||
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
||||
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
||||
function worte(t: string): Set<string> {
|
||||
return new Set(
|
||||
t
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length > 0),
|
||||
)
|
||||
}
|
||||
|
||||
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
||||
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
||||
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
||||
export function wortUeberlappung(a: string, b: string): number {
|
||||
const wa = worte(a)
|
||||
const wb = worte(b)
|
||||
if (wa.size === 0 || wb.size === 0) return 0
|
||||
@@ -113,59 +117,130 @@ export function wortUeberlappung(a: string, b: string): number {
|
||||
return gemeinsam / (wa.size + wb.size - gemeinsam)
|
||||
}
|
||||
|
||||
/** Gleicher Titel, wenn man Satzzeichen und Groß-/Kleinschreibung
|
||||
* weglässt. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb
|
||||
* schreibt ihn: 'Spartacus Gods Of The Arena' gegen 'Spartacus: Gods of
|
||||
* the Arena' war zeichengenau verglichen kein Treffer — der exakt
|
||||
* richtige Fund fiel an einem Satzzeichen durch (01.09.2026 gemessen). */
|
||||
export function gleichBedeutend(a: string, b: string): boolean {
|
||||
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
|
||||
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
|
||||
const nackt = (t: string): string =>
|
||||
t
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length > 0)
|
||||
.join(' ')
|
||||
const na = nackt(a)
|
||||
return na.length > 0 && na === nackt(b)
|
||||
}
|
||||
|
||||
/** Wie viel vom Disc-Titel steckt noch in dieser Suchvariante (0–1)? */
|
||||
export function abdeckung(discTitel: string, kandidat: string): number {
|
||||
const ganz = worte(discTitel)
|
||||
if (ganz.size === 0) return 0
|
||||
let drin = 0
|
||||
for (const w of worte(kandidat)) if (ganz.has(w)) drin += 1
|
||||
return drin / ganz.size
|
||||
}
|
||||
|
||||
/** Sicherheit eines Treffers, den das UI als Vorschlag zeigt und nachfragt. */
|
||||
export const VORSCHLAG = 0.6
|
||||
|
||||
/** Darunter ist ein Treffer GERATEN, nicht gefunden — und wird zum
|
||||
* Vorschlag herabgestuft, egal wie wörtlich er aussieht.
|
||||
*
|
||||
* Der Grund, an der echten Disc gemessen (01.09.2026): Aus
|
||||
* 'Spartacus: Gods Of The Arena - Disc 2' blieb nach dem Stutzen die
|
||||
* Frage 'Spartacus' übrig — sechs von sieben Wörtern weggeworfen. TMDb
|
||||
* antwortete mit einem wörtlichen Filmtreffer, und Rippy meldete mit
|
||||
* 95 % Sicherheit den falschen Film. Die alte Kaskade maß nur die ART
|
||||
* des Treffers, nie die QUALITÄT der Frage. */
|
||||
export const MINDEST_ABDECKUNG = 0.5
|
||||
|
||||
export interface DiscHinweis {
|
||||
/** Die Disc bezeugt über ihr EIGENES Inhaltsverzeichnis, dass sie Folgen
|
||||
* trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige
|
||||
* aus: Was bewiesen ist, muss nicht mehr geraten werden. */
|
||||
istSerie?: boolean
|
||||
}
|
||||
|
||||
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
|
||||
*
|
||||
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → (nur beim
|
||||
* vollen Titel) spezifisch Film → spezifisch Serie. Erst dann der nächste,
|
||||
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
|
||||
* Beim Disc-Titel „Spartacus Gods Of The Arena" erschlug der stark
|
||||
* gekürzte Kandidat „Spartacus" mit einem wörtlichen Treffer (Film von
|
||||
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
|
||||
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → spezifisch
|
||||
* Film → spezifisch Serie. Erst dann der nächste, ärmere Kandidat.
|
||||
*
|
||||
* Drei Sicherungen, alle an echten Discs bezahlt:
|
||||
*
|
||||
* * `hinweis.istSerie` schaltet die Film-Zweige ganz ab.
|
||||
* * Jede Sicherheit wird an der ABDECKUNG gedeckelt (MINDEST_ABDECKUNG):
|
||||
* Wer den halben Titel wegwerfen musste, um zu treffen, hat geraten —
|
||||
* das ist ein Vorschlag, kein Fund. Der Fall vom 01.09.2026:
|
||||
* 'Spartacus: Gods Of The Arena - Disc 2' wurde über die Restfrage
|
||||
* 'Spartacus' zum Film von 1960, mit 95 % Sicherheit gemeldet.
|
||||
* * Die spezifischen Treffer (0,80) verlangen einen VOLLSTÄNDIGEN
|
||||
* Kandidaten — er muss noch jedes Wort des Titels tragen (Zusätze wie
|
||||
* '- Disc 2' zählen nicht mit). Vorher hing das an „Kandidat Nummer
|
||||
* 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat
|
||||
* Nummer 1 und fiel durch dieses Raster.
|
||||
*/
|
||||
export async function zuordnen(
|
||||
discTitel: string,
|
||||
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
||||
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
||||
hinweis: DiscHinweis = {},
|
||||
): Promise<Zuordnung> {
|
||||
const titel = discTitel.trim()
|
||||
if (titel.length === 0) return UNBEKANNT('')
|
||||
|
||||
const nurSerie = hinweis.istSerie === true
|
||||
// Maßstab für die Abdeckung ist der Titel OHNE Verpackungs-Zusatz:
|
||||
// '- Disc 2' ist Rauschen und darf einer guten Frage nicht angelastet werden.
|
||||
const massstab = discZusatzAbtrennen(titel).titel || titel
|
||||
const kandidaten = titelKandidaten(titel)
|
||||
if (kandidaten.length === 0) kandidaten.push(titel)
|
||||
|
||||
let ersteTreffer: FilmTreffer[] = []
|
||||
let ersteFilme: FilmTreffer[] = []
|
||||
let ersteSerien: FilmTreffer[] = []
|
||||
|
||||
for (let i = 0; i < kandidaten.length; i++) {
|
||||
const kandidat = kandidaten[i]
|
||||
const filme = (await tmdb.searchMovie(kandidat)) ?? []
|
||||
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
|
||||
for (const kandidat of kandidaten) {
|
||||
const deckung = abdeckung(massstab, kandidat)
|
||||
const vollstaendig = deckung >= 1
|
||||
/** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */
|
||||
const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG))
|
||||
|
||||
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat)) ?? [])
|
||||
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
||||
|
||||
// Wörtlicher Filmtreffer (0,95)
|
||||
for (const film of filme) {
|
||||
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||
if (gleichBedeutend(film.title ?? '', kandidat)) {
|
||||
const details = await tmdb.movieDetails(film.id)
|
||||
if (details !== null) {
|
||||
const meta = ausDetails('movie', film.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta }
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serien = (await tmdb.searchTv(kandidat)) ?? []
|
||||
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
||||
|
||||
// Wörtlicher Serientreffer (0,90)
|
||||
for (const serie of serien) {
|
||||
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
||||
if (gleichBedeutend(serie.name ?? '', kandidat)) {
|
||||
const details = await tmdb.tvDetails(serie.id)
|
||||
if (details !== null) {
|
||||
const meta = ausDetails('tv', serie.id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta }
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80):
|
||||
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
|
||||
// jemand, der weiß, was er sucht (der Evangelion-Fund).
|
||||
if (i === 0) {
|
||||
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
|
||||
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
|
||||
// (der Evangelion-Fund).
|
||||
if (vollstaendig) {
|
||||
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
||||
const details = await tmdb.movieDetails(filme[0].id)
|
||||
if (details !== null) {
|
||||
@@ -189,6 +264,9 @@ export async function zuordnen(
|
||||
for (const kandidat of kandidaten) {
|
||||
const treffer = await omdb.lookup(kandidat)
|
||||
if (treffer !== null) {
|
||||
// Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen,
|
||||
// was sie ist.
|
||||
if (nurSerie && treffer.type !== 'tv') continue
|
||||
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
|
||||
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
||||
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
||||
@@ -208,17 +286,28 @@ export async function zuordnen(
|
||||
if (deutsch['type'] === 'tv') meta.typ = 'tv'
|
||||
}
|
||||
}
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||
// Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
|
||||
// ist ein Vorschlag, egal von welchem Dienst er kommt.
|
||||
const sicher = abdeckung(massstab, kandidat) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach.
|
||||
if (ersteTreffer.length > 0) {
|
||||
const details = await tmdb.movieDetails(ersteTreffer[0].id)
|
||||
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
|
||||
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
|
||||
if (ersteFilme.length > 0) {
|
||||
const details = await tmdb.movieDetails(ersteFilme[0].id)
|
||||
if (details !== null) {
|
||||
const meta = ausDetails('movie', ersteTreffer[0].id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
|
||||
const meta = ausDetails('movie', ersteFilme[0].id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
|
||||
}
|
||||
}
|
||||
if (ersteSerien.length > 0) {
|
||||
const details = await tmdb.tvDetails(ersteSerien[0].id)
|
||||
if (details !== null) {
|
||||
const meta = ausDetails('tv', ersteSerien[0].id, details)
|
||||
return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
KRITISCHE_CODES,
|
||||
KRITISCHE_TEXTE,
|
||||
LESEFEHLER_MARKE,
|
||||
offsetAusMeldung,
|
||||
parseMsg,
|
||||
parseStreamInfo,
|
||||
parseTitelInfo,
|
||||
@@ -38,18 +39,41 @@ export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: str
|
||||
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
||||
}
|
||||
|
||||
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
|
||||
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
|
||||
* zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer
|
||||
* von einem echten Defekt zu unterscheiden. */
|
||||
export const HAENGT_AB = 12
|
||||
|
||||
/** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der
|
||||
* Aufrufer schweigt dazu — abgelöst ist kein Fehler, sondern Absicht. */
|
||||
export const ABGELOEST = 'titel-lauf-abgeloest'
|
||||
|
||||
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
||||
export class RipAuswertung {
|
||||
prozent = -1
|
||||
letzteMeldung = ''
|
||||
lesefehler = false
|
||||
kritischeMeldungen: string[] = []
|
||||
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
|
||||
haengtBei = ''
|
||||
/** Wie oft dieselbe Stelle schon gemeldet wurde. */
|
||||
haengtWiederholungen = 0
|
||||
|
||||
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
|
||||
* der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */
|
||||
haengt(): boolean {
|
||||
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
|
||||
}
|
||||
|
||||
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
||||
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
||||
const p = progressAusPrgv(zeile)
|
||||
if (p >= 0) {
|
||||
if (p === this.prozent) return {}
|
||||
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
|
||||
this.haengtBei = ''
|
||||
this.haengtWiederholungen = 0
|
||||
this.prozent = p
|
||||
return { prozent: p }
|
||||
}
|
||||
@@ -57,6 +81,16 @@ export class RipAuswertung {
|
||||
if (meldung === null) return {}
|
||||
this.letzteMeldung = meldung.text
|
||||
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
||||
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
|
||||
const stelle = offsetAusMeldung(meldung.text)
|
||||
if (stelle.length > 0) {
|
||||
if (stelle === this.haengtBei) {
|
||||
this.haengtWiederholungen += 1
|
||||
} else {
|
||||
this.haengtBei = stelle
|
||||
this.haengtWiederholungen = 1
|
||||
}
|
||||
}
|
||||
if (meldung.code in KRITISCHE_CODES) {
|
||||
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
|
||||
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
|
||||
@@ -103,6 +137,15 @@ export class ZeilenLeser {
|
||||
}
|
||||
|
||||
export interface DiscInfo {
|
||||
/** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam — die
|
||||
* kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief.
|
||||
*
|
||||
* Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 („Das
|
||||
* Programm konnte keine verwendbaren optischen Laufwerke finden")
|
||||
* antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
|
||||
* stünde im Fenster nur „keine Titel gefunden" — und niemand wüsste,
|
||||
* dass das Laufwerk gemeint ist und nicht die Disc. */
|
||||
ursachen: string[]
|
||||
titel: TitelInfo[]
|
||||
streams: Map<number, TitelStreams>
|
||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||
@@ -114,6 +157,7 @@ export function titelInfoLesen(
|
||||
programm: string,
|
||||
geraetepfad: string,
|
||||
timeoutMs = 300_000,
|
||||
abbruch?: AbortSignal,
|
||||
): Promise<DiscInfo> {
|
||||
return new Promise((fertig, scheitert) => {
|
||||
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
||||
@@ -127,16 +171,37 @@ export function titelInfoLesen(
|
||||
kind.kill()
|
||||
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
||||
}, timeoutMs)
|
||||
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
|
||||
// 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
|
||||
// die längst gelieferte Titelliste).
|
||||
const aufgeben = (): void => {
|
||||
clearTimeout(zeiger)
|
||||
kind.kill()
|
||||
scheitert(new Error(ABGELOEST))
|
||||
}
|
||||
abbruch?.addEventListener('abort', aufgeben, { once: true })
|
||||
kind.on('error', (fehler) => {
|
||||
clearTimeout(zeiger)
|
||||
abbruch?.removeEventListener('abort', aufgeben)
|
||||
scheitert(fehler)
|
||||
})
|
||||
kind.on('close', () => {
|
||||
clearTimeout(zeiger)
|
||||
abbruch?.removeEventListener('abort', aufgeben)
|
||||
const ausgabe = textVon(Buffer.concat(stuecke))
|
||||
const titel = parseTitelInfo(ausgabe)
|
||||
const streams = parseStreamInfo(ausgabe)
|
||||
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
||||
// Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
|
||||
const ursachen: string[] = []
|
||||
for (const zeile of ausgabe.split('\n')) {
|
||||
const meldung = parseMsg(zeile)
|
||||
if (meldung === null) continue
|
||||
if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
|
||||
ursachen.push(KRITISCHE_CODES[meldung.code])
|
||||
}
|
||||
}
|
||||
fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -154,6 +219,9 @@ export interface RipLaufOptionen {
|
||||
titel: string
|
||||
aufFortschritt?: (prozent: number) => void
|
||||
aufMeldung?: (code: number, text: string) => void
|
||||
/** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) —
|
||||
* das ist ein Disc-Defekt, kein Fortschrittsproblem. */
|
||||
aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void
|
||||
abbruch?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -179,12 +247,22 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
||||
}
|
||||
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
|
||||
|
||||
// Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder
|
||||
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
|
||||
let klemmteVorher = false
|
||||
const zeile = (z: string): void => {
|
||||
const ergebnis = auswertung.verarbeite(z)
|
||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||
if (ergebnis.meldung !== undefined) {
|
||||
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
|
||||
}
|
||||
const klemmt = auswertung.haengt()
|
||||
if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) {
|
||||
klemmteVorher = klemmt
|
||||
optionen.aufHaengt?.(
|
||||
klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
|
||||
@@ -249,6 +249,26 @@ export function episodenTitel(dauern: Map<number, number>, episodenMinuten: numb
|
||||
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
|
||||
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
|
||||
|
||||
/** Die STELLE, an der ein Lesefehler auftrat. MakeMKV hängt sie ans Ende
|
||||
* der Meldung, in jeder Sprache als letzte Zahl in Anführungszeichen:
|
||||
*
|
||||
* … während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'
|
||||
* … while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'
|
||||
*
|
||||
* Deshalb wird die letzte gequotete Zahl gelesen und nicht das Wort davor
|
||||
* — das wäre von der MakeMKV-Sprache abhängig. '' wenn keine dasteht.
|
||||
*
|
||||
* Wozu (01.09.2026, an der Spartacus-Disc gesehen): Wiederholt sich
|
||||
* DIESELBE Stelle, ist das kein Fortschritt, sondern ein physischer
|
||||
* Defekt — MakeMKV versucht es dann stundenlang, das Laufwerk hämmert,
|
||||
* und Windows protokolliert einen fehlerhaften Block nach dem anderen. */
|
||||
export function offsetAusMeldung(text: string): string {
|
||||
// Mindestens zwei Ziffern: Titel- und Kapitelnummern stehen ebenfalls in
|
||||
// Anführungszeichen, ein Offset ist immer groß.
|
||||
const treffer = /'(\d{2,})'\s*$/.exec(text.trim())
|
||||
return treffer === null ? '' : treffer[1]
|
||||
}
|
||||
|
||||
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
|
||||
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
|
||||
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Welche Titel einer Disc will man wirklich? — PUR, ohne Laufwerk, damit
|
||||
// die Ampel das auf Linux prüfen kann.
|
||||
//
|
||||
// Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die
|
||||
// auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen,
|
||||
// bei filmen z. B. nur das hauptfeature oder auch die extras."
|
||||
//
|
||||
// Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte
|
||||
// IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen.
|
||||
//
|
||||
// Diese Datei RÄT NICHT INS BLAUE. Sie rechnet mit dem, was messbar ist
|
||||
// (Dauer, Größe, Kapitelzahl) und mit dem, was die Disc selbst bezeugt
|
||||
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
|
||||
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
|
||||
// zu schweigen.
|
||||
// Die Rolle ist EINE Definition für Kern und Fenster (gemeinsam/).
|
||||
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
import type { TitelInfo } from './parser'
|
||||
|
||||
export type { TitelRolle }
|
||||
|
||||
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
|
||||
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
|
||||
* standardmäßig für seine eigene Mindestlänge. */
|
||||
export const KURZ_S = 120
|
||||
|
||||
/** Folgen einer Staffel sind ähnlich lang — 45 bis 55 Minuten sind eine
|
||||
* Serie, 50 Minuten und 3 Minuten sind es nicht. */
|
||||
export const AEHNLICH = 0.15
|
||||
|
||||
/** Mehrere Titel dicht am längsten heißen fast immer „Seamless Branching"
|
||||
* bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl
|
||||
* nicht eindeutig, und Rippy sagt das. */
|
||||
export const FILM_GLEICHAUF = 0.05
|
||||
|
||||
/** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein
|
||||
* Sammeltitel („Alle Folgen am Stück"). Der ist der längste und würde
|
||||
* eine Film-Auswahl sonst gewinnen — und dann rippt Rippy alles doppelt. */
|
||||
export const SAMMEL_TOLERANZ = 0.1
|
||||
|
||||
export interface TitelVorschlag extends TitelInfo {
|
||||
/** Von Rippy vorausgewählt? */
|
||||
gewaehlt: boolean
|
||||
/** Als was Rippy diesen Titel einsortieren würde. */
|
||||
rolle: TitelRolle
|
||||
/** Warum — im Klartext, das Fenster zeigt es an. */
|
||||
grund: string
|
||||
}
|
||||
|
||||
export interface TitelWahl {
|
||||
titel: TitelVorschlag[]
|
||||
/** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */
|
||||
einordnung: string
|
||||
/** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */
|
||||
unsicher: boolean
|
||||
}
|
||||
|
||||
/** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */
|
||||
export interface DiscHinweis {
|
||||
istSerie: boolean
|
||||
/** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */
|
||||
folgen: number
|
||||
}
|
||||
|
||||
function dauerText(s: number): string {
|
||||
const m = Math.round(s / 60)
|
||||
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
|
||||
}
|
||||
|
||||
/** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */
|
||||
function istSammeltitel(kandidat: TitelInfo, alle: readonly TitelInfo[]): boolean {
|
||||
const kuerzere = alle.filter((t) => t.nr !== kandidat.nr && t.dauerS >= KURZ_S && t.dauerS < kandidat.dauerS)
|
||||
// Mit weniger als zwei Teilen gibt es nichts zu sammeln.
|
||||
if (kuerzere.length < 2) return false
|
||||
const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0)
|
||||
if (summe <= 0) return false
|
||||
return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Vorauswahl. Sie wählt VOR — entschieden wird im Fenster.
|
||||
*
|
||||
* Drei Fälle:
|
||||
* * Serie (von der Disc bezeugt): die längsten Titel, so viele wie das
|
||||
* Inhaltsverzeichnis Folgen nennt.
|
||||
* * Film: der längste Titel, der kein Sammeltitel ist.
|
||||
* * Unklar: nichts vorausgewählt — lieber fragen als raten.
|
||||
*/
|
||||
export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl {
|
||||
if (alle.length === 0) {
|
||||
return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true }
|
||||
}
|
||||
|
||||
const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr))
|
||||
const kandidaten = alle
|
||||
.filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr))
|
||||
.sort((a, b) => b.dauerS - a.dauerS)
|
||||
|
||||
const gewaehlt = new Set<number>()
|
||||
let einordnung: string
|
||||
let unsicher = false
|
||||
|
||||
if (kandidaten.length === 0) {
|
||||
einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.`
|
||||
unsicher = true
|
||||
} else if (hinweis.istSerie && hinweis.folgen >= 2) {
|
||||
const folgen = kandidaten.slice(0, hinweis.folgen)
|
||||
for (const t of folgen) gewaehlt.add(t.nr)
|
||||
const laengste = folgen[0].dauerS
|
||||
const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH)
|
||||
if (folgen.length < hinweis.folgen) {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else if (!passen) {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.`
|
||||
}
|
||||
} else {
|
||||
const laengster = kandidaten[0]
|
||||
const gleichauf = kandidaten.filter(
|
||||
(t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF,
|
||||
)
|
||||
if (gleichauf.length > 1) {
|
||||
// Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist
|
||||
// erfahrungsgemäß die Hauptfassung — sicher ist das nicht.
|
||||
const beste = [...gleichauf].sort((a, b) => b.kapitel - a.kapitel || a.nr - b.nr)[0]
|
||||
gewaehlt.add(beste.nr)
|
||||
einordnung = `${gleichauf.length} Titel sind fast gleich lang (${dauerText(laengster.dauerS)}) — Rippy hat den mit den meisten Kapiteln gewählt. Bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else {
|
||||
gewaehlt.add(laengster.nr)
|
||||
einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.`
|
||||
}
|
||||
}
|
||||
|
||||
const alsSerie = hinweis.istSerie && hinweis.folgen >= 2
|
||||
const titel: TitelVorschlag[] = alle.map((t) => ({
|
||||
...t,
|
||||
gewaehlt: gewaehlt.has(t.nr),
|
||||
// Gewählt UND Serie heißt Folge; gewählt und Film heißt Hauptfilm.
|
||||
// Alles andere ist eine Zugabe — wer sie mit anhakt, bekommt sie als
|
||||
// Extra abgelegt, nicht als zweiten Hauptfilm.
|
||||
rolle: gewaehlt.has(t.nr) ? (alsSerie ? 'folge' : 'hauptfilm') : 'extra',
|
||||
grund: gewaehlt.has(t.nr)
|
||||
? alsSerie
|
||||
? 'Folge'
|
||||
: 'Hauptinhalt'
|
||||
: sammel.has(t.nr)
|
||||
? 'Sammeltitel — enthält vermutlich alles am Stück'
|
||||
: t.dauerS < KURZ_S
|
||||
? 'zu kurz — Logo, Warnung oder Menü'
|
||||
: 'Zugabe',
|
||||
}))
|
||||
|
||||
return { titel, einordnung, unsicher }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// MESSUNG an einer ECHTEN Disc im Laufwerk — läuft nur auf Verlangen und
|
||||
// nur unter Windows (die CI-Ampel auf Linux überspringt sie):
|
||||
//
|
||||
// RIPPY_MESSUNG_DISC=G npx vitest run test/messung.disc-erkennung.test.ts --reporter=verbose
|
||||
//
|
||||
// Der Anlass (01.09.2026): Der Commander legte Disc 2 der Serie
|
||||
// „Spartacus: Gods of the Arena" ein, Rippy meldete den FILM „Spartacus"
|
||||
// von 1960. Gemessen am Laufwerk G: — Volume-Label SPARTACUS_GOTA_D2,
|
||||
// UDF, 33 759 100 928 Bytes. Die Disc trug die richtige Antwort die
|
||||
// ganze Zeit selbst mit sich: In G:\BDMV\META\DL\bdmt_eng.xml steht der
|
||||
// Studio-Titel MIT Disc-Nummer und ein Inhaltsverzeichnis, das `EP 1`
|
||||
// und `EP 2` nennt. Rippy las davon nur die erste Zeile.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const buchstabe = (process.env.RIPPY_MESSUNG_DISC ?? '').replace(/:$/, '').toUpperCase()
|
||||
const messen = /^[A-Z]$/.test(buchstabe) && process.platform === 'win32'
|
||||
|
||||
describe.skipIf(!messen)('Disc-Erkennung an der eingelegten Disc', () => {
|
||||
it('liest Titel, Verpackungs-Zusatz und Inhaltsverzeichnis', async () => {
|
||||
const { discTitel, discWurzel, inhaltAusBdmt, titelKandidaten } = await import(
|
||||
'../src/kern/metadaten/discmerkmale'
|
||||
)
|
||||
const geraetepfad = `\\\\.\\${buchstabe}:`
|
||||
const gefunden = discTitel(geraetepfad)
|
||||
|
||||
console.log(`MESSUNG Laufwerk ${buchstabe}: Titel "${gefunden.titel}" (Quelle: ${gefunden.quelle || 'keine'})`)
|
||||
console.log(
|
||||
`MESSUNG Disc ${gefunden.discNr ?? '—'} | Staffel ${gefunden.staffel ?? '—'} | ` +
|
||||
`Folgen ${gefunden.folgen} | Serie bewiesen: ${gefunden.istSerie}`,
|
||||
)
|
||||
const wurzel = discWurzel(geraetepfad)
|
||||
if (wurzel.length > 0) {
|
||||
console.log(`MESSUNG Inhaltsverzeichnis: ${inhaltAusBdmt(wurzel).eintraege.join(' | ') || '(keines)'}`)
|
||||
}
|
||||
console.log(`MESSUNG Suchvarianten: ${titelKandidaten(gefunden.titel).join(' -> ')}`)
|
||||
|
||||
// Eine eingelegte Disc muss sich benennen lassen — sonst ist die
|
||||
// Messung selbst kaputt und nicht die Erkennung.
|
||||
expect(gefunden.titel.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(messen)('Disc-Messung übersprungen', () => {
|
||||
it('läuft nur mit RIPPY_MESSUNG_DISC=<Laufwerksbuchstabe> unter Windows', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,9 @@ const FAELLE = [
|
||||
'Akira',
|
||||
'Evangelion 2.22',
|
||||
'Spartacus Gods Of The Arena',
|
||||
// Der echte Studio-Titel der Disc vom 01.09.2026 — MIT Doppelpunkt und
|
||||
// Verpackungs-Zusatz, genau so, wie er von der Disc kommt.
|
||||
'Spartacus: Gods Of The Arena - Disc 2',
|
||||
'Bd Evg D2',
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// MESSUNG am ECHTEN Laufwerk: Was bekäme der Auswahl-Dialog zu sehen?
|
||||
//
|
||||
// RIPPY_MESSUNG_DISC=G npx vitest run test/messung.titelwahl.test.ts --reporter=verbose
|
||||
//
|
||||
// Der Anlass (01.09.2026): Der Dialog blieb ewig bei „Rippy liest die
|
||||
// Titel der Disc …". Ursache war NICHT der Info-Lauf, sondern der
|
||||
// Nachrichten-Prüfer: `istFensterNachricht` kannte 'titel-lesen' nicht,
|
||||
// der Kern verwarf die Anfrage — still. Gemessen wurde danach:
|
||||
//
|
||||
// makemkvcon -r --noscan info dev:G: -> 0,7 s, TCOUNT:0
|
||||
// MSG:5042 „konnte keine verwendbaren optischen Laufwerke finden"
|
||||
// MSG:2024 „Unknown device - 'G:'" · MSG:5010 „Öffnen der Disk schlug fehl"
|
||||
//
|
||||
// Der Info-Lauf HING also nie. Diese Messung fährt genau den Weg des
|
||||
// Kerns nach — Info-Lauf, Vorauswahl, und was als Fehlertext im Dialog
|
||||
// stünde. Sie läuft nur auf Verlangen und nur unter Windows.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const buchstabe = (process.env.RIPPY_MESSUNG_DISC ?? '').replace(/:$/, '').toUpperCase()
|
||||
const messen = /^[A-Z]$/.test(buchstabe) && process.platform === 'win32'
|
||||
|
||||
describe.skipIf(!messen)('Titelwahl am echten Laufwerk', () => {
|
||||
it('liest die Titel und baut die Vorauswahl', { timeout: 320_000 }, async () => {
|
||||
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||
const { titelInfoLesen } = await import('../src/kern/rip/makemkv')
|
||||
const { titelVorauswahl } = await import('../src/kern/rip/titelwahl')
|
||||
const { discTitel } = await import('../src/kern/metadaten/discmerkmale')
|
||||
|
||||
const programm = finden('makemkv', '')
|
||||
expect(programm.length, 'makemkvcon nicht gefunden').toBeGreaterThan(0)
|
||||
|
||||
const geraetepfad = `\\\\.\\${buchstabe}:`
|
||||
const merkmale = discTitel(geraetepfad)
|
||||
console.log(`MESSUNG Disc sagt: "${merkmale.titel}" · Folgen ${merkmale.folgen} · Serie ${merkmale.istSerie}`)
|
||||
|
||||
const start = Date.now()
|
||||
const info = await titelInfoLesen(programm, geraetepfad)
|
||||
console.log(`MESSUNG Info-Lauf: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel`)
|
||||
if (info.ursachen.length > 0) console.log(`MESSUNG Ursachen: ${info.ursachen.join(' · ')}`)
|
||||
|
||||
const wahl = titelVorauswahl(info.titel, {
|
||||
istSerie: merkmale.istSerie,
|
||||
folgen: merkmale.folgen,
|
||||
})
|
||||
console.log(`MESSUNG Einordnung: ${wahl.einordnung} (unsicher: ${wahl.unsicher})`)
|
||||
for (const t of wahl.titel) {
|
||||
const min = Math.floor(t.dauerS / 60)
|
||||
console.log(
|
||||
`MESSUNG ${t.gewaehlt ? '[x]' : '[ ]'} Titel ${t.nr} | ${min}:${String(t.dauerS % 60).padStart(2, '0')} min` +
|
||||
` | ${(t.groesseBytes / 1e9).toFixed(2)} GB | ${t.kapitel} Kap. | ${t.rolle} | ${t.grund}`,
|
||||
)
|
||||
}
|
||||
// Was im Dialog als Fehler stünde — genau die Regel aus kern/index.ts.
|
||||
const dialogFehler = info.titel.length === 0 && info.ursachen.length > 0 ? info.ursachen.join(' · ') : ''
|
||||
console.log(`MESSUNG Dialog-Fehlertext: ${dialogFehler.length > 0 ? dialogFehler : '(keiner)'}`)
|
||||
|
||||
// Der Lauf muss ANTWORTEN — leer sein darf er, hängen nicht.
|
||||
expect(Date.now() - start).toBeLessThan(300_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(messen)('Titelwahl-Messung übersprungen', () => {
|
||||
it('läuft nur mit RIPPY_MESSUNG_DISC=<Laufwerksbuchstabe> unter Windows', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -6,12 +6,23 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
discZusatzAbtrennen,
|
||||
folgenZahl,
|
||||
inhaltAusBdmt,
|
||||
istFolgenName,
|
||||
normalizeDiscLabel,
|
||||
parseUdfDstring,
|
||||
titelAusBdmt,
|
||||
titelKandidaten,
|
||||
} from '../src/kern/metadaten/discmerkmale'
|
||||
import { istSicherGenug, wortUeberlappung, zuordnen, WENIGE_TREFFER } from '../src/kern/metadaten/zuordnung'
|
||||
import {
|
||||
abdeckung,
|
||||
gleichBedeutend,
|
||||
istSicherGenug,
|
||||
wortUeberlappung,
|
||||
zuordnen,
|
||||
WENIGE_TREFFER,
|
||||
} from '../src/kern/metadaten/zuordnung'
|
||||
import { posterUrl } from '../src/kern/ablage/poster'
|
||||
|
||||
let wegwerf: string[] = []
|
||||
@@ -169,10 +180,13 @@ describe('zuordnen — die Confidence-Kaskade', () => {
|
||||
expect(z.meta.quelle).toBe('omdb')
|
||||
})
|
||||
|
||||
it('der Spartacus-Fund: spezifische Serie auf den vollen Titel schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => {
|
||||
it('der Spartacus-Fund: die Serie schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => {
|
||||
// Live gemessen (30.08.2026): 'Spartacus Gods Of The Arena' wurde zum
|
||||
// Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf,
|
||||
// BEVOR die volle Anfrage als spezifische Serie gewertet wurde.
|
||||
// Seit dem 01.09.2026 ist es sogar ein WÖRTLICHER Serientreffer (0,90
|
||||
// statt 0,80): Der Vergleich stolpert nicht mehr über den Doppelpunkt,
|
||||
// den TMDb schreibt und ein Volume-Label nicht tragen kann.
|
||||
const z = await zuordnen(
|
||||
'Spartacus Gods Of The Arena',
|
||||
fakeTmdb(
|
||||
@@ -181,7 +195,7 @@ describe('zuordnen — die Confidence-Kaskade', () => {
|
||||
),
|
||||
keinOmdb,
|
||||
)
|
||||
expect(z.confidence).toBe(0.8)
|
||||
expect(z.confidence).toBe(0.9)
|
||||
expect(z.meta.typ).toBe('tv')
|
||||
expect(z.titel).toBe('Spartacus: Gods of the Arena')
|
||||
})
|
||||
@@ -224,6 +238,179 @@ describe('zuordnen — die Confidence-Kaskade', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── Der Spartacus-Fund des Commanders (01.09.2026) ──────────────────────
|
||||
//
|
||||
// Gemessen an der Disc, die im Laufwerk lag: Volume-Label
|
||||
// SPARTACUS_GOTA_D2 (UDF, 33 759 100 928 Bytes), und in
|
||||
// G:\BDMV\META\DL\bdmt_eng.xml stand alles, was Rippy brauchte — es las
|
||||
// nur die erste Zeile davon. Rippy meldete „Spartacus" (Film, 1960).
|
||||
|
||||
/** Das echte bdmt_eng.xml der Disc, gekürzt auf das Wesentliche. */
|
||||
const ECHTES_BDMT =
|
||||
'<?xml version="1.0" encoding="UTF-8"?><disclib xmlns="urn:BDA:bdmv;disclib">' +
|
||||
'<di:discinfo xmlns:di="urn:BDA:bdmv;discinfo"><di:title>' +
|
||||
'<di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>' +
|
||||
'<di:numSets>1</di:numSets><di:setNumber>1</di:setNumber></di:title>' +
|
||||
'<di:description><di:tableOfContents>' +
|
||||
'<di:titleName titleNumber="1">EP 1</di:titleName>' +
|
||||
'<di:titleName titleNumber="2">EP 2</di:titleName>' +
|
||||
'<di:titleName titleNumber="20">MM ITA</di:titleName>' +
|
||||
'<di:titleName titleNumber="50">ILSM</di:titleName>' +
|
||||
'<di:titleName titleNumber="51">DUB GER 1</di:titleName>' +
|
||||
'<di:titleName titleNumber="52">DUB GER 2</di:titleName>' +
|
||||
'<di:titleName titleNumber="101">101 LOGO</di:titleName>' +
|
||||
'<di:titleName titleNumber="104">104 GER FSK</di:titleName>' +
|
||||
'<di:titleName titleNumber="105">105 WARNINGS</di:titleName>' +
|
||||
'</di:tableOfContents></di:description></di:discinfo></disclib>'
|
||||
|
||||
describe('inhaltAusBdmt — die Disc sagt selbst, was sie ist', () => {
|
||||
it('liest Titel UND Inhaltsverzeichnis der echten Disc', () => {
|
||||
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
|
||||
wegwerf.push(wurzel)
|
||||
const meta = join(wurzel, 'BDMV', 'META', 'DL')
|
||||
mkdirSync(meta, { recursive: true })
|
||||
writeFileSync(join(meta, 'bdmt_eng.xml'), ECHTES_BDMT)
|
||||
const inhalt = inhaltAusBdmt(wurzel)
|
||||
expect(inhalt.titel).toBe('Spartacus: Gods Of The Arena - Disc 2')
|
||||
expect(inhalt.eintraege).toContain('EP 1')
|
||||
expect(inhalt.eintraege).toContain('DUB GER 1')
|
||||
// Zwei Folgen — das deckt sich mit der Disc-Struktur: genau zwei große
|
||||
// Ströme (15,76 GB und 14,89 GB), alles andere unter 200 MB.
|
||||
expect(folgenZahl(inhalt.eintraege)).toBe(2)
|
||||
})
|
||||
|
||||
it('Tonspuren, Trailer und Warnhinweise sind KEINE Folgen', () => {
|
||||
expect(istFolgenName('EP 1')).toBe(true)
|
||||
expect(istFolgenName('Episode 12')).toBe(true)
|
||||
expect(istFolgenName('Folge 3')).toBe(true)
|
||||
expect(istFolgenName('DUB GER 1')).toBe(false)
|
||||
expect(istFolgenName('101 LOGO')).toBe(false)
|
||||
expect(istFolgenName('104 GER FSK')).toBe(false)
|
||||
expect(istFolgenName('MM ITA')).toBe(false)
|
||||
})
|
||||
|
||||
it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => {
|
||||
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
|
||||
wegwerf.push(wurzel)
|
||||
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('discZusatzAbtrennen — abtrennen UND merken', () => {
|
||||
it('trennt den Disc-Zusatz vom echten Titel', () => {
|
||||
expect(discZusatzAbtrennen('Spartacus: Gods Of The Arena - Disc 2')).toEqual({
|
||||
titel: 'Spartacus: Gods Of The Arena',
|
||||
discNr: 2,
|
||||
staffel: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('auch aus dem Volume-Label (SPARTACUS_GOTA_D2)', () => {
|
||||
expect(discZusatzAbtrennen(normalizeDiscLabel('SPARTACUS_GOTA_D2'))).toEqual({
|
||||
titel: 'Spartacus Gota',
|
||||
discNr: 2,
|
||||
staffel: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('Staffel und Disc hintereinander', () => {
|
||||
expect(discZusatzAbtrennen('Akte X S1 D2')).toEqual({ titel: 'Akte X', discNr: 2, staffel: 1 })
|
||||
})
|
||||
|
||||
it('lässt echte Titel in Ruhe — die vier Fallen', () => {
|
||||
// Nackte Zahl am Ende: sonst würde aus 'Rocky 2' die 'Rocky', Disc 2.
|
||||
expect(discZusatzAbtrennen('Rocky 2').titel).toBe('Rocky 2')
|
||||
// 'Teil'/'Part' sind Titel-Wörter, keine Verpackung.
|
||||
expect(discZusatzAbtrennen('Kill Bill Teil 2').titel).toBe('Kill Bill Teil 2')
|
||||
// Die Apostroph-Wortgrenze: ohne den Trenner-Zwang würde hieraus
|
||||
// "Ocean'" mit Staffel 11.
|
||||
expect(discZusatzAbtrennen("Ocean's 11").titel).toBe("Ocean's 11")
|
||||
expect(discZusatzAbtrennen("Ocean's 11").staffel).toBe(null)
|
||||
expect(discZusatzAbtrennen('Pulp Fiction')).toEqual({ titel: 'Pulp Fiction', discNr: null, staffel: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('titelKandidaten — der Doppelpunkt-Vorspann steht GANZ hinten', () => {
|
||||
it('die Reihenfolge der echten Disc', () => {
|
||||
const k = titelKandidaten('Spartacus: Gods Of The Arena - Disc 2')
|
||||
// Platz 1 war bis zum 01.09.2026 'Spartacus' — und erschlug damit alles.
|
||||
expect(k[0]).toBe('Spartacus: Gods Of The Arena - Disc 2')
|
||||
expect(k[1]).toBe('Spartacus: Gods Of The Arena')
|
||||
expect(k).toContain('Spartacus Gods Of The Arena')
|
||||
expect(k[k.length - 1]).toBe('Spartacus')
|
||||
expect(k.indexOf('Spartacus')).toBeGreaterThan(k.indexOf('Spartacus: Gods Of The Arena'))
|
||||
// Kein Müll-Zwischenschritt mehr, der auf einem Trennzeichen endet.
|
||||
expect(k.some((t) => /[\s\-–—_:,.]$/.test(t))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gleichBedeutend und abdeckung', () => {
|
||||
it('der Doppelpunkt kostet keinen Treffer mehr', () => {
|
||||
expect(gleichBedeutend('Spartacus: Gods of the Arena', 'Spartacus Gods Of The Arena')).toBe(true)
|
||||
})
|
||||
|
||||
it('aber Wortdopplungen bleiben unterscheidbar', () => {
|
||||
expect(gleichBedeutend('New York, New York', 'New York')).toBe(false)
|
||||
expect(gleichBedeutend('', 'x')).toBe(false)
|
||||
})
|
||||
|
||||
it('abdeckung misst, wie viel von der Frage übrig ist', () => {
|
||||
expect(abdeckung('Spartacus: Gods Of The Arena', 'Spartacus')).toBeCloseTo(0.2)
|
||||
expect(abdeckung('Spartacus: Gods Of The Arena', 'Spartacus: Gods Of The Arena')).toBe(1)
|
||||
expect(abdeckung('', 'x')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zuordnen — der Fund des Commanders, an der echten Disc', () => {
|
||||
const echteWelt = () =>
|
||||
fakeTmdb(
|
||||
// TMDb hat einen Film, der WÖRTLICH 'Spartacus' heißt — das ist die Falle.
|
||||
{ Spartacus: [{ id: 5, title: 'Spartacus' }] },
|
||||
{ 'Spartacus: Gods Of The Arena': [{ id: 6, title: 'Spartacus: Gods of the Arena' }] },
|
||||
)
|
||||
|
||||
it('der echte Disc-Titel führt zur Serie, nicht zum Film von 1960', async () => {
|
||||
const z = await zuordnen('Spartacus: Gods Of The Arena - Disc 2', echteWelt(), keinOmdb)
|
||||
expect(z.meta.typ).toBe('tv')
|
||||
expect(z.titel).toBe('Spartacus: Gods of the Arena')
|
||||
expect(z.confidence).toBe(0.9)
|
||||
})
|
||||
|
||||
it('mit dem Serien-Hinweis der Disc kann ein Film gar nicht mehr gewinnen', async () => {
|
||||
const z = await zuordnen('Spartacus: Gods Of The Arena - Disc 2', echteWelt(), keinOmdb, { istSerie: true })
|
||||
expect(z.meta.typ).toBe('tv')
|
||||
expect(z.titel).toBe('Spartacus: Gods of the Arena')
|
||||
})
|
||||
|
||||
it('DER REGRESSIONSTEST: kennt TMDb nur den Film, wird daraus ein VORSCHLAG', async () => {
|
||||
// Genau der Fehler, den der Commander sah: Die Restfrage 'Spartacus'
|
||||
// trifft wörtlich — vorher 95 % Sicherheit auf den falschen Film.
|
||||
// Jetzt deckelt die Abdeckung (1 von 5 Wörtern übrig) auf 0,60: Rippy
|
||||
// zeigt es als Vorschlag und fragt nach, statt sich sicher zu irren.
|
||||
const z = await zuordnen(
|
||||
'Spartacus: Gods Of The Arena - Disc 2',
|
||||
fakeTmdb({ Spartacus: [{ id: 5, title: 'Spartacus' }] }),
|
||||
keinOmdb,
|
||||
)
|
||||
expect(z.titel).toBe('Spartacus')
|
||||
expect(z.confidence).toBe(0.6)
|
||||
expect(istSicherGenug(z)).toBe(false)
|
||||
})
|
||||
|
||||
it('eine Serien-Disc nimmt auch aus dem Label keinen Filmtreffer an', async () => {
|
||||
// Ohne bdmt bliebe nur 'Spartacus Gota D2' — die Folgen im
|
||||
// Inhaltsverzeichnis bezeugen trotzdem, dass hier kein Film liegt.
|
||||
const z = await zuordnen(
|
||||
normalizeDiscLabel('SPARTACUS_GOTA_D2'),
|
||||
fakeTmdb({ Spartacus: [{ id: 5, title: 'Spartacus' }] }),
|
||||
keinOmdb,
|
||||
{ istSerie: true },
|
||||
)
|
||||
expect(z.meta.typ).not.toBe('movie')
|
||||
expect(z.confidence).toBe(0.3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('posterUrl', () => {
|
||||
it('relative TMDb-Pfade bekommen das w500-Präfix', () => {
|
||||
expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg')
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// Welche Titel schlägt Rippy vor? — und wann sagt es „ich bin unsicher"?
|
||||
//
|
||||
// Anlass (01.09.2026): Bis dahin übergab die Pipeline fest `titel: 'all'`
|
||||
// — Rippy rippte IMMER alles, inklusive Trailer, Werbung und
|
||||
// Alterskennzeichen. Der Commander: „es fehlt generell noch komplett die
|
||||
// auswahl WAS man rippen möchte."
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { offsetAusMeldung } from '../src/kern/rip/parser'
|
||||
import { HAENGT_AB, RipAuswertung } from '../src/kern/rip/makemkv'
|
||||
import { AEHNLICH, KURZ_S, titelVorauswahl } from '../src/kern/rip/titelwahl'
|
||||
import type { TitelInfo } from '../src/kern/rip/parser'
|
||||
|
||||
function titel(nr: number, minuten: number, kapitel = 8, gb = 1): TitelInfo {
|
||||
return { nr, dauerS: Math.round(minuten * 60), groesseBytes: Math.round(gb * 1e9), kapitel }
|
||||
}
|
||||
|
||||
const KEIN_HINWEIS = { istSerie: false, folgen: 0 }
|
||||
|
||||
describe('titelVorauswahl — Serie', () => {
|
||||
it('die echte Spartacus-Disc: zwei Folgen, sonst nichts', () => {
|
||||
// Gemessen an der Disc: zwei große Ströme (15,76 GB / 14,89 GB) und
|
||||
// Kleinkram. Das bdmt-Inhaltsverzeichnis nennt EP 1 und EP 2.
|
||||
const wahl = titelVorauswahl(
|
||||
[titel(0, 52.2, 8, 15.76), titel(1, 49.6, 8, 14.89), titel(2, 0.5, 1), titel(3, 0.2, 1)],
|
||||
{ istSerie: true, folgen: 2 },
|
||||
)
|
||||
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1])
|
||||
expect(wahl.unsicher).toBe(false)
|
||||
expect(wahl.einordnung).toContain('2 Folgen erwartet, 2 gefunden')
|
||||
expect(wahl.titel[0].grund).toBe('Folge')
|
||||
expect(wahl.titel[3].grund).toContain('zu kurz')
|
||||
})
|
||||
|
||||
it('weniger Folgen gefunden als erwartet — Rippy sagt es', () => {
|
||||
const wahl = titelVorauswahl([titel(0, 50), titel(1, 0.4, 1)], { istSerie: true, folgen: 3 })
|
||||
expect(wahl.unsicher).toBe(true)
|
||||
expect(wahl.einordnung).toContain('nur 1 gefunden')
|
||||
})
|
||||
|
||||
it('gefundene Folgen sind unterschiedlich lang — auch das sagt Rippy', () => {
|
||||
// 50 min und 20 min sind keine Staffel; die Auswahl bleibt, aber
|
||||
// Rippy behauptet nicht, sie sei sicher.
|
||||
const wahl = titelVorauswahl([titel(0, 50), titel(1, 20)], { istSerie: true, folgen: 2 })
|
||||
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1])
|
||||
expect(wahl.unsicher).toBe(true)
|
||||
expect(wahl.einordnung).toContain('unterschiedlich lang')
|
||||
})
|
||||
|
||||
it('ähnlich lange Folgen bleiben innerhalb der Toleranz sicher', () => {
|
||||
// 45 und 50 Minuten liegen bei 10 % — unter AEHNLICH.
|
||||
expect((50 - 45) / 50).toBeLessThan(AEHNLICH)
|
||||
const wahl = titelVorauswahl([titel(0, 50), titel(1, 45)], { istSerie: true, folgen: 2 })
|
||||
expect(wahl.unsicher).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('titelVorauswahl — Film', () => {
|
||||
it('der längste Titel ist der Hauptinhalt', () => {
|
||||
const wahl = titelVorauswahl([titel(0, 118), titel(1, 4), titel(2, 2)], KEIN_HINWEIS)
|
||||
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0])
|
||||
expect(wahl.unsicher).toBe(false)
|
||||
expect(wahl.titel[0].grund).toBe('Hauptinhalt')
|
||||
expect(wahl.titel[1].grund).toBe('Zugabe')
|
||||
})
|
||||
|
||||
it('DIE ANGEL-FALLE: mehrere fast gleich lange Fassungen', () => {
|
||||
// Seamless Branching / Kamerawinkel — dieselbe Fassung in Varianten.
|
||||
// Rippy nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist.
|
||||
const wahl = titelVorauswahl(
|
||||
[titel(0, 120, 12), titel(1, 119, 24), titel(2, 118, 6)],
|
||||
KEIN_HINWEIS,
|
||||
)
|
||||
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1])
|
||||
expect(wahl.unsicher).toBe(true)
|
||||
expect(wahl.einordnung).toContain('fast gleich lang')
|
||||
})
|
||||
})
|
||||
|
||||
describe('titelVorauswahl — die Sammeltitel-Falle', () => {
|
||||
it('„alle Folgen am Stück" gewinnt NICHT die Film-Auswahl', () => {
|
||||
// 150 min = 50 + 50 + 50. Ohne diese Regel wäre der Sammeltitel der
|
||||
// längste — und Rippy würde alles doppelt sichern.
|
||||
const wahl = titelVorauswahl(
|
||||
[titel(0, 150), titel(1, 50), titel(2, 50), titel(3, 50)],
|
||||
KEIN_HINWEIS,
|
||||
)
|
||||
expect(wahl.titel[0].gewaehlt).toBe(false)
|
||||
expect(wahl.titel[0].grund).toContain('Sammeltitel')
|
||||
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1])
|
||||
})
|
||||
|
||||
it('auch bei einer Serie bleibt der Sammeltitel außen vor', () => {
|
||||
const wahl = titelVorauswahl(
|
||||
[titel(0, 100), titel(1, 50), titel(2, 50)],
|
||||
{ istSerie: true, folgen: 2 },
|
||||
)
|
||||
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('ein einzelner langer Titel ist KEIN Sammeltitel', () => {
|
||||
// Ein Film mit einem Trailer daneben — 118 min sind nicht die Summe
|
||||
// von irgendetwas, sonst würde jeder Film als Sammeltitel gelten.
|
||||
const wahl = titelVorauswahl([titel(0, 118), titel(1, 4)], KEIN_HINWEIS)
|
||||
expect(wahl.titel[0].gewaehlt).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('titelVorauswahl — wenn nichts passt, wird nichts behauptet', () => {
|
||||
it('nur Schnipsel: keine Vorauswahl, aber eine Ansage', () => {
|
||||
const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
|
||||
expect(wahl.titel.every((t) => !t.gewaehlt)).toBe(true)
|
||||
expect(wahl.unsicher).toBe(true)
|
||||
expect(wahl.einordnung).toContain(`${Math.round(KURZ_S / 60)} Minuten`)
|
||||
})
|
||||
|
||||
it('gar keine Titel', () => {
|
||||
const wahl = titelVorauswahl([], KEIN_HINWEIS)
|
||||
expect(wahl.titel).toEqual([])
|
||||
expect(wahl.unsicher).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('titelVorauswahl — die Rolle bestimmt, WOHIN abgelegt wird', () => {
|
||||
it('Serie: gewählte Titel sind Folgen', () => {
|
||||
const wahl = titelVorauswahl([titel(0, 52), titel(1, 50), titel(2, 3)], { istSerie: true, folgen: 2 })
|
||||
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.rolle)).toEqual(['folge', 'folge'])
|
||||
})
|
||||
|
||||
it('Film: der Hauptinhalt ist Hauptfilm, alles andere Extra', () => {
|
||||
// Hakt der Nutzer eine Zugabe zusätzlich an, landet sie damit unter
|
||||
// extras/ statt als zweiter Film daneben.
|
||||
const wahl = titelVorauswahl([titel(0, 118), titel(1, 6), titel(2, 4)], KEIN_HINWEIS)
|
||||
expect(wahl.titel[0].rolle).toBe('hauptfilm')
|
||||
expect(wahl.titel[1].rolle).toBe('extra')
|
||||
expect(wahl.titel[2].rolle).toBe('extra')
|
||||
})
|
||||
|
||||
it('ohne Vorauswahl ist nichts Hauptfilm', () => {
|
||||
const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
|
||||
expect(wahl.titel.every((t) => t.rolle === 'extra')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Der Defekt-Wächter ──────────────────────────────────────────────────
|
||||
// Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe
|
||||
// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU
|
||||
// (es wartete also), und Windows protokollierte 41 fehlerhafte Blöcke in
|
||||
// 10 Minuten. Dieselbe Stelle wieder und wieder ist kein Fortschritt.
|
||||
const FEHLER_DE =
|
||||
"Fehler 'SCSI-Fehler - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' trat auf während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'"
|
||||
const FEHLER_EN =
|
||||
"Error 'SCSI error - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' occurred while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'"
|
||||
|
||||
describe('offsetAusMeldung', () => {
|
||||
it('liest die Stelle — unabhängig von MakeMKVs Sprache', () => {
|
||||
expect(offsetAusMeldung(FEHLER_DE)).toBe('56881152')
|
||||
expect(offsetAusMeldung(FEHLER_EN)).toBe('56881152')
|
||||
})
|
||||
|
||||
it('ohne Stelle bleibt es leer', () => {
|
||||
expect(offsetAusMeldung('Kopiere 2 Titel')).toBe('')
|
||||
expect(offsetAusMeldung('')).toBe('')
|
||||
// Eine einzelne Ziffer ist eine Titelnummer, kein Offset.
|
||||
expect(offsetAusMeldung("Titel '3'")).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RipAuswertung — hängt der Lauf an einer festen Stelle?', () => {
|
||||
const zeile = (text: string): string => `MSG:2003,0,3,"${text}"`
|
||||
|
||||
it('dieselbe Stelle immer wieder = Defekt', () => {
|
||||
const a = new RipAuswertung()
|
||||
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE))
|
||||
expect(a.haengtBei).toBe('56881152')
|
||||
expect(a.haengt()).toBe(true)
|
||||
})
|
||||
|
||||
it('ein kurzer Aussetzer ist noch kein Defekt', () => {
|
||||
const a = new RipAuswertung()
|
||||
for (let i = 0; i < HAENGT_AB - 1; i++) a.verarbeite(zeile(FEHLER_DE))
|
||||
expect(a.haengt()).toBe(false)
|
||||
})
|
||||
|
||||
it('Fortschritt räumt den Verdacht ab — es ging ja weiter', () => {
|
||||
const a = new RipAuswertung()
|
||||
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE))
|
||||
expect(a.haengt()).toBe(true)
|
||||
a.verarbeite('PRGV:16384,16384,65536')
|
||||
expect(a.haengt()).toBe(false)
|
||||
expect(a.haengtBei).toBe('')
|
||||
})
|
||||
|
||||
it('wandernde Stellen sind normale Lesefehler, kein Hängen', () => {
|
||||
const a = new RipAuswertung()
|
||||
for (let i = 0; i < HAENGT_AB * 2; i++) {
|
||||
a.verarbeite(zeile(FEHLER_DE.replace('56881152', String(56881152 + i * 2048))))
|
||||
}
|
||||
expect(a.haengt()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,13 @@
|
||||
// Ohne Code-Signing (Entscheid 5) ist der Transportweg die EINZIGE
|
||||
// Absicherung des Selbst-Updates.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { updateUrlPruefen, STANDARD_UPDATE_URL } from '../src/haupt/update-regeln'
|
||||
import { argumentQuoten, kommandozeileBauen, INSTALLER_ARGUMENTE } from '../src/haupt/kommandozeile'
|
||||
import {
|
||||
neuerungenText,
|
||||
updateUrlPruefen,
|
||||
NEUERUNGEN_HOECHSTENS,
|
||||
STANDARD_UPDATE_URL,
|
||||
} from '../src/haupt/update-regeln'
|
||||
|
||||
describe('updateUrlPruefen', () => {
|
||||
it('leer heißt Standard (der ist https)', () => {
|
||||
@@ -22,3 +28,87 @@ describe('updateUrlPruefen', () => {
|
||||
expect(updateUrlPruefen('file://x').ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Die Änderungsnotizen kommen aus latest.yml und werden im Fenster
|
||||
// angezeigt (Commander-Wunsch 01.09.2026: „inkl. changelog").
|
||||
// builder-util-runtime deklariert sie als
|
||||
// `string | Array<ReleaseNoteInfo> | null` — beide Formen müssen hier
|
||||
// ankommen, sonst steht in der Karte „[object Object]".
|
||||
describe('neuerungenText', () => {
|
||||
it('nimmt den Text aus bau/release-notes.md, wie er kommt', () => {
|
||||
expect(neuerungenText('Version 5.1.2\n\nNeu: ein Knopf.\n')).toBe('Version 5.1.2\n\nNeu: ein Knopf.')
|
||||
})
|
||||
|
||||
it('nimmt auch die Listenform (mehrere übersprungene Versionen)', () => {
|
||||
const roh = [
|
||||
{ version: '5.1.1', note: 'Serien-Discs richtig erkannt.' },
|
||||
{ version: '5.1.2', note: 'Update-Meldung im Fenster.' },
|
||||
]
|
||||
const text = neuerungenText(roh)
|
||||
expect(text).toContain('5.1.1')
|
||||
expect(text).toContain('Serien-Discs richtig erkannt.')
|
||||
expect(text).toContain('Update-Meldung im Fenster.')
|
||||
})
|
||||
|
||||
it('HTML-Marken fliegen raus — der Text kommt von einem Server', () => {
|
||||
const text = neuerungenText('<b>fett</b> und <script>boese()</script> Rest')
|
||||
expect(text).not.toContain('<')
|
||||
expect(text).not.toContain('script>')
|
||||
expect(text).toContain('fett')
|
||||
})
|
||||
|
||||
it('nichts, Unsinn und Leeres ergeben einen leeren Text — nie [object Object]', () => {
|
||||
expect(neuerungenText(null)).toBe('')
|
||||
expect(neuerungenText(undefined)).toBe('')
|
||||
expect(neuerungenText(42)).toBe('')
|
||||
expect(neuerungenText([{ version: '1' }])).toBe('')
|
||||
expect(neuerungenText(' \n ')).toBe('')
|
||||
})
|
||||
|
||||
it('eine ausufernde Datei sprengt die Karte nicht', () => {
|
||||
expect(neuerungenText('x'.repeat(NEUERUNGEN_HOECHSTENS * 2)).length).toBe(NEUERUNGEN_HOECHSTENS)
|
||||
})
|
||||
})
|
||||
|
||||
// Seit 5.1.4 startet Rippy den Update-Installer SELBST, außerhalb der
|
||||
// Prozess-Leine (§ 3.3) — electron-updaters eigener Weg lief als Kind und
|
||||
// wurde beim Beenden von der Leine erschlagen. CreateProcessW nimmt EINE
|
||||
// Zeichenkette statt einer Argumentliste; wer sie naiv zusammenklebt,
|
||||
// verliert jeden Pfad mit Leerzeichen.
|
||||
describe('kommandozeileBauen — die Regeln von CommandLineToArgvW', () => {
|
||||
it('Harmloses bleibt unangetastet', () => {
|
||||
expect(argumentQuoten('--updated')).toBe('--updated')
|
||||
expect(argumentQuoten('/S')).toBe('/S')
|
||||
})
|
||||
|
||||
it('DER FALL: ein Pfad mit Leerzeichen bleibt EIN Argument', () => {
|
||||
// Ohne Anführungszeichen zerfiele das in „C:\\Users\\Tobi" und „Neu\\…"
|
||||
// — der Installer startete nie.
|
||||
expect(argumentQuoten('C:\\Users\\Tobi Neu\\RippySetup.exe')).toBe('"C:\\Users\\Tobi Neu\\RippySetup.exe"')
|
||||
})
|
||||
|
||||
it('Rückstriche vor dem Schluss-Anführungszeichen zählen doppelt', () => {
|
||||
// Sonst würde das abschließende " selbst escaped und die Zeile bliebe offen.
|
||||
expect(argumentQuoten('C:\\Pfad mit Lücke\\')).toBe('"C:\\Pfad mit Lücke\\\\"')
|
||||
})
|
||||
|
||||
it('Anführungszeichen im Argument werden escaped', () => {
|
||||
expect(argumentQuoten('sag "hallo"')).toBe('"sag \\"hallo\\""')
|
||||
expect(argumentQuoten('a\\"b')).toBe('"a\\\\\\"b"')
|
||||
})
|
||||
|
||||
it('das leere Argument verschwindet nicht', () => {
|
||||
expect(argumentQuoten('')).toBe('""')
|
||||
})
|
||||
|
||||
it('die ganze Zeile, wie sie an CreateProcessW geht', () => {
|
||||
const zeile = kommandozeileBauen('C:\\Users\\Tobi Neu\\RippySetup-5.1.4.exe', INSTALLER_ARGUMENTE)
|
||||
expect(zeile).toBe('"C:\\Users\\Tobi Neu\\RippySetup-5.1.4.exe" --updated /S --force-run')
|
||||
})
|
||||
|
||||
it('die Argumente sind die von electron-updater gemessenen', () => {
|
||||
// NsisUpdater.js doInstall: --updated immer, /S für still,
|
||||
// --force-run für „danach wieder starten".
|
||||
expect(INSTALLER_ARGUMENTE).toEqual(['--updated', '/S', '--force-run'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,7 +34,15 @@ describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
|
||||
// § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die
|
||||
// Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts:
|
||||
// Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt.
|
||||
const erlaubt = new Set(['src/haupt/leine.ts', 'src/kern/laufwerk/win32.ts'])
|
||||
// Der DRITTE (seit 01.09.2026) ist der Fensterrand: DwmSetWindowAttribute
|
||||
// braucht das Fensterhandle und kann deshalb nur im Haupt liegen. Die
|
||||
// Regel bleibt, was sie war — „Win32 nur an benannten Orten"; sie zählt
|
||||
// jetzt drei statt zwei, und der Test hält alles andere weiterhin dicht.
|
||||
const erlaubt = new Set([
|
||||
'src/haupt/leine.ts',
|
||||
'src/haupt/fensterrand.ts',
|
||||
'src/kern/laufwerk/win32.ts',
|
||||
])
|
||||
|
||||
it('koffi wird nirgendwo sonst angefasst', () => {
|
||||
const verstoesse = dateien
|
||||
@@ -97,6 +105,46 @@ describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nachrichten-Schema — der Prüfer kennt JEDE Nachrichtenart', () => {
|
||||
// Der teure Fund vom 01.09.2026: 'titel-lesen' und 'rip-ueberspringen'
|
||||
// standen in der FensterNachricht-Union, aber NICHT in
|
||||
// istFensterNachricht(). Der Kern verwarf sie — still. Im Fenster stand
|
||||
// ewig „Rippy liest die Titel …", und nirgends stand ein Grund.
|
||||
//
|
||||
// Dieser Test liest den Quelltext: Jede `art: '…'` der Union muss im
|
||||
// Prüfer wörtlich vorkommen. Eine neue Nachricht ohne Prüfung fällt
|
||||
// damit sofort auf, statt erst am lebenden Objekt.
|
||||
const schema = readFileSync(join(WURZEL, 'src', 'gemeinsam', 'nachrichten.ts'), 'utf8')
|
||||
|
||||
function artenAus(unionName: string): string[] {
|
||||
const start = schema.indexOf(`export type ${unionName} =`)
|
||||
expect(start, `${unionName} nicht gefunden`).toBeGreaterThan(-1)
|
||||
// Der Union-Block reicht bis zur nächsten Deklaration auf oberster Ebene.
|
||||
const rest = schema.slice(start + `export type ${unionName} =`.length)
|
||||
const ende = rest.indexOf('\nexport ')
|
||||
const block = ende > 0 ? rest.slice(0, ende) : rest
|
||||
return [...new Set([...block.matchAll(/art:\s*'([a-z0-9-]+)'/g)].map((m) => m[1]))]
|
||||
}
|
||||
|
||||
it('istFensterNachricht kennt jede FensterNachricht', () => {
|
||||
const pruefer = schema.slice(schema.indexOf('export function istFensterNachricht'))
|
||||
const fehlend = artenAus('FensterNachricht').filter((art) => !pruefer.includes(`'${art}'`))
|
||||
expect(fehlend).toEqual([])
|
||||
})
|
||||
|
||||
it('istKernNachricht kennt jede KernNachricht', () => {
|
||||
const pruefer = schema.slice(schema.indexOf('export function istKernNachricht'))
|
||||
const fehlend = artenAus('KernNachricht').filter((art) => !pruefer.includes(`'${art}'`))
|
||||
expect(fehlend).toEqual([])
|
||||
})
|
||||
|
||||
it('der Kern verwirft nichts still', () => {
|
||||
// Wer eine Nachricht wegwirft, sagt WARUM (R4).
|
||||
const kern = readFileSync(join(WURZEL, 'src', 'kern', 'index.ts'), 'utf8')
|
||||
expect(kern).not.toMatch(/if\s*\(!istFensterNachricht\([^)]*\)\)\s*return/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
|
||||
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
|
||||
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
|
||||
|
||||
Reference in New Issue
Block a user