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
|
# 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
|
> Der Commander fand den Einstellungs-Tab „sehr mager" — jetzt ist er
|
||||||
> eine erklärende Karten-Seite im Kino-Mix (`b64ddf5`, 209 Tests, Dev-
|
> 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",
|
"name": "rippy",
|
||||||
"version": "5.0.0-w0",
|
"version": "5.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"version": "5.0.0-w0",
|
"version": "5.3.2",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
@@ -2229,6 +2229,72 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"productName": "Rippy",
|
"productName": "Rippy",
|
||||||
"version": "5.1.0",
|
"version": "5.3.2",
|
||||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
"main": "out/haupt/index.js",
|
"main": "out/haupt/index.js",
|
||||||
"author": "KrBrZ",
|
"author": "KrBrZ",
|
||||||
|
|||||||
@@ -56,4 +56,17 @@ contextBridge.exposeInMainWorld('rippy', {
|
|||||||
autostartSetzen(an: boolean): Promise<boolean> {
|
autostartSetzen(an: boolean): Promise<boolean> {
|
||||||
return ipcRenderer.invoke('autostart-setzen', an === true) as 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 }>
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+2005
-1459
File diff suppressed because it is too large
Load Diff
+3
@@ -9,6 +9,9 @@ declare global {
|
|||||||
melden(art: string): void
|
melden(art: string): void
|
||||||
ordnerWaehlen(titel: string): Promise<string | null>
|
ordnerWaehlen(titel: string): Promise<string | null>
|
||||||
autostartSetzen(an: boolean): Promise<boolean>
|
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 GeraeteInfo,
|
||||||
type KernNachricht,
|
type KernNachricht,
|
||||||
type MetaVorschlag,
|
type MetaVorschlag,
|
||||||
|
type TitelStand,
|
||||||
|
type RipAuftragTitel,
|
||||||
type RipStatus,
|
type RipStatus,
|
||||||
type WerkzeugAuskunft,
|
type WerkzeugAuskunft,
|
||||||
} from '../gemeinsam/nachrichten'
|
} from '../gemeinsam/nachrichten'
|
||||||
@@ -30,6 +32,8 @@ export interface Verbindung {
|
|||||||
discInfos: Record<string, DiscInfoStand>
|
discInfos: Record<string, DiscInfoStand>
|
||||||
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||||||
vorschlaege: Record<string, MetaVorschlag[]>
|
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). */
|
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||||
ripStart: Record<string, number>
|
ripStart: Record<string, number>
|
||||||
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||||
@@ -65,6 +69,7 @@ const stand: Verbindung = {
|
|||||||
laufwerke: [],
|
laufwerke: [],
|
||||||
rips: {},
|
rips: {},
|
||||||
discInfos: {},
|
discInfos: {},
|
||||||
|
discTitel: {},
|
||||||
vorschlaege: {},
|
vorschlaege: {},
|
||||||
ripStart: {},
|
ripStart: {},
|
||||||
ripProtokoll: {},
|
ripProtokoll: {},
|
||||||
@@ -140,6 +145,10 @@ function kernNachricht(nachricht: KernNachricht): void {
|
|||||||
}
|
}
|
||||||
break
|
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':
|
case 'metadaten-vorschlaege':
|
||||||
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
||||||
break
|
break
|
||||||
@@ -245,7 +254,7 @@ export function aufVerbindung(h: Horcher): () => void {
|
|||||||
return () => horcher.delete(h)
|
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) {
|
if (port === null) {
|
||||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||||
melden()
|
melden()
|
||||||
@@ -258,8 +267,19 @@ export function auswerfen(id: string): void {
|
|||||||
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ripStarten(id: string): void {
|
/** Den Info-Lauf anstoßen (20–120 s) — erst auf Zuruf, nie beim Einlegen. */
|
||||||
anKern({ art: 'rip-start', id }, `Rip ${id}`)
|
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 {
|
export function ripAbbrechen(id: string): void {
|
||||||
|
|||||||
@@ -59,6 +59,18 @@
|
|||||||
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
|
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. */
|
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
|
||||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
::-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; }
|
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
||||||
|
|||||||
@@ -1,306 +1,411 @@
|
|||||||
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
|
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
|
||||||
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
|
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
|
||||||
// Datei läuft im Fenster UND im Kern.
|
// Datei läuft im Fenster UND im Kern.
|
||||||
//
|
//
|
||||||
// Wege:
|
// Wege:
|
||||||
// Kern → Haupt über process.parentPort (KernNachricht)
|
// Kern → Haupt über process.parentPort (KernNachricht)
|
||||||
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
|
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
|
||||||
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
|
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
|
||||||
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
|
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
|
||||||
|
|
||||||
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
|
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
|
||||||
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
|
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
|
||||||
|
|
||||||
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
|
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
|
||||||
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
|
||||||
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
|
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
|
||||||
|
|
||||||
export interface GeraeteInfo {
|
export interface GeraeteInfo {
|
||||||
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
|
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
typ: DiscTyp
|
typ: DiscTyp
|
||||||
pfad: string
|
pfad: string
|
||||||
status: LaufwerkStatus
|
status: LaufwerkStatus
|
||||||
modell: string
|
modell: string
|
||||||
seriennummer: string
|
seriennummer: string
|
||||||
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
|
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
|
||||||
groesseBytes: number
|
groesseBytes: number
|
||||||
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
|
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
|
||||||
grund: string
|
grund: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WacheEreignis =
|
export type WacheEreignis =
|
||||||
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
|
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
|
||||||
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
|
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
|
||||||
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
|
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
|
||||||
| { art: 'laufwerk-weg'; id: string }
|
| { art: 'laufwerk-weg'; id: string }
|
||||||
|
|
||||||
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
||||||
|
|
||||||
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
|
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
|
||||||
export type RipPhase =
|
export type RipPhase =
|
||||||
| 'liest-info'
|
| 'liest-info'
|
||||||
| 'rippt'
|
| 'rippt'
|
||||||
| 'komprimiert'
|
| 'komprimiert'
|
||||||
| 'legt-ab'
|
| 'legt-ab'
|
||||||
| 'fertig'
|
| 'fertig'
|
||||||
| 'fehler'
|
| 'fehler'
|
||||||
| 'abgebrochen'
|
| 'abgebrochen'
|
||||||
|
|
||||||
export interface RipStatus {
|
export interface RipStatus {
|
||||||
/** Laufwerks-Kennung, auf der der Rip läuft. */
|
/** Laufwerks-Kennung, auf der der Rip läuft. */
|
||||||
id: string
|
id: string
|
||||||
phase: RipPhase
|
phase: RipPhase
|
||||||
/** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
|
/** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
|
||||||
prozent: number
|
prozent: number
|
||||||
text: string
|
text: string
|
||||||
dateien: string[]
|
dateien: string[]
|
||||||
lesefehler: boolean
|
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
|
||||||
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
* und in Fehler-Meldungen; die kennen keine Titel. */
|
||||||
export interface DiscInfoStand {
|
titelNr?: number
|
||||||
id: string
|
titelGesamt?: number
|
||||||
titel: string
|
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
|
||||||
jahr: number | null
|
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
|
||||||
/** 0–1; die Schwelle für „sicher genug" ist einstellbar. */
|
haengt?: { offset: string; wiederholungen: number } | null
|
||||||
confidence: number
|
}
|
||||||
quelle: string
|
|
||||||
posterPfad: string
|
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
|
||||||
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
|
* hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
|
||||||
backdropPfad: string
|
* extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
|
||||||
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
|
* folge → Serien/<Titel>/Season NN/… S01E02.mkv
|
||||||
beschreibung: string
|
* EINE Definition für Kern und Fenster. */
|
||||||
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
|
||||||
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
|
||||||
schonGerippt: string | null
|
/** 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
|
||||||
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
* bei eindeutiger Zuordnung um (§ 4.2). */
|
||||||
export interface BibliothekEintrag {
|
export interface RipAuftragTitel {
|
||||||
fingerprint: string
|
nr: number
|
||||||
titel: string
|
rolle: TitelRolle
|
||||||
jahr: number | null
|
dauerS: number
|
||||||
typ: string
|
}
|
||||||
groesseBytes: number
|
|
||||||
ablageort: string
|
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
|
||||||
datum: string
|
export interface DiscTitelVorschlag {
|
||||||
dauerS: number
|
nr: number
|
||||||
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
|
dauerS: number
|
||||||
posterPfad: string
|
groesseBytes: number
|
||||||
}
|
kapitel: number
|
||||||
|
/** Von Rippy vorausgewählt. */
|
||||||
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
|
gewaehlt: boolean
|
||||||
export interface MetaVorschlag {
|
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
|
||||||
typ: 'movie' | 'tv'
|
rolle: TitelRolle
|
||||||
tmdbId: number
|
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
||||||
titel: string
|
grund: string
|
||||||
jahr: number | null
|
}
|
||||||
posterPfad: 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
|
||||||
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
|
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
|
||||||
export interface WerkzeugAuskunft {
|
export interface TitelStand {
|
||||||
makemkvcon: string
|
id: string
|
||||||
handbrake: string
|
laeuft: boolean
|
||||||
flac: string
|
titel: DiscTitelVorschlag[]
|
||||||
presets: string[]
|
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
|
||||||
backends: string[]
|
einordnung: string
|
||||||
}
|
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
|
||||||
|
unsicher: boolean
|
||||||
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
|
fehler: string
|
||||||
export interface BeschaffungStand {
|
}
|
||||||
/** Welches Werkzeug — bisher nur 'makemkv'. */
|
|
||||||
werkzeug: string
|
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||||||
laeuft: boolean
|
export interface DiscInfoStand {
|
||||||
text: string
|
id: string
|
||||||
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
titel: string
|
||||||
anteil: number | null
|
jahr: number | null
|
||||||
fehler: boolean
|
/** 0–1; die Schwelle für „sicher genug" ist einstellbar. */
|
||||||
}
|
confidence: number
|
||||||
|
quelle: string
|
||||||
/** Nachrichten, die der Kern verschickt. */
|
posterPfad: string
|
||||||
export type KernNachricht =
|
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
|
||||||
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
backdropPfad: string
|
||||||
| { art: 'pong'; zeit: number }
|
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
|
||||||
| { art: 'kern-fehler'; text: string }
|
beschreibung: string
|
||||||
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||||||
| { art: 'rip-status'; status: RipStatus }
|
schonGerippt: string | null
|
||||||
| { art: 'disc-info'; info: DiscInfoStand }
|
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
|
||||||
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
discNr: number | null
|
||||||
| { art: 'einstellungen'; werte: Record<string, string> }
|
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
|
||||||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
staffel: number | null
|
||||||
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
|
||||||
| { art: 'beschaffung-status'; stand: BeschaffungStand }
|
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
|
||||||
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
|
folgen: number
|
||||||
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
|
}
|
||||||
| { art: 'installer-starten'; pfad: string }
|
|
||||||
| {
|
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
||||||
art: 'schluessel-status'
|
export interface BibliothekEintrag {
|
||||||
stand: {
|
fingerprint: string
|
||||||
quelle: string
|
titel: string
|
||||||
angenommen: boolean | null
|
jahr: number | null
|
||||||
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
|
typ: string
|
||||||
weiterBetrieb: boolean
|
groesseBytes: number
|
||||||
gueltigBis: string
|
ablageort: string
|
||||||
geholtAm: string
|
datum: string
|
||||||
text: string
|
dauerS: number
|
||||||
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
|
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
|
||||||
version: string
|
posterPfad: string
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
|
||||||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
export interface MetaVorschlag {
|
||||||
export type FensterNachricht =
|
typ: 'movie' | 'tv'
|
||||||
| { art: 'ping'; zeit: number }
|
tmdbId: number
|
||||||
| { art: 'auswerfen'; id: string }
|
titel: string
|
||||||
| { art: 'rip-start'; id: string }
|
jahr: number | null
|
||||||
| { art: 'rip-abbruch'; id: string }
|
posterPfad: string
|
||||||
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
}
|
||||||
| { art: 'metadaten-suchen'; id: string; text: string }
|
|
||||||
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
|
||||||
| { art: 'einstellungen-laden' }
|
export interface WerkzeugAuskunft {
|
||||||
| { art: 'werkzeuge-laden' }
|
makemkvcon: string
|
||||||
| { art: 'bibliothek-laden' }
|
handbrake: string
|
||||||
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
|
flac: string
|
||||||
| { art: 'makemkv-update' }
|
presets: string[]
|
||||||
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
backends: string[]
|
||||||
| { art: 'schluessel-pruefen' }
|
}
|
||||||
|
|
||||||
/** Nachrichten, die das Haupt an den Kern schickt. */
|
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
|
||||||
export type HauptNachricht =
|
export interface BeschaffungStand {
|
||||||
| { art: 'fenster-port' }
|
/** Welches Werkzeug — bisher nur 'makemkv'. */
|
||||||
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
|
werkzeug: string
|
||||||
| { art: 'installer-ergebnis'; fehler: string | null }
|
laeuft: boolean
|
||||||
|
text: string
|
||||||
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
|
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||||||
export interface HauptStatus {
|
anteil: number | null
|
||||||
version: string
|
fehler: boolean
|
||||||
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
|
}
|
||||||
leine: { ok: boolean; fehler?: string }
|
|
||||||
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
/** Nachrichten, die der Kern verschickt. */
|
||||||
autostart: boolean
|
export type KernNachricht =
|
||||||
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */
|
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
||||||
update: { letzteErfolgreichePruefung: string; text: string }
|
| { art: 'pong'; zeit: number }
|
||||||
}
|
| { art: 'kern-fehler'; text: string }
|
||||||
|
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
||||||
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||||
return typeof wert === 'object' && wert !== null
|
| { art: 'rip-status'; status: RipStatus }
|
||||||
}
|
| { art: 'disc-info'; info: DiscInfoStand }
|
||||||
|
| { art: 'disc-titel'; stand: TitelStand }
|
||||||
export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
||||||
if (!istObjekt(wert)) return false
|
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||||
switch (wert.art) {
|
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||||
case 'kern-bereit':
|
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
|
||||||
return (
|
| { art: 'beschaffung-status'; stand: BeschaffungStand }
|
||||||
typeof wert.pid === 'number' &&
|
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt —
|
||||||
typeof wert.nodeVersion === 'string' &&
|
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
|
||||||
istDbStatus(wert.datenbank)
|
| { art: 'installer-starten'; pfad: string }
|
||||||
)
|
| {
|
||||||
case 'pong':
|
art: 'schluessel-status'
|
||||||
return typeof wert.zeit === 'number'
|
stand: {
|
||||||
case 'kern-fehler':
|
quelle: string
|
||||||
return typeof wert.text === 'string'
|
angenommen: boolean | null
|
||||||
case 'laufwerke':
|
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
|
||||||
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
|
weiterBetrieb: boolean
|
||||||
case 'auswurf-ergebnis':
|
gueltigBis: string
|
||||||
return (
|
geholtAm: string
|
||||||
typeof wert.id === 'string' &&
|
text: string
|
||||||
typeof wert.grund === 'string' &&
|
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
|
||||||
(wert.fehler === null || typeof wert.fehler === 'string')
|
version: string
|
||||||
)
|
}
|
||||||
case 'rip-status':
|
}
|
||||||
return istRipStatus(wert.status)
|
|
||||||
case 'disc-info':
|
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||||
return (
|
export type FensterNachricht =
|
||||||
istObjekt(wert.info) &&
|
| { art: 'ping'; zeit: number }
|
||||||
typeof (wert.info as Record<string, unknown>).id === 'string' &&
|
| { art: 'auswerfen'; id: string }
|
||||||
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
/** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 s, deshalb erst auf Zuruf
|
||||||
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||||
)
|
* sehr sehr lange"). */
|
||||||
case 'einstellungen':
|
| { art: 'titel-lesen'; id: string }
|
||||||
return istObjekt(wert.werte)
|
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
||||||
case 'werkzeuge':
|
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[] }
|
||||||
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
|
| { art: 'rip-abbruch'; id: string }
|
||||||
case 'bibliothek':
|
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
||||||
return Array.isArray(wert.eintraege)
|
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
||||||
case 'schluessel-status':
|
| { art: 'rip-ueberspringen'; id: string }
|
||||||
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
||||||
case 'beschaffung-status':
|
| { art: 'metadaten-suchen'; id: string; text: string }
|
||||||
return (
|
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
||||||
istObjekt(wert.stand) &&
|
| { art: 'einstellungen-laden' }
|
||||||
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
|
| { art: 'werkzeuge-laden' }
|
||||||
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
|
| { art: 'bibliothek-laden' }
|
||||||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
|
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
|
||||||
)
|
| { art: 'makemkv-update' }
|
||||||
case 'installer-starten':
|
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||||||
return typeof wert.pfad === 'string' && wert.pfad.length > 0
|
| { art: 'schluessel-pruefen' }
|
||||||
case 'metadaten-vorschlaege':
|
|
||||||
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
|
/** Nachrichten, die das Haupt an den Kern schickt. */
|
||||||
default:
|
export type HauptNachricht =
|
||||||
return false
|
| { art: 'fenster-port' }
|
||||||
}
|
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
|
||||||
}
|
| { art: 'installer-ergebnis'; fehler: string | null }
|
||||||
|
|
||||||
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
|
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
|
||||||
if (!istObjekt(wert)) return false
|
export interface HauptStatus {
|
||||||
return (
|
version: string
|
||||||
(wert.typ === 'movie' || wert.typ === 'tv') &&
|
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
|
||||||
typeof wert.tmdbId === 'number' &&
|
leine: { ok: boolean; fehler?: string }
|
||||||
typeof wert.titel === 'string' &&
|
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
||||||
(wert.jahr === null || typeof wert.jahr === 'number') &&
|
autostart: boolean
|
||||||
typeof wert.posterPfad === '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. */
|
||||||
export function istRipStatus(wert: unknown): wert is RipStatus {
|
fensterMaximiert: boolean
|
||||||
if (!istObjekt(wert)) return false
|
}
|
||||||
return (
|
|
||||||
typeof wert.id === 'string' &&
|
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
|
||||||
typeof wert.phase === 'string' &&
|
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
|
||||||
typeof wert.prozent === 'number' &&
|
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
|
||||||
typeof wert.text === 'string' &&
|
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
|
||||||
Array.isArray(wert.dateien) &&
|
export interface UpdateStand {
|
||||||
typeof wert.lesefehler === 'boolean'
|
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||||
)
|
letzteErfolgreichePruefung: string
|
||||||
}
|
text: string
|
||||||
|
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
|
||||||
export function istDbStatus(wert: unknown): wert is DbStatus {
|
neueVersion: string
|
||||||
if (!istObjekt(wert)) return false
|
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
|
||||||
if (wert.ok === true) return typeof wert.pfad === 'string'
|
neuerungen: string
|
||||||
if (wert.ok === false) return typeof wert.fehler === 'string'
|
/** Geladen und wartet auf den Neustart. */
|
||||||
return false
|
bereit: boolean
|
||||||
}
|
/** Eine Prüfung oder ein Download läuft gerade. */
|
||||||
|
laeuft: boolean
|
||||||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
|
||||||
if (!istObjekt(wert)) return false
|
fortschritt: number
|
||||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
}
|
||||||
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
|
|
||||||
return typeof wert.id === 'string' && wert.id.length > 0
|
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
||||||
}
|
return typeof wert === 'object' && wert !== null
|
||||||
if (wert.art === 'einstellung-setzen') {
|
}
|
||||||
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
|
|
||||||
}
|
export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||||
if (wert.art === 'metadaten-suchen') {
|
if (!istObjekt(wert)) return false
|
||||||
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
|
switch (wert.art) {
|
||||||
}
|
case 'kern-bereit':
|
||||||
if (wert.art === 'zuordnung-setzen') {
|
return (
|
||||||
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
|
typeof wert.pid === 'number' &&
|
||||||
}
|
typeof wert.nodeVersion === 'string' &&
|
||||||
return (
|
istDbStatus(wert.datenbank)
|
||||||
wert.art === 'einstellungen-laden' ||
|
)
|
||||||
wert.art === 'werkzeuge-laden' ||
|
case 'pong':
|
||||||
wert.art === 'bibliothek-laden' ||
|
return typeof wert.zeit === 'number'
|
||||||
wert.art === 'makemkv-update' ||
|
case 'kern-fehler':
|
||||||
wert.art === 'schluessel-pruefen'
|
return typeof wert.text === 'string'
|
||||||
)
|
case 'laufwerke':
|
||||||
}
|
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
|
||||||
|
case 'auswurf-ergebnis':
|
||||||
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
return (
|
||||||
if (!istObjekt(wert)) return false
|
typeof wert.id === 'string' &&
|
||||||
if (wert.art === 'fenster-port') return true
|
typeof wert.grund === 'string' &&
|
||||||
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
|
(wert.fehler === null || typeof wert.fehler === 'string')
|
||||||
return false
|
)
|
||||||
}
|
case 'rip-status':
|
||||||
|
return istRipStatus(wert.status)
|
||||||
|
case 'disc-info':
|
||||||
|
return (
|
||||||
|
istObjekt(wert.info) &&
|
||||||
|
typeof (wert.info as Record<string, unknown>).id === 'string' &&
|
||||||
|
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':
|
||||||
|
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
|
||||||
|
case 'bibliothek':
|
||||||
|
return Array.isArray(wert.eintraege)
|
||||||
|
case 'schluessel-status':
|
||||||
|
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
|
||||||
|
case 'beschaffung-status':
|
||||||
|
return (
|
||||||
|
istObjekt(wert.stand) &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
|
||||||
|
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
|
||||||
|
)
|
||||||
|
case 'installer-starten':
|
||||||
|
return typeof wert.pfad === 'string' && wert.pfad.length > 0
|
||||||
|
case 'metadaten-vorschlaege':
|
||||||
|
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
return (
|
||||||
|
(wert.typ === 'movie' || wert.typ === 'tv') &&
|
||||||
|
typeof wert.tmdbId === 'number' &&
|
||||||
|
typeof wert.titel === 'string' &&
|
||||||
|
(wert.jahr === null || typeof wert.jahr === 'number') &&
|
||||||
|
typeof wert.posterPfad === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istRipStatus(wert: unknown): wert is RipStatus {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
return (
|
||||||
|
typeof wert.id === 'string' &&
|
||||||
|
typeof wert.phase === 'string' &&
|
||||||
|
typeof wert.prozent === 'number' &&
|
||||||
|
typeof wert.text === 'string' &&
|
||||||
|
Array.isArray(wert.dateien) &&
|
||||||
|
typeof wert.lesefehler === 'boolean'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istDbStatus(wert: unknown): wert is DbStatus {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
if (wert.ok === true) return typeof wert.pfad === 'string'
|
||||||
|
if (wert.ok === false) return typeof wert.fehler === 'string'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === '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') {
|
||||||
|
return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
|
||||||
|
}
|
||||||
|
if (wert.art === 'metadaten-suchen') {
|
||||||
|
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
|
||||||
|
}
|
||||||
|
if (wert.art === 'zuordnung-setzen') {
|
||||||
|
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
wert.art === 'einstellungen-laden' ||
|
||||||
|
wert.art === 'werkzeuge-laden' ||
|
||||||
|
wert.art === 'bibliothek-laden' ||
|
||||||
|
wert.art === 'makemkv-update' ||
|
||||||
|
wert.art === 'schluessel-pruefen'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
|
||||||
|
if (!istObjekt(wert)) return false
|
||||||
|
if (wert.art === 'fenster-port') return true
|
||||||
|
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,15 +4,25 @@
|
|||||||
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
|
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
|
||||||
import { BrowserWindow } from 'electron'
|
import { BrowserWindow } from 'electron'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { fensterrandEntfernen } from './fensterrand'
|
||||||
|
|
||||||
export function fensterErstellen(): BrowserWindow {
|
export function fensterErstellen(): BrowserWindow {
|
||||||
const fenster = new BrowserWindow({
|
const fenster = new BrowserWindow({
|
||||||
width: 1000,
|
width: 1280,
|
||||||
height: 700,
|
height: 820,
|
||||||
minWidth: 720,
|
minWidth: 860,
|
||||||
minHeight: 480,
|
minHeight: 560,
|
||||||
show: true,
|
show: true,
|
||||||
autoHideMenuBar: 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,
|
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
|
||||||
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
|
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
|
||||||
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
|
// (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.
|
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
|
||||||
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
|
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
|
||||||
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
|
// #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 devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||||
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
|
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
|
||||||
if (devUrl !== undefined && devUrl.length > 0) {
|
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 { KernVerwaltung } from './kernstart'
|
||||||
import { leineSetzen, type LeineStatus } from './leine'
|
import { leineSetzen, type LeineStatus } from './leine'
|
||||||
import { trayErstellen, trayHinweis } from './tray'
|
import { trayErstellen, trayHinweis } from './tray'
|
||||||
import { updateEinrichten, updateStand } from './update'
|
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
|
||||||
|
|
||||||
const smokeModus = process.argv.includes('--smoke')
|
const smokeModus = process.argv.includes('--smoke')
|
||||||
|
|
||||||
@@ -83,6 +83,13 @@ function start(): void {
|
|||||||
nachricht.werte['updateUrl'] ?? '',
|
nachricht.werte['updateUrl'] ?? '',
|
||||||
() => aktiveVorgaenge.size > 0,
|
() => aktiveVorgaenge.size > 0,
|
||||||
(text) => toast('Rippy-Update', text),
|
(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') {
|
} else if (nachricht.art === 'installer-starten') {
|
||||||
@@ -172,6 +179,9 @@ function start(): void {
|
|||||||
kern: kernVerwaltung.zustand(),
|
kern: kernVerwaltung.zustand(),
|
||||||
autostart: autostartAktiv(),
|
autostart: autostartAktiv(),
|
||||||
update: updateStand(),
|
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)
|
fenster.webContents.send('haupt-status', status)
|
||||||
}
|
}
|
||||||
@@ -185,6 +195,11 @@ function start(): void {
|
|||||||
statusSenden()
|
statusSenden()
|
||||||
smokePruefen()
|
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.
|
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
|
||||||
fenster.on('close', (ereignis) => {
|
fenster.on('close', (ereignis) => {
|
||||||
if (beendenGewollt) return
|
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.
|
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
|
||||||
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
|
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
|
||||||
autostartSetzen(an === true)
|
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
|
// 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.
|
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
|
||||||
import koffi from 'koffi'
|
import koffi from 'koffi'
|
||||||
|
import { kommandozeileBauen } from './kommandozeile'
|
||||||
|
|
||||||
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
|
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
|
||||||
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
|
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
|
||||||
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
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
|
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
|
||||||
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
|
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
|
||||||
// bei Offset 16.
|
// bei Offset 16.
|
||||||
@@ -63,7 +96,7 @@ export function leineSetzen(): LeineStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = Buffer.alloc(STRUKTUR_GROESSE)
|
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 (
|
if (
|
||||||
!SetInformationJobObject(
|
!SetInformationJobObject(
|
||||||
job,
|
job,
|
||||||
@@ -90,3 +123,64 @@ export function leineSetzen(): LeineStatus {
|
|||||||
return gesetzt
|
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).',
|
'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:
|
// öffentlichen Gitea (Entscheid 6). Die vier Regeln:
|
||||||
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
|
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
|
||||||
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
|
// 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
|
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
|
||||||
// letzten erfolgreichen Prüfung steht im Status.
|
// letzten erfolgreichen Prüfung steht im Status.
|
||||||
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
|
// 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 { app } from 'electron'
|
||||||
import electronUpdater from 'electron-updater'
|
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
|
const { autoUpdater } = electronUpdater
|
||||||
|
|
||||||
export interface UpdateStand {
|
// EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle).
|
||||||
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
export type { UpdateStand } from '../gemeinsam/nachrichten'
|
||||||
letzteErfolgreichePruefung: string
|
import type { UpdateStand } from '../gemeinsam/nachrichten'
|
||||||
text: string
|
|
||||||
|
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 {
|
export function updateStand(): UpdateStand {
|
||||||
return { ...stand }
|
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(
|
export function updateEinrichten(
|
||||||
eigeneUrl: string,
|
eigeneUrl: string,
|
||||||
ripLaeuft: () => boolean,
|
ripLaeuft: () => boolean,
|
||||||
melden: (text: string) => void,
|
melden: (text: string) => void,
|
||||||
|
beiAenderung: () => void = () => {
|
||||||
|
// ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar
|
||||||
|
},
|
||||||
|
beenden: () => void = () => app.quit(),
|
||||||
): void {
|
): void {
|
||||||
|
aenderungMelden = beiAenderung
|
||||||
|
ripLaeuftJetzt = ripLaeuft
|
||||||
|
rippyBeenden = beenden
|
||||||
if (!app.isPackaged) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const pruefung = updateUrlPruefen(eigeneUrl)
|
const pruefung = updateUrlPruefen(eigeneUrl)
|
||||||
if (!pruefung.ok) {
|
if (!pruefung.ok) {
|
||||||
stand.text = pruefung.grund
|
setzen({ text: pruefung.grund })
|
||||||
melden(pruefung.grund)
|
melden(pruefung.grund)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -45,39 +93,154 @@ export function updateEinrichten(
|
|||||||
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
|
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
|
||||||
}
|
}
|
||||||
autoUpdater.autoDownload = false
|
autoUpdater.autoDownload = false
|
||||||
// Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip.
|
// WIR installieren, nicht electron-updater (01.09.2026, am lebenden
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
// 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()) {
|
if (ripLaeuft()) {
|
||||||
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
|
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
|
||||||
// tägliche Takt versucht es wieder.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
void autoUpdater.downloadUpdate().catch((fehler: unknown) => {
|
setzen({
|
||||||
stand.text = `Update-Download fehlgeschlagen: ${String(fehler)}`
|
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) => {
|
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)
|
melden(stand.text)
|
||||||
})
|
})
|
||||||
autoUpdater.on('update-not-available', () => {
|
autoUpdater.on('update-not-available', () => {
|
||||||
stand.letzteErfolgreichePruefung = new Date().toISOString()
|
setzen({
|
||||||
stand.text = 'Rippy ist aktuell.'
|
letzteErfolgreichePruefung: new Date().toISOString(),
|
||||||
|
neueVersion: '',
|
||||||
|
neuerungen: '',
|
||||||
|
bereit: false,
|
||||||
|
laeuft: false,
|
||||||
|
fortschritt: -1,
|
||||||
|
text: 'Rippy ist aktuell.',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
autoUpdater.on('error', (fehler) => {
|
autoUpdater.on('error', (fehler) => {
|
||||||
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
|
// 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()
|
pruefen()
|
||||||
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
|
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: '' }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,12 +26,14 @@ import {
|
|||||||
listOptischeLaufwerke,
|
listOptischeLaufwerke,
|
||||||
} from './laufwerk/disc'
|
} from './laufwerk/disc'
|
||||||
import { DiscWache } from './laufwerk/wache'
|
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 { OmdbClient } from './metadaten/omdb'
|
||||||
import { TmdbClient } from './metadaten/tmdb'
|
import { TmdbClient } from './metadaten/tmdb'
|
||||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||||
import { Datenbank } from './speicher/db'
|
import { Datenbank } from './speicher/db'
|
||||||
|
import { titelInfoLesen, ABGELOEST } from './rip/makemkv'
|
||||||
|
import { titelVorauswahl } from './rip/titelwahl'
|
||||||
import { finden } from './werkzeuge/katalog'
|
import { finden } from './werkzeuge/katalog'
|
||||||
|
|
||||||
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
// --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? ─────────────────────────────────
|
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
||||||
const discZuordnungen = new Map<string, Zuordnung>()
|
const discZuordnungen = new Map<string, Zuordnung>()
|
||||||
const discFingerprints = new Map<string, string>()
|
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> {
|
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const gefunden = discTitel(geraetepfad)
|
const gefunden = discTitel(geraetepfad)
|
||||||
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
||||||
discFingerprints.set(id, fingerprint)
|
discFingerprints.set(id, fingerprint)
|
||||||
|
discMerkmale.set(id, gefunden)
|
||||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
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)
|
discZuordnungen.set(id, zuordnung)
|
||||||
anHauptUndFenster({
|
anHauptUndFenster({
|
||||||
art: 'disc-info',
|
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
|
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
||||||
|
discNr: gefunden.discNr,
|
||||||
|
staffel: gefunden.staffel,
|
||||||
|
folgen: gefunden.folgen,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
@@ -203,6 +214,10 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
|
|||||||
backdropPfad: zuordnung.meta.backdropPfad,
|
backdropPfad: zuordnung.meta.backdropPfad,
|
||||||
beschreibung: zuordnung.meta.beschreibung,
|
beschreibung: zuordnung.meta.beschreibung,
|
||||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
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) {
|
} catch (fehler) {
|
||||||
@@ -227,6 +242,7 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
} else if (ereignis.art === 'disc-entnommen') {
|
} else if (ereignis.art === 'disc-entnommen') {
|
||||||
discZuordnungen.delete(ereignis.geraet.id)
|
discZuordnungen.delete(ereignis.geraet.id)
|
||||||
discFingerprints.delete(ereignis.geraet.id)
|
discFingerprints.delete(ereignis.geraet.id)
|
||||||
|
discMerkmale.delete(ereignis.geraet.id)
|
||||||
anHauptUndFenster({
|
anHauptUndFenster({
|
||||||
art: 'disc-info',
|
art: 'disc-info',
|
||||||
info: {
|
info: {
|
||||||
@@ -239,6 +255,9 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
backdropPfad: '',
|
backdropPfad: '',
|
||||||
beschreibung: '',
|
beschreibung: '',
|
||||||
schonGerippt: null,
|
schonGerippt: null,
|
||||||
|
discNr: null,
|
||||||
|
staffel: null,
|
||||||
|
folgen: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -261,6 +280,95 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
// ── Rippen (W-2) ────────────────────────────────────────────────────────
|
// ── Rippen (W-2) ────────────────────────────────────────────────────────
|
||||||
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
|
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
|
||||||
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
|
// 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 {
|
function ablageOrdner(): string {
|
||||||
const eingestellt = db?.einstellungLesen('ablage')
|
const eingestellt = db?.einstellungLesen('ablage')
|
||||||
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
|
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),
|
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
||||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
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,
|
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||||
bibliothekEintragen: (eintrag) => {
|
bibliothekEintragen: (eintrag) => {
|
||||||
try {
|
try {
|
||||||
@@ -559,15 +682,31 @@ process.parentPort.on('message', (ereignis) => {
|
|||||||
if (fensterPort === null) return
|
if (fensterPort === null) return
|
||||||
fensterPort.on('message', (portEreignis) => {
|
fensterPort.on('message', (portEreignis) => {
|
||||||
const frage: unknown = portEreignis.data
|
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') {
|
if (frage.art === 'ping') {
|
||||||
anFenster({ art: 'pong', zeit: frage.zeit })
|
anFenster({ art: 'pong', zeit: frage.zeit })
|
||||||
} else if (frage.art === 'auswerfen') {
|
} else if (frage.art === 'auswerfen') {
|
||||||
void auswerfen(frage.id)
|
void auswerfen(frage.id)
|
||||||
|
} else if (frage.art === 'titel-lesen') {
|
||||||
|
void titelLesen(frage.id)
|
||||||
} else if (frage.art === 'rip-start') {
|
} 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') {
|
} else if (frage.art === 'rip-abbruch') {
|
||||||
ripVerwaltung.abbrechen(frage.id)
|
ripVerwaltung.abbrechen(frage.id)
|
||||||
|
} else if (frage.art === 'rip-ueberspringen') {
|
||||||
|
ripVerwaltung.ueberspringen(frage.id)
|
||||||
} else if (frage.art === 'metadaten-suchen') {
|
} else if (frage.art === 'metadaten-suchen') {
|
||||||
void metadatenSuchen(frage.id, frage.text)
|
void metadatenSuchen(frage.id, frage.text)
|
||||||
} else if (frage.art === 'zuordnung-setzen') {
|
} 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,
|
/** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von
|
||||||
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
* dieser Datei NUR den Titel — das Inhaltsverzeichnis daneben ist der
|
||||||
export function titelAusBdmt(wurzel: string): string {
|
* 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')
|
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
||||||
if (!existsSync(metaOrdner)) return ''
|
if (!existsSync(metaOrdner)) return leer
|
||||||
let kandidaten: string[]
|
let kandidaten: string[]
|
||||||
try {
|
try {
|
||||||
kandidaten = readdirSync(metaOrdner).sort()
|
kandidaten = readdirSync(metaOrdner).sort()
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
return leer
|
||||||
}
|
}
|
||||||
const bdmt =
|
const bdmt =
|
||||||
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
||||||
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||||
if (bdmt === undefined) return ''
|
if (bdmt === undefined) return leer
|
||||||
let inhalt: string
|
let inhalt: string
|
||||||
try {
|
try {
|
||||||
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
return leer
|
||||||
}
|
}
|
||||||
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||||
return treffer === null ? '' : treffer[1].trim()
|
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. */
|
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||||
@@ -138,22 +227,50 @@ export function discWurzel(geraetepfad: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscTitel {
|
export interface DiscTitel {
|
||||||
|
/** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */
|
||||||
titel: string
|
titel: string
|
||||||
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||||
quelle: 'bdmt' | 'label' | ''
|
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
|
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als
|
||||||
* BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */
|
* 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 {
|
export function discTitel(geraetepfad: string): DiscTitel {
|
||||||
const wurzel = discWurzel(geraetepfad)
|
const wurzel = discWurzel(geraetepfad)
|
||||||
|
let folgen = 0
|
||||||
if (wurzel.length > 0) {
|
if (wurzel.length > 0) {
|
||||||
const bdmt = titelAusBdmt(wurzel)
|
const bdmt = inhaltAusBdmt(wurzel)
|
||||||
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
|
folgen = folgenZahl(bdmt.eintraege)
|
||||||
|
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen)
|
||||||
}
|
}
|
||||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||||
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
|
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
|
||||||
return { titel: '', quelle: '' }
|
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
||||||
@@ -163,25 +280,42 @@ export function discFingerprint(geraetepfad: string, groesseBytes: number): stri
|
|||||||
return `${label}|${groesseBytes}`
|
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,
|
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||||
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
|
* ARM-Lehre) — von SPEZIFISCH nach vage:
|
||||||
* Klammern, dann hintere Worte strippen. */
|
*
|
||||||
|
* 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[] {
|
export function titelKandidaten(titel: string): string[] {
|
||||||
const kandidaten: string[] = []
|
const kandidaten: string[] = []
|
||||||
|
const nachlaufend = new RegExp(`${TRENNER}+$`)
|
||||||
const merke = (t: string): void => {
|
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)
|
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
|
||||||
}
|
}
|
||||||
merke(titel)
|
merke(titel)
|
||||||
if (titel.includes(':')) {
|
// Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist
|
||||||
merke(titel.split(':', 1)[0])
|
// der Kandidat, der die Serie findet, nicht der gestutzte Vorspann.
|
||||||
merke(titel.replaceAll(':', ''))
|
const ohneZusatz = discZusatzAbtrennen(titel).titel
|
||||||
}
|
merke(ohneZusatz)
|
||||||
merke(titel.replace(/\([^)]*\)/g, ''))
|
// Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn:
|
||||||
let worte = titel.split(/\s+/).filter(Boolean)
|
// beide Schreibweisen fragen.
|
||||||
while (worte.length > 1 && kandidaten.length < 6) {
|
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)
|
worte = worte.slice(0, -1)
|
||||||
merke(worte.join(' '))
|
merke(worte.join(' '))
|
||||||
}
|
}
|
||||||
|
if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0])
|
||||||
return kandidaten
|
return kandidaten
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//
|
//
|
||||||
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
||||||
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
|
// (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 { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
|
||||||
import type { OmdbClient, OmdbTreffer } from './omdb'
|
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||||
|
|
||||||
@@ -94,17 +94,21 @@ const UNBEKANNT = (titel: string): Zuordnung => ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 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
|
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
||||||
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
* 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"). */
|
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
||||||
export function wortUeberlappung(a: string, b: string): number {
|
export function wortUeberlappung(a: string, b: string): number {
|
||||||
const worte = (t: string): Set<string> =>
|
|
||||||
new Set(
|
|
||||||
t
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/[^\p{L}\p{N}]+/u)
|
|
||||||
.filter((w) => w.length > 0),
|
|
||||||
)
|
|
||||||
const wa = worte(a)
|
const wa = worte(a)
|
||||||
const wb = worte(b)
|
const wb = worte(b)
|
||||||
if (wa.size === 0 || wb.size === 0) return 0
|
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)
|
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.
|
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
|
||||||
*
|
*
|
||||||
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → (nur beim
|
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → spezifisch
|
||||||
* vollen Titel) spezifisch Film → spezifisch Serie. Erst dann der nächste,
|
* Film → spezifisch Serie. Erst dann der nächste, ärmere Kandidat.
|
||||||
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
|
*
|
||||||
* Beim Disc-Titel „Spartacus Gods Of The Arena" erschlug der stark
|
* Drei Sicherungen, alle an echten Discs bezahlt:
|
||||||
* gekürzte Kandidat „Spartacus" mit einem wörtlichen Treffer (Film von
|
*
|
||||||
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
|
* * `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(
|
export async function zuordnen(
|
||||||
discTitel: string,
|
discTitel: string,
|
||||||
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
||||||
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
||||||
|
hinweis: DiscHinweis = {},
|
||||||
): Promise<Zuordnung> {
|
): Promise<Zuordnung> {
|
||||||
const titel = discTitel.trim()
|
const titel = discTitel.trim()
|
||||||
if (titel.length === 0) return UNBEKANNT('')
|
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)
|
const kandidaten = titelKandidaten(titel)
|
||||||
if (kandidaten.length === 0) kandidaten.push(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++) {
|
for (const kandidat of kandidaten) {
|
||||||
const kandidat = kandidaten[i]
|
const deckung = abdeckung(massstab, kandidat)
|
||||||
const filme = (await tmdb.searchMovie(kandidat)) ?? []
|
const vollstaendig = deckung >= 1
|
||||||
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
|
/** 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)
|
// Wörtlicher Filmtreffer (0,95)
|
||||||
for (const film of filme) {
|
for (const film of filme) {
|
||||||
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
if (gleichBedeutend(film.title ?? '', kandidat)) {
|
||||||
const details = await tmdb.movieDetails(film.id)
|
const details = await tmdb.movieDetails(film.id)
|
||||||
if (details !== null) {
|
if (details !== null) {
|
||||||
const meta = ausDetails('movie', film.id, details)
|
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)) ?? []
|
const serien = (await tmdb.searchTv(kandidat)) ?? []
|
||||||
|
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
||||||
|
|
||||||
// Wörtlicher Serientreffer (0,90)
|
// Wörtlicher Serientreffer (0,90)
|
||||||
for (const serie of serien) {
|
for (const serie of serien) {
|
||||||
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
if (gleichBedeutend(serie.name ?? '', kandidat)) {
|
||||||
const details = await tmdb.tvDetails(serie.id)
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
if (details !== null) {
|
if (details !== null) {
|
||||||
const meta = ausDetails('tv', serie.id, details)
|
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):
|
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
|
||||||
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
|
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
|
||||||
// jemand, der weiß, was er sucht (der Evangelion-Fund).
|
// (der Evangelion-Fund).
|
||||||
if (i === 0) {
|
if (vollstaendig) {
|
||||||
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
||||||
const details = await tmdb.movieDetails(filme[0].id)
|
const details = await tmdb.movieDetails(filme[0].id)
|
||||||
if (details !== null) {
|
if (details !== null) {
|
||||||
@@ -189,6 +264,9 @@ export async function zuordnen(
|
|||||||
for (const kandidat of kandidaten) {
|
for (const kandidat of kandidaten) {
|
||||||
const treffer = await omdb.lookup(kandidat)
|
const treffer = await omdb.lookup(kandidat)
|
||||||
if (treffer !== null) {
|
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
|
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
|
||||||
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
||||||
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
||||||
@@ -208,17 +286,28 @@ export async function zuordnen(
|
|||||||
if (deutsch['type'] === 'tv') meta.typ = 'tv'
|
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.
|
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
|
||||||
if (ersteTreffer.length > 0) {
|
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
|
||||||
const details = await tmdb.movieDetails(ersteTreffer[0].id)
|
if (ersteFilme.length > 0) {
|
||||||
|
const details = await tmdb.movieDetails(ersteFilme[0].id)
|
||||||
if (details !== null) {
|
if (details !== null) {
|
||||||
const meta = ausDetails('movie', ersteTreffer[0].id, details)
|
const meta = ausDetails('movie', ersteFilme[0].id, details)
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,225 +1,303 @@
|
|||||||
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
|
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
|
||||||
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
|
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
|
||||||
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
|
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
|
||||||
//
|
//
|
||||||
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
|
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
|
||||||
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
|
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
|
||||||
// hing bzw. stürzte — Befund 23.07.)
|
// hing bzw. stürzte — Befund 23.07.)
|
||||||
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
|
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
|
||||||
// Kodierungen; „Das Öffnen" war die Folge von text=True.
|
// Kodierungen; „Das Öffnen" war die Folge von text=True.
|
||||||
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
|
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
|
||||||
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
|
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
|
||||||
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
|
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
|
||||||
// nur „Failed to open disc", die Ursache steht Zeilen davor.
|
// nur „Failed to open disc", die Ursache steht Zeilen davor.
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { mkdirSync, readdirSync } from 'node:fs'
|
import { mkdirSync, readdirSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
KRITISCHE_CODES,
|
KRITISCHE_CODES,
|
||||||
KRITISCHE_TEXTE,
|
KRITISCHE_TEXTE,
|
||||||
LESEFEHLER_MARKE,
|
LESEFEHLER_MARKE,
|
||||||
parseMsg,
|
offsetAusMeldung,
|
||||||
parseStreamInfo,
|
parseMsg,
|
||||||
parseTitelInfo,
|
parseStreamInfo,
|
||||||
progressAusPrgv,
|
parseTitelInfo,
|
||||||
quelle,
|
progressAusPrgv,
|
||||||
sprachenZusammenfassen,
|
quelle,
|
||||||
textVon,
|
sprachenZusammenfassen,
|
||||||
type SprachEintrag,
|
textVon,
|
||||||
type TitelInfo,
|
type SprachEintrag,
|
||||||
type TitelStreams,
|
type TitelInfo,
|
||||||
} from './parser'
|
type TitelStreams,
|
||||||
|
} from './parser'
|
||||||
export function buildInfoArgs(geraetepfad: string): string[] {
|
|
||||||
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
|
export function buildInfoArgs(geraetepfad: string): string[] {
|
||||||
}
|
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
|
||||||
|
}
|
||||||
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
|
|
||||||
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
|
||||||
}
|
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
||||||
|
}
|
||||||
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
|
||||||
export class RipAuswertung {
|
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
|
||||||
prozent = -1
|
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
|
||||||
letzteMeldung = ''
|
* zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer
|
||||||
lesefehler = false
|
* von einem echten Defekt zu unterscheiden. */
|
||||||
kritischeMeldungen: string[] = []
|
export const HAENGT_AB = 12
|
||||||
|
|
||||||
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
/** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der
|
||||||
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
* Aufrufer schweigt dazu — abgelöst ist kein Fehler, sondern Absicht. */
|
||||||
const p = progressAusPrgv(zeile)
|
export const ABGELOEST = 'titel-lauf-abgeloest'
|
||||||
if (p >= 0) {
|
|
||||||
if (p === this.prozent) return {}
|
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
||||||
this.prozent = p
|
export class RipAuswertung {
|
||||||
return { prozent: p }
|
prozent = -1
|
||||||
}
|
letzteMeldung = ''
|
||||||
const meldung = parseMsg(zeile)
|
lesefehler = false
|
||||||
if (meldung === null) return {}
|
kritischeMeldungen: string[] = []
|
||||||
this.letzteMeldung = meldung.text
|
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
|
||||||
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
haengtBei = ''
|
||||||
if (meldung.code in KRITISCHE_CODES) {
|
/** Wie oft dieselbe Stelle schon gemeldet wurde. */
|
||||||
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
|
haengtWiederholungen = 0
|
||||||
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
|
|
||||||
this.kritischeMeldungen.push(meldung.text)
|
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
|
||||||
}
|
* der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */
|
||||||
return { meldung }
|
haengt(): boolean {
|
||||||
}
|
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
|
||||||
|
}
|
||||||
fehlerText(returncode: number | null, dateienDa: boolean): string {
|
|
||||||
return (
|
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
||||||
`makemkvcon endete mit Code ${returncode ?? '?'}` +
|
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
||||||
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
|
const p = progressAusPrgv(zeile)
|
||||||
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
|
if (p >= 0) {
|
||||||
(dateienDa ? '' : ' — keine MKV-Datei entstanden')
|
if (p === this.prozent) return {}
|
||||||
)
|
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
|
||||||
}
|
this.haengtBei = ''
|
||||||
}
|
this.haengtWiederholungen = 0
|
||||||
|
this.prozent = p
|
||||||
/** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt
|
return { prozent: p }
|
||||||
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
|
}
|
||||||
export class ZeilenLeser {
|
const meldung = parseMsg(zeile)
|
||||||
private rest: Buffer = Buffer.alloc(0)
|
if (meldung === null) return {}
|
||||||
|
this.letzteMeldung = meldung.text
|
||||||
hinzu(stueck: Buffer): string[] {
|
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
||||||
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
|
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
|
||||||
const zeilen: string[] = []
|
const stelle = offsetAusMeldung(meldung.text)
|
||||||
for (;;) {
|
if (stelle.length > 0) {
|
||||||
const umbruch = this.rest.indexOf(0x0a)
|
if (stelle === this.haengtBei) {
|
||||||
if (umbruch < 0) break
|
this.haengtWiederholungen += 1
|
||||||
let ende = umbruch
|
} else {
|
||||||
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
|
this.haengtBei = stelle
|
||||||
zeilen.push(textVon(this.rest.subarray(0, ende)))
|
this.haengtWiederholungen = 1
|
||||||
this.rest = this.rest.subarray(umbruch + 1)
|
}
|
||||||
}
|
}
|
||||||
return zeilen
|
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))) {
|
||||||
schluss(): string[] {
|
this.kritischeMeldungen.push(meldung.text)
|
||||||
if (this.rest.length === 0) return []
|
}
|
||||||
const letzte = textVon(this.rest)
|
return { meldung }
|
||||||
this.rest = Buffer.alloc(0)
|
}
|
||||||
return letzte.trim().length > 0 ? [letzte] : []
|
|
||||||
}
|
fehlerText(returncode: number | null, dateienDa: boolean): string {
|
||||||
}
|
return (
|
||||||
|
`makemkvcon endete mit Code ${returncode ?? '?'}` +
|
||||||
export interface DiscInfo {
|
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
|
||||||
titel: TitelInfo[]
|
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
|
||||||
streams: Map<number, TitelStreams>
|
(dateienDa ? '' : ' — keine MKV-Datei entstanden')
|
||||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20–120 s,
|
|
||||||
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
|
/** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt
|
||||||
export function titelInfoLesen(
|
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
|
||||||
programm: string,
|
export class ZeilenLeser {
|
||||||
geraetepfad: string,
|
private rest: Buffer = Buffer.alloc(0)
|
||||||
timeoutMs = 300_000,
|
|
||||||
): Promise<DiscInfo> {
|
hinzu(stueck: Buffer): string[] {
|
||||||
return new Promise((fertig, scheitert) => {
|
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
|
||||||
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
const zeilen: string[] = []
|
||||||
windowsHide: true,
|
for (;;) {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
const umbruch = this.rest.indexOf(0x0a)
|
||||||
})
|
if (umbruch < 0) break
|
||||||
const stuecke: Buffer[] = []
|
let ende = umbruch
|
||||||
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
|
||||||
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
zeilen.push(textVon(this.rest.subarray(0, ende)))
|
||||||
const zeiger = setTimeout(() => {
|
this.rest = this.rest.subarray(umbruch + 1)
|
||||||
kind.kill()
|
}
|
||||||
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
return zeilen
|
||||||
}, timeoutMs)
|
}
|
||||||
kind.on('error', (fehler) => {
|
|
||||||
clearTimeout(zeiger)
|
schluss(): string[] {
|
||||||
scheitert(fehler)
|
if (this.rest.length === 0) return []
|
||||||
})
|
const letzte = textVon(this.rest)
|
||||||
kind.on('close', () => {
|
this.rest = Buffer.alloc(0)
|
||||||
clearTimeout(zeiger)
|
return letzte.trim().length > 0 ? [letzte] : []
|
||||||
const ausgabe = textVon(Buffer.concat(stuecke))
|
}
|
||||||
const titel = parseTitelInfo(ausgabe)
|
}
|
||||||
const streams = parseStreamInfo(ausgabe)
|
|
||||||
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
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
|
||||||
export type RipErgebnis =
|
* Programm konnte keine verwendbaren optischen Laufwerke finden")
|
||||||
| { status: 'success'; dateien: string[]; lesefehler: boolean }
|
* antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
|
||||||
| { status: 'cancelled' }
|
* stünde im Fenster nur „keine Titel gefunden" — und niemand wüsste,
|
||||||
| { status: 'error'; fehler: string; lesefehler: boolean }
|
* dass das Laufwerk gemeint ist und nicht die Disc. */
|
||||||
|
ursachen: string[]
|
||||||
export interface RipLaufOptionen {
|
titel: TitelInfo[]
|
||||||
programm: string
|
streams: Map<number, TitelStreams>
|
||||||
geraetepfad: string
|
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||||
zielOrdner: string
|
}
|
||||||
/** 'all' oder eine Titel-Nummer als Text. */
|
|
||||||
titel: string
|
/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20–120 s,
|
||||||
aufFortschritt?: (prozent: number) => void
|
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
|
||||||
aufMeldung?: (code: number, text: string) => void
|
export function titelInfoLesen(
|
||||||
abbruch?: AbortSignal
|
programm: string,
|
||||||
}
|
geraetepfad: string,
|
||||||
|
timeoutMs = 300_000,
|
||||||
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt
|
abbruch?: AbortSignal,
|
||||||
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
|
): Promise<DiscInfo> {
|
||||||
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
return new Promise((fertig, scheitert) => {
|
||||||
return new Promise((fertig) => {
|
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
||||||
mkdirSync(optionen.zielOrdner, { recursive: true })
|
windowsHide: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
const kind = spawn(
|
})
|
||||||
optionen.programm,
|
const stuecke: Buffer[] = []
|
||||||
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
|
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
||||||
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
|
||||||
)
|
const zeiger = setTimeout(() => {
|
||||||
|
kind.kill()
|
||||||
const auswertung = new RipAuswertung()
|
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
||||||
const leser = new ZeilenLeser()
|
}, timeoutMs)
|
||||||
let abgebrochen = false
|
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
|
||||||
|
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
|
||||||
const abbrechen = (): void => {
|
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
|
||||||
abgebrochen = true
|
// die längst gelieferte Titelliste).
|
||||||
kind.kill()
|
const aufgeben = (): void => {
|
||||||
}
|
clearTimeout(zeiger)
|
||||||
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
|
kind.kill()
|
||||||
|
scheitert(new Error(ABGELOEST))
|
||||||
const zeile = (z: string): void => {
|
}
|
||||||
const ergebnis = auswertung.verarbeite(z)
|
abbruch?.addEventListener('abort', aufgeben, { once: true })
|
||||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
kind.on('error', (fehler) => {
|
||||||
if (ergebnis.meldung !== undefined) {
|
clearTimeout(zeiger)
|
||||||
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
|
abbruch?.removeEventListener('abort', aufgeben)
|
||||||
}
|
scheitert(fehler)
|
||||||
}
|
})
|
||||||
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
kind.on('close', () => {
|
||||||
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
clearTimeout(zeiger)
|
||||||
|
abbruch?.removeEventListener('abort', aufgeben)
|
||||||
kind.on('error', (fehler) => {
|
const ausgabe = textVon(Buffer.concat(stuecke))
|
||||||
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
const titel = parseTitelInfo(ausgabe)
|
||||||
fertig({ status: 'error', fehler: String(fehler), lesefehler: false })
|
const streams = parseStreamInfo(ausgabe)
|
||||||
})
|
// Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
|
||||||
|
const ursachen: string[] = []
|
||||||
kind.on('close', (code) => {
|
for (const zeile of ausgabe.split('\n')) {
|
||||||
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
const meldung = parseMsg(zeile)
|
||||||
leser.schluss().forEach(zeile)
|
if (meldung === null) continue
|
||||||
if (abgebrochen) {
|
if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
|
||||||
fertig({ status: 'cancelled' })
|
ursachen.push(KRITISCHE_CODES[meldung.code])
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
let dateien: string[] = []
|
fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
||||||
try {
|
})
|
||||||
dateien = readdirSync(optionen.zielOrdner)
|
})
|
||||||
.filter((name) => name.endsWith('.mkv'))
|
}
|
||||||
.sort()
|
|
||||||
.map((name) => join(optionen.zielOrdner, name))
|
export type RipErgebnis =
|
||||||
} catch (fehler) {
|
| { status: 'success'; dateien: string[]; lesefehler: boolean }
|
||||||
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
|
| { status: 'cancelled' }
|
||||||
return
|
| { status: 'error'; fehler: string; lesefehler: boolean }
|
||||||
}
|
|
||||||
if (code === 0 && dateien.length > 0) {
|
export interface RipLaufOptionen {
|
||||||
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
|
programm: string
|
||||||
return
|
geraetepfad: string
|
||||||
}
|
zielOrdner: string
|
||||||
fertig({
|
/** 'all' oder eine Titel-Nummer als Text. */
|
||||||
status: 'error',
|
titel: string
|
||||||
fehler: auswertung.fehlerText(code, dateien.length > 0),
|
aufFortschritt?: (prozent: number) => void
|
||||||
lesefehler: auswertung.lesefehler,
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt
|
||||||
|
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
|
||||||
|
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
mkdirSync(optionen.zielOrdner, { recursive: true })
|
||||||
|
|
||||||
|
const kind = spawn(
|
||||||
|
optionen.programm,
|
||||||
|
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
|
||||||
|
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const auswertung = new RipAuswertung()
|
||||||
|
const leser = new ZeilenLeser()
|
||||||
|
let abgebrochen = false
|
||||||
|
|
||||||
|
const abbrechen = (): void => {
|
||||||
|
abgebrochen = true
|
||||||
|
kind.kill()
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
|
||||||
|
kind.on('error', (fehler) => {
|
||||||
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
fertig({ status: 'error', fehler: String(fehler), lesefehler: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
kind.on('close', (code) => {
|
||||||
|
optionen.abbruch?.removeEventListener('abort', abbrechen)
|
||||||
|
leser.schluss().forEach(zeile)
|
||||||
|
if (abgebrochen) {
|
||||||
|
fertig({ status: 'cancelled' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let dateien: string[] = []
|
||||||
|
try {
|
||||||
|
dateien = readdirSync(optionen.zielOrdner)
|
||||||
|
.filter((name) => name.endsWith('.mkv'))
|
||||||
|
.sort()
|
||||||
|
.map((name) => join(optionen.zielOrdner, name))
|
||||||
|
} catch (fehler) {
|
||||||
|
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (code === 0 && dateien.length > 0) {
|
||||||
|
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fertig({
|
||||||
|
status: 'error',
|
||||||
|
fehler: auswertung.fehlerText(code, dateien.length > 0),
|
||||||
|
lesefehler: auswertung.lesefehler,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).
|
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
|
||||||
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
|
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
|
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
|
||||||
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
|
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
|
||||||
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
|
// 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',
|
'Akira',
|
||||||
'Evangelion 2.22',
|
'Evangelion 2.22',
|
||||||
'Spartacus Gods Of The Arena',
|
'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',
|
'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 { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
discZusatzAbtrennen,
|
||||||
|
folgenZahl,
|
||||||
|
inhaltAusBdmt,
|
||||||
|
istFolgenName,
|
||||||
normalizeDiscLabel,
|
normalizeDiscLabel,
|
||||||
parseUdfDstring,
|
parseUdfDstring,
|
||||||
titelAusBdmt,
|
titelAusBdmt,
|
||||||
titelKandidaten,
|
titelKandidaten,
|
||||||
} from '../src/kern/metadaten/discmerkmale'
|
} 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'
|
import { posterUrl } from '../src/kern/ablage/poster'
|
||||||
|
|
||||||
let wegwerf: string[] = []
|
let wegwerf: string[] = []
|
||||||
@@ -169,10 +180,13 @@ describe('zuordnen — die Confidence-Kaskade', () => {
|
|||||||
expect(z.meta.quelle).toBe('omdb')
|
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
|
// Live gemessen (30.08.2026): 'Spartacus Gods Of The Arena' wurde zum
|
||||||
// Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf,
|
// Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf,
|
||||||
// BEVOR die volle Anfrage als spezifische Serie gewertet wurde.
|
// 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(
|
const z = await zuordnen(
|
||||||
'Spartacus Gods Of The Arena',
|
'Spartacus Gods Of The Arena',
|
||||||
fakeTmdb(
|
fakeTmdb(
|
||||||
@@ -181,7 +195,7 @@ describe('zuordnen — die Confidence-Kaskade', () => {
|
|||||||
),
|
),
|
||||||
keinOmdb,
|
keinOmdb,
|
||||||
)
|
)
|
||||||
expect(z.confidence).toBe(0.8)
|
expect(z.confidence).toBe(0.9)
|
||||||
expect(z.meta.typ).toBe('tv')
|
expect(z.meta.typ).toBe('tv')
|
||||||
expect(z.titel).toBe('Spartacus: Gods of the Arena')
|
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', () => {
|
describe('posterUrl', () => {
|
||||||
it('relative TMDb-Pfade bekommen das w500-Präfix', () => {
|
it('relative TMDb-Pfade bekommen das w500-Präfix', () => {
|
||||||
expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg')
|
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
|
// Ohne Code-Signing (Entscheid 5) ist der Transportweg die EINZIGE
|
||||||
// Absicherung des Selbst-Updates.
|
// Absicherung des Selbst-Updates.
|
||||||
import { describe, expect, it } from 'vitest'
|
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', () => {
|
describe('updateUrlPruefen', () => {
|
||||||
it('leer heißt Standard (der ist https)', () => {
|
it('leer heißt Standard (der ist https)', () => {
|
||||||
@@ -22,3 +28,87 @@ describe('updateUrlPruefen', () => {
|
|||||||
expect(updateUrlPruefen('file://x').ok).toBe(false)
|
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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+158
-110
@@ -1,110 +1,158 @@
|
|||||||
// Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3.
|
// Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3.
|
||||||
// Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem
|
// Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem
|
||||||
// bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei.
|
// bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei.
|
||||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
import { join, relative, sep } from 'node:path'
|
import { join, relative, sep } from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const WURZEL = join(__dirname, '..')
|
const WURZEL = join(__dirname, '..')
|
||||||
const SRC = join(WURZEL, 'src')
|
const SRC = join(WURZEL, 'src')
|
||||||
|
|
||||||
function quelldateien(ordner: string): string[] {
|
function quelldateien(ordner: string): string[] {
|
||||||
const ergebnis: string[] = []
|
const ergebnis: string[] = []
|
||||||
for (const name of readdirSync(ordner)) {
|
for (const name of readdirSync(ordner)) {
|
||||||
const pfad = join(ordner, name)
|
const pfad = join(ordner, name)
|
||||||
if (statSync(pfad).isDirectory()) {
|
if (statSync(pfad).isDirectory()) {
|
||||||
ergebnis.push(...quelldateien(pfad))
|
ergebnis.push(...quelldateien(pfad))
|
||||||
} else if (/\.(ts|tsx)$/.test(name)) {
|
} else if (/\.(ts|tsx)$/.test(name)) {
|
||||||
ergebnis.push(pfad)
|
ergebnis.push(pfad)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ergebnis
|
return ergebnis
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativPosix(pfad: string): string {
|
function relativPosix(pfad: string): string {
|
||||||
return relative(WURZEL, pfad).split(sep).join('/')
|
return relative(WURZEL, pfad).split(sep).join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateien = quelldateien(SRC).map((pfad) => ({
|
const dateien = quelldateien(SRC).map((pfad) => ({
|
||||||
pfad: relativPosix(pfad),
|
pfad: relativPosix(pfad),
|
||||||
inhalt: readFileSync(pfad, 'utf8'),
|
inhalt: readFileSync(pfad, 'utf8'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
|
describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
|
||||||
// § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die
|
// § 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:
|
// 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.
|
// 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
|
||||||
it('koffi wird nirgendwo sonst angefasst', () => {
|
// Regel bleibt, was sie war — „Win32 nur an benannten Orten"; sie zählt
|
||||||
const verstoesse = dateien
|
// jetzt drei statt zwei, und der Test hält alles andere weiterhin dicht.
|
||||||
.filter((d) => !erlaubt.has(d.pfad))
|
const erlaubt = new Set([
|
||||||
.filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt))
|
'src/haupt/leine.ts',
|
||||||
.map((d) => d.pfad)
|
'src/haupt/fensterrand.ts',
|
||||||
expect(verstoesse).toEqual([])
|
'src/kern/laufwerk/win32.ts',
|
||||||
})
|
])
|
||||||
})
|
|
||||||
|
it('koffi wird nirgendwo sonst angefasst', () => {
|
||||||
describe('R2/R4 — kein Fehlschlag verschwindet still', () => {
|
const verstoesse = dateien
|
||||||
it('kein leerer catch-Block', () => {
|
.filter((d) => !erlaubt.has(d.pfad))
|
||||||
// R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde
|
.filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt))
|
||||||
// gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum.
|
.map((d) => d.pfad)
|
||||||
const verstoesse = dateien
|
expect(verstoesse).toEqual([])
|
||||||
.filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt))
|
})
|
||||||
.map((d) => d.pfad)
|
})
|
||||||
expect(verstoesse).toEqual([])
|
|
||||||
})
|
describe('R2/R4 — kein Fehlschlag verschwindet still', () => {
|
||||||
|
it('kein leerer catch-Block', () => {
|
||||||
it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => {
|
// R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde
|
||||||
// R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß
|
// gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum.
|
||||||
// „es gibt keine Jobs", die Liste leerte sich im Sekundentakt.
|
const verstoesse = dateien
|
||||||
const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/
|
.filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt))
|
||||||
const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/
|
.map((d) => d.pfad)
|
||||||
const verstoesse = dateien
|
expect(verstoesse).toEqual([])
|
||||||
.filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt))
|
})
|
||||||
.map((d) => d.pfad)
|
|
||||||
expect(verstoesse).toEqual([])
|
it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => {
|
||||||
})
|
// R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß
|
||||||
})
|
// „es gibt keine Jobs", die Liste leerte sich im Sekundentakt.
|
||||||
|
const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/
|
||||||
describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => {
|
const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/
|
||||||
it('niemand öffnet einen Server oder lauscht auf einem Port', () => {
|
const verstoesse = dateien
|
||||||
// Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene
|
.filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt))
|
||||||
// Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von
|
.map((d) => d.pfad)
|
||||||
// electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den
|
expect(verstoesse).toEqual([])
|
||||||
// reinen Variablennamen ist erlaubt.
|
})
|
||||||
const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/
|
})
|
||||||
const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad)
|
|
||||||
expect(verstoesse).toEqual([])
|
describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => {
|
||||||
})
|
it('niemand öffnet einen Server oder lauscht auf einem Port', () => {
|
||||||
})
|
// Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene
|
||||||
|
// Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von
|
||||||
describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => {
|
// electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den
|
||||||
it('keine Datei enthält Steuerzeichen außer Tab/LF/CR', () => {
|
// reinen Variablennamen ist erlaubt.
|
||||||
// Zweimal passiert (30.08.2026): Escape-Sequenzen wie \x00 landeten
|
const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/
|
||||||
// als ECHTE Bytes in der Datei — grep meldet „Binary file", Diffs
|
const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad)
|
||||||
// leiden, und ein Test sah dadurch anders aus, als sein Quelltext las.
|
expect(verstoesse).toEqual([])
|
||||||
const testDateien = readdirSync(join(WURZEL, 'test'))
|
})
|
||||||
.filter((name) => /\.(ts|tsx)$/.test(name))
|
})
|
||||||
.map((name) => ({
|
|
||||||
pfad: `test/${name}`,
|
describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => {
|
||||||
inhalt: readFileSync(join(WURZEL, 'test', name), 'utf8'),
|
it('keine Datei enthält Steuerzeichen außer Tab/LF/CR', () => {
|
||||||
}))
|
// Zweimal passiert (30.08.2026): Escape-Sequenzen wie \x00 landeten
|
||||||
const verstoesse = [...dateien, ...testDateien]
|
// als ECHTE Bytes in der Datei — grep meldet „Binary file", Diffs
|
||||||
.filter((d) => /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(d.inhalt))
|
// leiden, und ein Test sah dadurch anders aus, als sein Quelltext las.
|
||||||
.map((d) => d.pfad)
|
const testDateien = readdirSync(join(WURZEL, 'test'))
|
||||||
expect(verstoesse).toEqual([])
|
.filter((name) => /\.(ts|tsx)$/.test(name))
|
||||||
})
|
.map((name) => ({
|
||||||
})
|
pfad: `test/${name}`,
|
||||||
|
inhalt: readFileSync(join(WURZEL, 'test', name), 'utf8'),
|
||||||
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
|
}))
|
||||||
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
|
const verstoesse = [...dateien, ...testDateien]
|
||||||
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
|
.filter((d) => /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(d.inhalt))
|
||||||
// aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul.
|
.map((d) => d.pfad)
|
||||||
const verstoesse = dateien
|
expect(verstoesse).toEqual([])
|
||||||
.filter((d) => d.pfad !== 'src/kern/speicher/db.ts')
|
})
|
||||||
.filter((d) => /['"]node:sqlite['"]/.test(d.inhalt))
|
})
|
||||||
.map((d) => d.pfad)
|
|
||||||
expect(verstoesse).toEqual([])
|
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
|
||||||
|
// aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul.
|
||||||
|
const verstoesse = dateien
|
||||||
|
.filter((d) => d.pfad !== 'src/kern/speicher/db.ts')
|
||||||
|
.filter((d) => /['"]node:sqlite['"]/.test(d.inhalt))
|
||||||
|
.map((d) => d.pfad)
|
||||||
|
expect(verstoesse).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user