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 }>
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,16 @@
|
|||||||
// Erst-Einrichtung, Dialoge). Die Regeln bleiben: EINE Einstellungs-Quelle
|
// Erst-Einrichtung, Dialoge). Die Regeln bleiben: EINE Einstellungs-Quelle
|
||||||
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4
|
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4
|
||||||
// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte).
|
// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte).
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
import type {
|
||||||
|
DiscInfoStand,
|
||||||
|
DiscTitelVorschlag,
|
||||||
|
GeraeteInfo,
|
||||||
|
HauptStatus,
|
||||||
|
RipStatus,
|
||||||
|
TitelRolle,
|
||||||
|
TitelStand,
|
||||||
|
} from '../gemeinsam/nachrichten'
|
||||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||||
import {
|
import {
|
||||||
aufVerbindung,
|
aufVerbindung,
|
||||||
@@ -18,7 +26,9 @@ import {
|
|||||||
metadatenSuchen,
|
metadatenSuchen,
|
||||||
ripAbbrechen,
|
ripAbbrechen,
|
||||||
ripStarten,
|
ripStarten,
|
||||||
|
ripUeberspringen,
|
||||||
schluesselPruefen,
|
schluesselPruefen,
|
||||||
|
titelLesen,
|
||||||
verbindungStarten,
|
verbindungStarten,
|
||||||
zuordnungSetzen,
|
zuordnungSetzen,
|
||||||
type Verbindung,
|
type Verbindung,
|
||||||
@@ -83,6 +93,60 @@ const PHASEN_MONO: Record<RipStatus['phase'], string> = {
|
|||||||
|
|
||||||
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
||||||
|
|
||||||
|
// ── Eigene Fensterknöpfe (§ 4.2, seit 5.1.3) ────────────────────────────
|
||||||
|
// Bewusst schlicht und in Windows-Maßen (46 × 32) — wer sie sucht, findet
|
||||||
|
// sie dort, wo er sie erwartet. Sie sitzen in der Ziehfläche und müssen
|
||||||
|
// deshalb `nicht-ziehbar` sein, sonst verschluckt der Fenstergriff den Klick.
|
||||||
|
const IconMinimieren = (): React.JSX.Element => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||||
|
<path d="M0 5h10" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const IconMaximieren = (): React.JSX.Element => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||||
|
<rect x="0.6" y="0.6" width="8.8" height="8.8" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const IconWiederherstellen = (): React.JSX.Element => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||||
|
<rect x="0.6" y="2.6" width="6.8" height="6.8" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
<path d="M2.7 2.6V0.6h6.7v6.7h-2" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const IconSchliessen = (): React.JSX.Element => (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||||
|
<path d="M0.6 0.6l8.8 8.8M9.4 0.6l-8.8 8.8" stroke="currentColor" strokeWidth="1.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
function FensterKnopf({
|
||||||
|
titel,
|
||||||
|
aufKlick,
|
||||||
|
rot = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titel: string
|
||||||
|
aufKlick: () => void
|
||||||
|
rot?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={aufKlick}
|
||||||
|
title={titel}
|
||||||
|
aria-label={titel}
|
||||||
|
className={`flex h-8 w-[46px] items-center justify-center text-dunst transition-colors ${
|
||||||
|
rot ? 'hover:bg-rot hover:text-white' : 'hover:bg-white/10 hover:text-schnee'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function sicherheitsWort(confidence: number): string {
|
function sicherheitsWort(confidence: number): string {
|
||||||
if (confidence >= 0.9) return 'sehr wahrscheinlich'
|
if (confidence >= 0.9) return 'sehr wahrscheinlich'
|
||||||
if (confidence >= 0.8) return 'wahrscheinlich'
|
if (confidence >= 0.8) return 'wahrscheinlich'
|
||||||
@@ -90,6 +154,18 @@ function sicherheitsWort(confidence: number): string {
|
|||||||
return 'unsicher'
|
return 'unsicher'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 'Staffel 1 · Disc 2 · 2 Folgen' — was die Disc über ihre Lage in der
|
||||||
|
* Ausgabe sagt. Die Folgenzahl stammt aus ihrem eigenen
|
||||||
|
* Inhaltsverzeichnis, ist also gemessen und nicht geraten. '' wenn die
|
||||||
|
* Disc nichts davon verrät. */
|
||||||
|
function discHerkunft(info: DiscInfoStand): string {
|
||||||
|
const teile: string[] = []
|
||||||
|
if (info.staffel !== null) teile.push(`Staffel ${info.staffel}`)
|
||||||
|
if (info.discNr !== null) teile.push(`Disc ${info.discNr}`)
|
||||||
|
if (info.folgen > 0) teile.push(info.folgen === 1 ? '1 Folge' : `${info.folgen} Folgen`)
|
||||||
|
return teile.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
|
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
|
||||||
* (eigene Wahl → allgemeines Preset → Empfehlung → Default). */
|
* (eigene Wahl → allgemeines Preset → Empfehlung → Default). */
|
||||||
function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
|
function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
|
||||||
@@ -196,6 +272,35 @@ function SegmentBalken({ prozent }: { prozent: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
|
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
|
||||||
|
|
||||||
|
/** Nur ein Klick, der WIRKLICH auf der Hintergrundfläche begann, schließt
|
||||||
|
* einen Dialog.
|
||||||
|
*
|
||||||
|
* Commander-Fund (01.09.2026): „es gibt einen kleinen bug im UI wenn man
|
||||||
|
* selbst den titel sucht das suchfenster schließt sich dann einfach."
|
||||||
|
* Der Grund ist eine bekannte Falle: Wer im Eingabefeld Text markiert und
|
||||||
|
* dabei über den Dialogrand hinauszieht, drückt INNEN und lässt AUSSEN
|
||||||
|
* los. Der Browser meldet als Ziel des Klicks dann den gemeinsamen
|
||||||
|
* Vorfahren — die Hintergrundfläche. Ein reiner onClick-Horcher darauf
|
||||||
|
* schließt den Dialog also mitten im Tippen.
|
||||||
|
*
|
||||||
|
* Deshalb wird der Anfang gemerkt: Nur wenn Drücken UND Loslassen auf der
|
||||||
|
* Fläche selbst passierten, ist es ein Klick daneben. */
|
||||||
|
function useHintergrundKlick(schliessen: () => void): {
|
||||||
|
onMouseDown: (e: React.MouseEvent) => void
|
||||||
|
onClick: (e: React.MouseEvent) => void
|
||||||
|
} {
|
||||||
|
const begannDraussen = useRef(false)
|
||||||
|
return {
|
||||||
|
onMouseDown: (e) => {
|
||||||
|
begannDraussen.current = e.target === e.currentTarget
|
||||||
|
},
|
||||||
|
onClick: (e) => {
|
||||||
|
if (begannDraussen.current && e.target === e.currentTarget) schliessen()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function AendernDialog({
|
function AendernDialog({
|
||||||
id,
|
id,
|
||||||
startText,
|
startText,
|
||||||
@@ -208,6 +313,7 @@ function AendernDialog({
|
|||||||
schliessen: () => void
|
schliessen: () => void
|
||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState(startText)
|
const [text, setText] = useState(startText)
|
||||||
|
const hintergrund = useHintergrundKlick(schliessen)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
metadatenSuchen(id, startText)
|
metadatenSuchen(id, startText)
|
||||||
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
|
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
|
||||||
@@ -216,7 +322,7 @@ function AendernDialog({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
|
||||||
onClick={schliessen}
|
{...hintergrund}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60"
|
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60"
|
||||||
@@ -286,6 +392,186 @@ function AendernDialog({
|
|||||||
|
|
||||||
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
|
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
|
||||||
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
|
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
|
||||||
|
|
||||||
|
// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ───────────────────────────
|
||||||
|
// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und
|
||||||
|
// Alterskennzeichen inklusive. Der Info-Lauf dauert 20–120 s, deshalb
|
||||||
|
// beginnt er erst hier und nicht schon beim Einlegen.
|
||||||
|
function dauerText(s: number): string {
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
return m >= 60
|
||||||
|
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
|
||||||
|
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitelWahlDialog({
|
||||||
|
id,
|
||||||
|
stand,
|
||||||
|
schliessen,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
stand: TitelStand | undefined
|
||||||
|
schliessen: () => void
|
||||||
|
}) {
|
||||||
|
const hintergrund = useHintergrundKlick(schliessen)
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
|
||||||
|
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
|
||||||
|
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
|
||||||
|
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
|
||||||
|
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
|
||||||
|
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
|
||||||
|
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
|
||||||
|
const umschalten = (nr: number): void => {
|
||||||
|
const neu = new Set(aktiv)
|
||||||
|
if (neu.has(nr)) neu.delete(nr)
|
||||||
|
else neu.add(nr)
|
||||||
|
setGewaehlt(neu)
|
||||||
|
}
|
||||||
|
const alle = stand?.titel ?? []
|
||||||
|
const gesamtBytes = alle.filter((t) => aktiv.has(t.nr)).reduce((s, t) => s + t.groesseBytes, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
|
||||||
|
{...hintergrund}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
||||||
|
|
||||||
|
{stand === undefined || stand.laeuft ? (
|
||||||
|
<p className="mt-4 font-mono text-xs text-nebel">
|
||||||
|
Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
|
||||||
|
</p>
|
||||||
|
) : stand.fehler.length > 0 ? (
|
||||||
|
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
|
||||||
|
{stand.unsicher ? '⚠ ' : ''}
|
||||||
|
{stand.einordnung}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
|
||||||
|
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
|
||||||
|
>
|
||||||
|
Rippys Vorschlag
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setGewaehlt(
|
||||||
|
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
|
||||||
|
// Sammeltitel noch Logos/Warnungen.
|
||||||
|
new Set(
|
||||||
|
alle
|
||||||
|
.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz'))
|
||||||
|
.map((t) => t.nr),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||||||
|
>
|
||||||
|
Hauptinhalt + Extras
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))}
|
||||||
|
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||||||
|
>
|
||||||
|
Alles
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setGewaehlt(new Set())}
|
||||||
|
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||||||
|
>
|
||||||
|
Nichts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{alle.map((t) => (
|
||||||
|
<label
|
||||||
|
key={t.nr}
|
||||||
|
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-2 ${
|
||||||
|
aktiv.has(t.nr) ? 'border-gold/40 bg-gold/8' : 'border-white/8 bg-samt/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={aktiv.has(t.nr)}
|
||||||
|
onChange={() => umschalten(t.nr)}
|
||||||
|
className="accent-[oklch(0.83_0.11_75)]"
|
||||||
|
/>
|
||||||
|
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
|
||||||
|
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
|
||||||
|
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
|
||||||
|
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">
|
||||||
|
{t.kapitel} Kap.
|
||||||
|
</span>
|
||||||
|
{aktiv.has(t.nr) ? (
|
||||||
|
<select
|
||||||
|
value={rolleVon(t)}
|
||||||
|
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-none rounded-lg border border-white/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
|
||||||
|
title="Bestimmt, WOHIN der Titel abgelegt wird"
|
||||||
|
>
|
||||||
|
<option value="hauptfilm">Hauptfilm</option>
|
||||||
|
<option value="extra">Extra</option>
|
||||||
|
<option value="folge">Folge</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{alle.length === 0 && (
|
||||||
|
<p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 font-mono text-[11px] text-nebel">
|
||||||
|
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-nebel">
|
||||||
|
{alle.some((t) => aktiv.has(t.nr) && rolleVon(t) === 'folge')
|
||||||
|
? 'Folgen landen unter Serien/… /Season NN. Die Episoden-Nummern setzt Rippy nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
|
||||||
|
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-between gap-2">
|
||||||
|
<button
|
||||||
|
onClick={schliessen}
|
||||||
|
className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
ripStarten(
|
||||||
|
id,
|
||||||
|
alle
|
||||||
|
.filter((t) => aktiv.has(t.nr))
|
||||||
|
.map((t) => ({ nr: t.nr, rolle: rolleVon(t), dauerS: t.dauerS })),
|
||||||
|
)
|
||||||
|
schliessen()
|
||||||
|
}}
|
||||||
|
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft}
|
||||||
|
className="rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4)] hover:brightness-105 disabled:opacity-35"
|
||||||
|
>
|
||||||
|
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LaufwerkKachel({
|
function LaufwerkKachel({
|
||||||
geraet,
|
geraet,
|
||||||
rip,
|
rip,
|
||||||
@@ -294,6 +580,7 @@ function LaufwerkKachel({
|
|||||||
ripStartMs,
|
ripStartMs,
|
||||||
protokoll,
|
protokoll,
|
||||||
verbindung,
|
verbindung,
|
||||||
|
titelStand,
|
||||||
}: {
|
}: {
|
||||||
geraet: GeraeteInfo
|
geraet: GeraeteInfo
|
||||||
rip: RipStatus | undefined
|
rip: RipStatus | undefined
|
||||||
@@ -302,8 +589,10 @@ function LaufwerkKachel({
|
|||||||
ripStartMs: number | undefined
|
ripStartMs: number | undefined
|
||||||
protokoll: string[]
|
protokoll: string[]
|
||||||
verbindung: Verbindung
|
verbindung: Verbindung
|
||||||
|
titelStand: TitelStand | undefined
|
||||||
}) {
|
}) {
|
||||||
const [aendernOffen, setAendernOffen] = useState(false)
|
const [aendernOffen, setAendernOffen] = useState(false)
|
||||||
|
const [titelOffen, setTitelOffen] = useState(false)
|
||||||
const [protokollOffen, setProtokollOffen] = useState(false)
|
const [protokollOffen, setProtokollOffen] = useState(false)
|
||||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||||
const discDa = geraet.status === 'ready'
|
const discDa = geraet.status === 'ready'
|
||||||
@@ -350,10 +639,14 @@ function LaufwerkKachel({
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => ripStarten(geraet.id)}
|
onClick={() => {
|
||||||
|
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
|
||||||
|
setTitelOffen(true)
|
||||||
|
titelLesen(geraet.id)
|
||||||
|
}}
|
||||||
disabled={!discDa}
|
disabled={!discDa}
|
||||||
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
|
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
|
||||||
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
|
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
|
||||||
>
|
>
|
||||||
<IconPlay />
|
<IconPlay />
|
||||||
Rippen
|
Rippen
|
||||||
@@ -415,7 +708,9 @@ function LaufwerkKachel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-3 grid max-w-2xl grid-cols-4 gap-2.5">
|
{/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im
|
||||||
|
breiten — vorher immer vier, also im schmalen gequetscht. */}
|
||||||
|
<div className="mt-3 grid max-w-3xl grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||||
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
|
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
|
||||||
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
|
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
|
||||||
<InfoKachel
|
<InfoKachel
|
||||||
@@ -446,6 +741,9 @@ function LaufwerkKachel({
|
|||||||
ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'}
|
ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{info !== undefined && discHerkunft(info).length > 0 && (
|
||||||
|
<Badge text={discHerkunft(info)} ton="blau" />
|
||||||
|
)}
|
||||||
{info !== undefined && info.schonGerippt !== null && (
|
{info !== undefined && info.schonGerippt !== null && (
|
||||||
<Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />
|
<Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />
|
||||||
)}
|
)}
|
||||||
@@ -479,6 +777,41 @@ function LaufwerkKachel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{ripLaeuft && <SegmentBalken prozent={rip.prozent} />}
|
{ripLaeuft && <SegmentBalken prozent={rip.prozent} />}
|
||||||
|
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
|
||||||
|
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
|
||||||
|
Titel {rip.titelNr} von {rip.titelGesamt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
|
||||||
|
wieder ist kein Fortschritt, sondern ein physischer
|
||||||
|
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
|
||||||
|
waehrend identische Zeilen durchs Protokoll rauschten. */}
|
||||||
|
{rip.haengt != null && (
|
||||||
|
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
|
||||||
|
<p className="font-mono text-[11px] font-semibold text-warn">
|
||||||
|
Die Disc hat an einer Stelle einen Defekt.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
|
||||||
|
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an
|
||||||
|
derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht besser — der Rest der
|
||||||
|
Disc ist davon nicht betroffen.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => ripUeberspringen(geraet.id)}
|
||||||
|
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
|
||||||
|
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
|
||||||
|
>
|
||||||
|
Titel ueberspringen
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-[10.5px] text-nebel">
|
||||||
|
oder weiter versuchen — abbrechen geht oben.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
|
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
|
||||||
> {rip.text}
|
> {rip.text}
|
||||||
</p>
|
</p>
|
||||||
@@ -516,6 +849,10 @@ function LaufwerkKachel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{titelOffen && (
|
||||||
|
<TitelWahlDialog id={geraet.id} stand={titelStand} schliessen={() => setTitelOffen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{aendernOffen && (
|
{aendernOffen && (
|
||||||
<AendernDialog
|
<AendernDialog
|
||||||
id={geraet.id}
|
id={geraet.id}
|
||||||
@@ -767,6 +1104,9 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
|||||||
const s = verbindung.schluessel
|
const s = verbindung.schluessel
|
||||||
const b = verbindung.beschaffung
|
const b = verbindung.beschaffung
|
||||||
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
|
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
|
||||||
|
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
|
||||||
|
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
|
||||||
|
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||||
const [aktiv, setAktiv] = useState('ablage')
|
const [aktiv, setAktiv] = useState('ablage')
|
||||||
|
|
||||||
const SEKTIONEN: Array<[string, string]> = [
|
const SEKTIONEN: Array<[string, string]> = [
|
||||||
@@ -789,9 +1129,12 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
|||||||
if (ordner !== null) setzen('ablage', ordner)
|
if (ordner !== null) setzen('ablage', ordner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schmales Fenster: die Sektionswahl legt sich als Zeile über die Karten.
|
||||||
|
// Ab lg steht sie wieder als mitlaufende Spalte daneben (Commander-Fund
|
||||||
|
// 01.09.2026: „alles gequetscht").
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-8">
|
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
|
||||||
<nav className="sticky top-4 flex h-fit w-44 flex-none flex-col gap-1">
|
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-4 lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
|
||||||
{SEKTIONEN.map(([id, name]) => (
|
{SEKTIONEN.map(([id, name]) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
@@ -807,7 +1150,9 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="min-w-0 max-w-2xl flex-1 space-y-5">
|
{/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem
|
||||||
|
breiten Fenster alles links klebte und rechts nichts stand. */}
|
||||||
|
<div className="min-w-0 flex-1 space-y-5">
|
||||||
<SektionsKarte
|
<SektionsKarte
|
||||||
id="ablage"
|
id="ablage"
|
||||||
titel="ABLAGE"
|
titel="ABLAGE"
|
||||||
@@ -1000,12 +1345,12 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
|||||||
aufWechsel={(an) => void window.rippy.autostartSetzen(an)}
|
aufWechsel={(an) => void window.rippy.autostartSetzen(an)}
|
||||||
label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)"
|
label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)"
|
||||||
/>
|
/>
|
||||||
<Feld
|
{/* Das Feld „Update-Adresse" ist am 01.09.2026 auf Zuruf des
|
||||||
name="Update-Adresse"
|
Commanders verschwunden: Updates kommen immer vom Gitea, ein
|
||||||
hinweis="leer = Standard; NUR https:// wird angenommen"
|
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
|
||||||
wert={werte['updateUrl'] ?? ''}
|
`updateUrl` gilt weiterhin — wer ihn wirklich braucht, setzt
|
||||||
aufAendern={(w) => setzen('updateUrl', w)}
|
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
|
||||||
/>
|
https://). */}
|
||||||
{haupt !== null && (
|
{haupt !== null && (
|
||||||
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||||
<p>Selbst-Update: {haupt.update.text}</p>
|
<p>Selbst-Update: {haupt.update.text}</p>
|
||||||
@@ -1017,6 +1362,25 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
|
|||||||
{verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`}
|
{verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-nebel/70">Updates warten, solange ein Rip läuft — nie mitten im Vorgang.</p>
|
<p className="text-nebel/70">Updates warten, solange ein Rip läuft — nie mitten im Vorgang.</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => void window.rippy.updatePruefen()}
|
||||||
|
disabled={ripLaeuftGerade || haupt.update.laeuft}
|
||||||
|
className="rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-[11px] text-dunst hover:text-schnee disabled:opacity-35"
|
||||||
|
title={
|
||||||
|
ripLaeuftGerade
|
||||||
|
? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).'
|
||||||
|
: 'Fragt sofort beim Update-Server nach.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{haupt.update.laeuft ? 'sucht …' : 'Jetzt auf Updates prüfen'}
|
||||||
|
</button>
|
||||||
|
{haupt.update.neueVersion.length > 0 && (
|
||||||
|
<span className="text-gold-hell">
|
||||||
|
Version {haupt.update.neueVersion} gefunden — siehe Übersicht.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SektionsKarte>
|
</SektionsKarte>
|
||||||
@@ -1163,10 +1527,24 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Automatik', 'Fertig']
|
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
|
||||||
|
// Preset-Empfehlung. Stand bisher nur in den Einstellungen und war
|
||||||
|
// deshalb unsichtbar (Commander-Fund 01.09.2026: „die alte Version konnte
|
||||||
|
// automatisch anhand der gescannten hardware encoder das beste preset
|
||||||
|
// wählen"). Sie konnte es, diese auch — man sah es nur nirgends.
|
||||||
|
const backends = werkzeuge?.backends ?? []
|
||||||
|
const presets = werkzeuge?.presets ?? []
|
||||||
|
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
|
||||||
|
const EMPFEHLUNGEN: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
['DVD', 'dvd'],
|
||||||
|
['Blu-ray', 'bluray'],
|
||||||
|
['4K-UHD', 'uhd'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Fertig']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl space-y-7 pt-6">
|
<div className="mx-auto max-w-2xl space-y-7 pt-6">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{schritte.map((name, i) => (
|
{schritte.map((name, i) => (
|
||||||
<span
|
<span
|
||||||
@@ -1184,7 +1562,10 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.</p>
|
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.</p>
|
||||||
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und sagt Bescheid.</p>
|
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und sagt Bescheid.</p>
|
||||||
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
|
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
|
||||||
<p>Dieses Setup stellt vier Dinge ein und dauert eine Minute.</p>
|
<p>
|
||||||
|
Rippy prüft gleich seine Werkzeuge und fragt dich danach drei Dinge: wohin, wie schnell, und
|
||||||
|
woher die Titel kommen. Dauert eine Minute — ändern kannst du alles später.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1204,7 +1585,16 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
{schritt === 2 && (
|
{schritt === 2 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder?</p>
|
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder?</p>
|
||||||
<p className="break-all font-mono text-xs text-nebel">{verbindung.einstellungen['ablage'] ?? ''}</p>
|
{(verbindung.einstellungen['ablage'] ?? '').length > 0 ? (
|
||||||
|
<p className="break-all font-mono text-xs text-gruen">{verbindung.einstellungen['ablage']}</p>
|
||||||
|
) : (
|
||||||
|
// Vorher stand hier eine LEERE Zeile — man sah nicht, wohin
|
||||||
|
// Rippy legt, wenn man nichts wählt. Es gibt aber eine Vorgabe
|
||||||
|
// (kern/index.ts: Videos\Rippy im Benutzerprofil).
|
||||||
|
<p className="font-mono text-xs text-nebel">
|
||||||
|
noch nichts gewählt — dann nimmt Rippy „Videos\Rippy" in deinem Benutzerordner
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void window.rippy.ordnerWaehlen('Ablage-Ordner wählen').then((ordner) => {
|
void window.rippy.ordnerWaehlen('Ablage-Ordner wählen').then((ordner) => {
|
||||||
@@ -1215,31 +1605,98 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
|
|||||||
>
|
>
|
||||||
Ordner wählen … (auch Netzlaufwerke)
|
Ordner wählen … (auch Netzlaufwerke)
|
||||||
</button>
|
</button>
|
||||||
|
<p className="text-xs text-nebel">
|
||||||
|
Rippy baut darin selbst die Struktur: Filme, Musik, ISO und roh. Ein Netzlaufwerk geht auch.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schritt === 3 && (
|
{schritt === 3 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-dunst">Wie viel soll Rippy allein entscheiden?</p>
|
<p className="text-sm text-dunst">
|
||||||
<label className="flex items-center gap-2 text-xs text-dunst">
|
Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung
|
||||||
<input
|
berechnen — die kostet Zeit, spart aber viel Platz.
|
||||||
type="checkbox"
|
</p>
|
||||||
checked={verbindung.einstellungen['transcodeEnabled'] !== 'false'}
|
<Schalter
|
||||||
onChange={(e) => einstellungSetzen('transcodeEnabled', e.target.checked ? 'true' : 'false')}
|
an={verbindung.einstellungen['transcodeEnabled'] !== 'false'}
|
||||||
className="accent-[oklch(0.83_0.11_75)]"
|
aufWechsel={(an) => einstellungSetzen('transcodeEnabled', an ? 'true' : 'false')}
|
||||||
|
label="Nach dem Rip automatisch komprimieren"
|
||||||
/>
|
/>
|
||||||
Nach dem Rip automatisch komprimieren (je Disc-Typ später fein einstellbar)
|
|
||||||
</label>
|
{/* Die Hardware-Empfehlung — gemessen, nicht geraten. */}
|
||||||
|
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
|
||||||
|
{werkzeuge === null ? (
|
||||||
|
<p className="font-mono text-xs text-nebel">Encoder werden gemessen …</p>
|
||||||
|
) : backends.length === 0 ? (
|
||||||
|
<p className="font-mono text-xs text-warn">
|
||||||
|
Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider text-nebel">
|
||||||
|
AUF DIESER MASCHINE GEMESSEN:
|
||||||
|
</span>
|
||||||
|
{backends.map((backend) => (
|
||||||
|
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-dunst">
|
||||||
|
Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt:
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
{EMPFEHLUNGEN.map(([name, typ]) => (
|
||||||
|
<div key={typ} className="rounded-lg border border-white/8 bg-buehne/60 p-2">
|
||||||
|
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
|
||||||
|
<p className="mt-0.5 font-mono text-xs text-gold-hell">
|
||||||
|
{presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{nurCpu && (
|
||||||
|
<p className="mt-2 text-xs text-warn">
|
||||||
|
Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der
|
||||||
|
verlustfreie Rip ist davon nicht betroffen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-nebel">
|
<p className="text-xs text-nebel">
|
||||||
Alles Weitere — Presets je Disc-Typ, Sprachen, Medienserver, API-Schlüssel — steht unter
|
Du musst hier nichts wählen. Unter „Einstellungen → Kompression" kannst du je Disc-Typ ein
|
||||||
„Einstellungen".
|
eigenes Preset setzen — deine Wahl schlägt die Empfehlung immer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schritt === 4 && (
|
{schritt === 4 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-dunst">
|
||||||
|
Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen
|
||||||
|
kostenlosen TMDb-Schlüssel.
|
||||||
|
</p>
|
||||||
|
<Feld
|
||||||
|
name="TMDb-API-Key"
|
||||||
|
hinweis="kostenlos auf themoviedb.org → Einstellungen → API; in zwei Minuten geholt"
|
||||||
|
wert={verbindung.einstellungen['tmdbApiKey'] ?? ''}
|
||||||
|
aufAendern={(w) => einstellungSetzen('tmdbApiKey', w)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-nebel">
|
||||||
|
Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann so, wie die Disc sich selbst
|
||||||
|
nennt, und es gibt keine Poster. Nachtragen geht jederzeit unter „Einstellungen →
|
||||||
|
Metadaten".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 5 && (
|
||||||
<div className="space-y-3 text-sm text-dunst">
|
<div className="space-y-3 text-sm text-dunst">
|
||||||
<p>Das war's. Disc einlegen — Rippy meldet sich.</p>
|
<p>Das war's. Disc einlegen — Rippy meldet sich.</p>
|
||||||
|
<p className="text-xs text-nebel">
|
||||||
|
Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über
|
||||||
|
das Tray-Menü.
|
||||||
|
</p>
|
||||||
{makemkvFehlt && (
|
{makemkvFehlt && (
|
||||||
<p className="text-xs text-rot">
|
<p className="text-xs text-rot">
|
||||||
Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt
|
Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt
|
||||||
@@ -1281,6 +1738,9 @@ type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek'
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
|
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
|
||||||
|
// Sagt ein Update-Knopf NEIN, steht der Grund daneben (R4) — ein Knopf,
|
||||||
|
// der nichts tut und nichts sagt, ist schlimmer als kein Knopf.
|
||||||
|
const [updateHinweis, setUpdateHinweis] = useState('')
|
||||||
// #einstellungen / #bibliothek als Startanker — der Smoke-Beweis
|
// #einstellungen / #bibliothek als Startanker — der Smoke-Beweis
|
||||||
// fotografiert damit auch die anderen Bereiche.
|
// fotografiert damit auch die anderen Bereiche.
|
||||||
const [bereich, setBereich] = useState<Bereich>(() => {
|
const [bereich, setBereich] = useState<Bereich>(() => {
|
||||||
@@ -1294,6 +1754,7 @@ export default function App() {
|
|||||||
laufwerke: [],
|
laufwerke: [],
|
||||||
rips: {},
|
rips: {},
|
||||||
discInfos: {},
|
discInfos: {},
|
||||||
|
discTitel: {},
|
||||||
vorschlaege: {},
|
vorschlaege: {},
|
||||||
ripStart: {},
|
ripStart: {},
|
||||||
ripProtokoll: {},
|
ripProtokoll: {},
|
||||||
@@ -1325,8 +1786,13 @@ export default function App() {
|
|||||||
|
|
||||||
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
|
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
|
||||||
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
|
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
|
||||||
|
//
|
||||||
|
// `#einrichtung` erzwingt sie (RIPPY_START_BEREICH) — wie #einstellungen
|
||||||
|
// und #bibliothek. Ohne diesen Anker wäre der Wizard nach dem ersten
|
||||||
|
// Start nicht mehr zu fotografieren und damit nicht mehr zu prüfen.
|
||||||
const setupOffen =
|
const setupOffen =
|
||||||
Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true'
|
window.location.hash === '#einrichtung' ||
|
||||||
|
(Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true')
|
||||||
|
|
||||||
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
|
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
|
||||||
<button
|
<button
|
||||||
@@ -1355,30 +1821,53 @@ export default function App() {
|
|||||||
<div className="buehnenlicht pointer-events-none fixed inset-0" />
|
<div className="buehnenlicht pointer-events-none fixed inset-0" />
|
||||||
<div className="filmkorn pointer-events-none fixed inset-0" />
|
<div className="filmkorn pointer-events-none fixed inset-0" />
|
||||||
|
|
||||||
<header className="relative border-b border-white/8 px-7 py-3.5">
|
{/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des
|
||||||
<div className="flex items-center justify-between gap-3">
|
rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt
|
||||||
<div className="flex items-center gap-3.5">
|
`nicht-ziehbar`, sonst schluckt der Griff den Klick. */}
|
||||||
<IconDisc groesse={30} farbe="oklch(0.83 0.11 75)" />
|
<header className="ziehbar relative flex-none border-b border-white/8">
|
||||||
<h1 className="font-display text-[25px] font-bold tracking-tight">Rippy</h1>
|
<div className="mx-auto flex w-full max-w-[1600px] items-center gap-3 py-2 pl-5 pr-0 sm:pl-7">
|
||||||
<span className="rounded-md border border-white/12 px-2 py-0.5 font-mono text-[10px] text-nebel">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<IconDisc groesse={28} farbe="oklch(0.83 0.11 75)" />
|
||||||
|
<h1 className="font-display text-[22px] font-bold tracking-tight sm:text-[25px]">Rippy</h1>
|
||||||
|
<span className="hidden rounded-md border border-white/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">
|
||||||
v{haupt?.version ?? '…'}
|
v{haupt?.version ?? '…'}
|
||||||
</span>
|
</span>
|
||||||
{einRipLaeuft && (
|
{einRipLaeuft && (
|
||||||
<span className="inline-flex items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
|
<span className="inline-flex flex-none items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
|
||||||
REC
|
REC
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-1.5">
|
<nav className="nicht-ziehbar ml-auto flex flex-wrap justify-end gap-1.5">
|
||||||
{knopf('uebersicht', 'Übersicht')}
|
{knopf('uebersicht', 'Übersicht')}
|
||||||
{knopf('einstellungen', 'Einstellungen')}
|
{knopf('einstellungen', 'Einstellungen')}
|
||||||
{knopf('bibliothek', `Bibliothek${verbindung.bibliothek.length > 0 ? ` (${verbindung.bibliothek.length})` : ''}`)}
|
{knopf('bibliothek', `Bibliothek${verbindung.bibliothek.length > 0 ? ` (${verbindung.bibliothek.length})` : ''}`)}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="nicht-ziehbar ml-1 flex flex-none items-center self-start">
|
||||||
|
<FensterKnopf titel="Minimieren" aufKlick={() => void window.rippy.fensterSteuern('minimieren')}>
|
||||||
|
<IconMinimieren />
|
||||||
|
</FensterKnopf>
|
||||||
|
<FensterKnopf
|
||||||
|
titel={haupt?.fensterMaximiert === true ? 'Wiederherstellen' : 'Maximieren'}
|
||||||
|
aufKlick={() => void window.rippy.fensterSteuern('maximieren')}
|
||||||
|
>
|
||||||
|
{haupt?.fensterMaximiert === true ? <IconWiederherstellen /> : <IconMaximieren />}
|
||||||
|
</FensterKnopf>
|
||||||
|
{/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein
|
||||||
|
laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */}
|
||||||
|
<FensterKnopf
|
||||||
|
titel="In den Infobereich (Rippy läuft weiter)"
|
||||||
|
rot
|
||||||
|
aufKlick={() => void window.rippy.fensterSteuern('schliessen')}
|
||||||
|
>
|
||||||
|
<IconSchliessen />
|
||||||
|
</FensterKnopf>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="relative flex-1 space-y-7 p-7">
|
<main className="relative mx-auto w-full max-w-[1600px] flex-1 space-y-6 px-5 py-6 sm:px-7 sm:py-7">
|
||||||
{setupOffen && <ErstEinrichtung verbindung={verbindung} />}
|
{setupOffen && <ErstEinrichtung verbindung={verbindung} />}
|
||||||
{!setupOffen && bereich === 'uebersicht' && (
|
{!setupOffen && bereich === 'uebersicht' && (
|
||||||
<>
|
<>
|
||||||
@@ -1392,6 +1881,62 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* § 6.8: Ein verfügbares Update ist eine SICHTBARE Karte, kein
|
||||||
|
Satz in den Einstellungen. Der Commander musste es am
|
||||||
|
01.09.2026 suchen — gefunden hat er es nicht. */}
|
||||||
|
{haupt !== null && haupt.update.neueVersion.length > 0 && (
|
||||||
|
<div className="rounded-2xl border border-gold/40 bg-gold/10 p-4">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<span className="font-display text-lg text-gold-hell">
|
||||||
|
Update verfügbar — Version {haupt.update.neueVersion}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-nebel">du hast {haupt.version}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
|
||||||
|
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
|
||||||
|
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gold transition-all"
|
||||||
|
style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{haupt.update.neuerungen.length > 0 && (
|
||||||
|
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/60 p-3">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">
|
||||||
|
{haupt.update.neuerungen}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{haupt.update.bereit && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setUpdateHinweis('')
|
||||||
|
void window.rippy.updateInstallieren().then((urteil) => {
|
||||||
|
if (!urteil.ok) setUpdateHinweis(urteil.grund)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={einRipLaeuft}
|
||||||
|
className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/25 disabled:opacity-35"
|
||||||
|
title={
|
||||||
|
einRipLaeuft
|
||||||
|
? 'Erst wenn der Rip fertig ist — Rippy startet dafür neu.'
|
||||||
|
: 'Rippy beendet sich, installiert und startet wieder.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Jetzt neu starten und installieren
|
||||||
|
</button>
|
||||||
|
<span className="font-mono text-[11px] text-nebel">
|
||||||
|
… oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateHinweis.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{updateHinweis}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{verbindung.laufwerke.length === 0 ? (
|
{verbindung.laufwerke.length === 0 ? (
|
||||||
<div className="flex items-center gap-5 rounded-2xl border border-white/10 bg-karte/50 p-6">
|
<div className="flex items-center gap-5 rounded-2xl border border-white/10 bg-karte/50 p-6">
|
||||||
@@ -1412,6 +1957,7 @@ export default function App() {
|
|||||||
ripStartMs={verbindung.ripStart[geraet.id]}
|
ripStartMs={verbindung.ripStart[geraet.id]}
|
||||||
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
|
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
|
||||||
verbindung={verbindung}
|
verbindung={verbindung}
|
||||||
|
titelStand={verbindung.discTitel[geraet.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+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; }
|
||||||
|
|||||||
@@ -57,6 +57,59 @@ export interface RipStatus {
|
|||||||
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
|
||||||
|
* und in Fehler-Meldungen; die kennen keine Titel. */
|
||||||
|
titelNr?: number
|
||||||
|
titelGesamt?: number
|
||||||
|
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
|
||||||
|
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
|
||||||
|
haengt?: { offset: string; wiederholungen: number } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
|
||||||
|
* hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
|
||||||
|
* extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
|
||||||
|
* folge → Serien/<Titel>/Season NN/… S01E02.mkv
|
||||||
|
* EINE Definition für Kern und Fenster. */
|
||||||
|
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
|
||||||
|
|
||||||
|
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
|
||||||
|
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
|
||||||
|
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
|
||||||
|
* bei eindeutiger Zuordnung um (§ 4.2). */
|
||||||
|
export interface RipAuftragTitel {
|
||||||
|
nr: number
|
||||||
|
rolle: TitelRolle
|
||||||
|
dauerS: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
|
||||||
|
export interface DiscTitelVorschlag {
|
||||||
|
nr: number
|
||||||
|
dauerS: number
|
||||||
|
groesseBytes: number
|
||||||
|
kapitel: number
|
||||||
|
/** Von Rippy vorausgewählt. */
|
||||||
|
gewaehlt: boolean
|
||||||
|
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
|
||||||
|
rolle: TitelRolle
|
||||||
|
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
||||||
|
grund: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
||||||
|
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
|
||||||
|
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
|
||||||
|
export interface TitelStand {
|
||||||
|
id: string
|
||||||
|
laeuft: boolean
|
||||||
|
titel: DiscTitelVorschlag[]
|
||||||
|
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
|
||||||
|
einordnung: string
|
||||||
|
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
|
||||||
|
unsicher: boolean
|
||||||
|
fehler: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||||||
@@ -75,6 +128,13 @@ export interface DiscInfoStand {
|
|||||||
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||||||
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||||||
schonGerippt: string | null
|
schonGerippt: string | null
|
||||||
|
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
|
||||||
|
discNr: number | null
|
||||||
|
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
|
||||||
|
staffel: number | null
|
||||||
|
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines
|
||||||
|
* gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */
|
||||||
|
folgen: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
||||||
@@ -129,6 +189,7 @@ export type KernNachricht =
|
|||||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||||
| { art: 'rip-status'; status: RipStatus }
|
| { art: 'rip-status'; status: RipStatus }
|
||||||
| { art: 'disc-info'; info: DiscInfoStand }
|
| { art: 'disc-info'; info: DiscInfoStand }
|
||||||
|
| { art: 'disc-titel'; stand: TitelStand }
|
||||||
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
||||||
| { art: 'einstellungen'; werte: Record<string, string> }
|
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||||
@@ -156,8 +217,16 @@ export type KernNachricht =
|
|||||||
export type FensterNachricht =
|
export type FensterNachricht =
|
||||||
| { art: 'ping'; zeit: number }
|
| { art: 'ping'; zeit: number }
|
||||||
| { art: 'auswerfen'; id: string }
|
| { art: 'auswerfen'; id: string }
|
||||||
| { art: 'rip-start'; id: string }
|
/** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 s, deshalb erst auf Zuruf
|
||||||
|
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||||
|
* sehr sehr lange"). */
|
||||||
|
| { art: 'titel-lesen'; id: string }
|
||||||
|
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
||||||
|
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[] }
|
||||||
| { art: 'rip-abbruch'; id: string }
|
| { art: 'rip-abbruch'; id: string }
|
||||||
|
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
||||||
|
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
||||||
|
| { art: 'rip-ueberspringen'; id: string }
|
||||||
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
||||||
| { art: 'metadaten-suchen'; id: string; text: string }
|
| { art: 'metadaten-suchen'; id: string; text: string }
|
||||||
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
||||||
@@ -182,8 +251,31 @@ export interface HauptStatus {
|
|||||||
leine: { ok: boolean; fehler?: string }
|
leine: { ok: boolean; fehler?: string }
|
||||||
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
kern: { laeuft: boolean; pid?: number; neustarts: number }
|
||||||
autostart: boolean
|
autostart: boolean
|
||||||
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */
|
/** Selbst-Update (§ 6.8). */
|
||||||
update: { letzteErfolgreichePruefung: string; text: string }
|
update: UpdateStand
|
||||||
|
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
|
||||||
|
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
|
||||||
|
fensterMaximiert: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt
|
||||||
|
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
|
||||||
|
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
|
||||||
|
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
|
||||||
|
export interface UpdateStand {
|
||||||
|
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
|
||||||
|
letzteErfolgreichePruefung: string
|
||||||
|
text: string
|
||||||
|
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
|
||||||
|
neueVersion: string
|
||||||
|
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
|
||||||
|
neuerungen: string
|
||||||
|
/** Geladen und wartet auf den Neustart. */
|
||||||
|
bereit: boolean
|
||||||
|
/** Eine Prüfung oder ein Download läuft gerade. */
|
||||||
|
laeuft: boolean
|
||||||
|
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
|
||||||
|
fortschritt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
function istObjekt(wert: unknown): wert is Record<string, unknown> {
|
||||||
@@ -220,6 +312,13 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
|||||||
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
||||||
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
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':
|
case 'einstellungen':
|
||||||
return istObjekt(wert.werte)
|
return istObjekt(wert.werte)
|
||||||
case 'werkzeuge':
|
case 'werkzeuge':
|
||||||
@@ -277,7 +376,13 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
|
|||||||
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||||
if (!istObjekt(wert)) return false
|
if (!istObjekt(wert)) return false
|
||||||
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
if (wert.art === 'ping') return typeof wert.zeit === 'number'
|
||||||
if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
|
if (
|
||||||
|
wert.art === 'auswerfen' ||
|
||||||
|
wert.art === 'titel-lesen' ||
|
||||||
|
wert.art === 'rip-start' ||
|
||||||
|
wert.art === 'rip-abbruch' ||
|
||||||
|
wert.art === 'rip-ueberspringen'
|
||||||
|
) {
|
||||||
return typeof wert.id === 'string' && wert.id.length > 0
|
return typeof wert.id === 'string' && wert.id.length > 0
|
||||||
}
|
}
|
||||||
if (wert.art === 'einstellung-setzen') {
|
if (wert.art === 'einstellung-setzen') {
|
||||||
|
|||||||
@@ -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: '' }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,17 @@
|
|||||||
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
import type {
|
||||||
|
BibliothekEintrag,
|
||||||
|
GeraeteInfo,
|
||||||
|
RipAuftragTitel,
|
||||||
|
RipStatus,
|
||||||
|
TitelRolle,
|
||||||
|
} from '../../gemeinsam/nachrichten'
|
||||||
import { baueNfo } from '../ablage/nfo'
|
import { baueNfo } from '../ablage/nfo'
|
||||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||||
import { posterSpeichern } from '../ablage/poster'
|
import { posterSpeichern } from '../ablage/poster'
|
||||||
import { zielordner } from '../ablage/struktur'
|
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
|
||||||
import { komprimieren } from '../komprimieren/handbrake'
|
import { komprimieren } from '../komprimieren/handbrake'
|
||||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||||
@@ -27,7 +33,12 @@ import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
|
|
||||||
interface LaufenderVorgang {
|
interface LaufenderVorgang {
|
||||||
|
/** Beendet den GANZEN Auftrag. */
|
||||||
abbruch: AbortController
|
abbruch: AbortController
|
||||||
|
/** Beendet nur den gerade laufenden Titel (überspringen). */
|
||||||
|
titelAbbruch?: AbortController
|
||||||
|
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
|
||||||
|
uebersprungen?: boolean
|
||||||
status: RipStatus
|
status: RipStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +72,14 @@ export interface PipelineUmgebung {
|
|||||||
zuordnung?: (id: string) => Zuordnung | null
|
zuordnung?: (id: string) => Zuordnung | null
|
||||||
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
||||||
fingerprint?: (id: string) => string | null
|
fingerprint?: (id: string) => string | null
|
||||||
|
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */
|
||||||
|
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
|
||||||
|
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden].
|
||||||
|
* Injiziert, damit die Pipeline ohne Netz prüfbar bleibt. */
|
||||||
|
episodenLaufzeiten?: (
|
||||||
|
tmdbId: number,
|
||||||
|
staffel: number,
|
||||||
|
) => Promise<ReadonlyArray<readonly [number, number]>>
|
||||||
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
|
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
|
||||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||||
@@ -86,8 +105,19 @@ export class Pipeline {
|
|||||||
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Startet die Kette für die ganze Disc ('all'). */
|
/** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
|
||||||
async starten(id: string): Promise<void> {
|
* weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
|
||||||
|
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
|
||||||
|
* während Folge 2 heil daneben lag). */
|
||||||
|
ueberspringen(id: string): void {
|
||||||
|
const vorgang = this.laufend.get(id)
|
||||||
|
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
|
||||||
|
vorgang.uebersprungen = true
|
||||||
|
vorgang.titelAbbruch.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
||||||
|
async starten(id: string, auftrag: readonly RipAuftragTitel[] = []): Promise<void> {
|
||||||
if (this.laufend.has(id)) {
|
if (this.laufend.has(id)) {
|
||||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||||
return
|
return
|
||||||
@@ -143,24 +173,63 @@ export class Pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||||
|
mkdirSync(rohOrdner, { recursive: true })
|
||||||
|
|
||||||
const start: RipStatus = {
|
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
|
||||||
|
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
|
||||||
|
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
|
||||||
|
// Trailer, Werbung und Alterskennzeichen.
|
||||||
|
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||||
|
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||||
|
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
||||||
|
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
|
||||||
|
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
|
||||||
|
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
|
||||||
|
const rolleJeDatei = new Map<string, TitelRolle>()
|
||||||
|
const dauerJeDatei = new Map<string, number>()
|
||||||
|
const alleDateien = new Set<string>()
|
||||||
|
let lesefehlerGesamt = false
|
||||||
|
const gescheitert: string[] = []
|
||||||
|
const uebersprungen: string[] = []
|
||||||
|
|
||||||
|
this.laufend.set(id, {
|
||||||
|
abbruch,
|
||||||
|
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let i = 0; i < laeufe.length; i++) {
|
||||||
|
const welcher = laeufe[i]
|
||||||
|
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
||||||
|
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
|
||||||
|
const titelAbbruch = new AbortController()
|
||||||
|
const weiterreichen = (): void => titelAbbruch.abort()
|
||||||
|
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
|
||||||
|
const vorgang = this.laufend.get(id)
|
||||||
|
if (vorgang !== undefined) {
|
||||||
|
vorgang.titelAbbruch = titelAbbruch
|
||||||
|
vorgang.uebersprungen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const beschriftung =
|
||||||
|
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})`
|
||||||
|
this.melden({
|
||||||
id,
|
id,
|
||||||
phase: 'rippt',
|
phase: 'rippt',
|
||||||
prozent: -1,
|
prozent: -1,
|
||||||
text: `Rippe verlustfrei nach ${rohOrdner} …`,
|
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`,
|
||||||
dateien: [],
|
dateien: [...alleDateien],
|
||||||
lesefehler: false,
|
lesefehler: lesefehlerGesamt,
|
||||||
}
|
titelNr: i + 1,
|
||||||
this.laufend.set(id, { abbruch, status: start })
|
titelGesamt: laeufe.length,
|
||||||
this.melden(start)
|
haengt: null,
|
||||||
|
})
|
||||||
|
|
||||||
const rip = await rippen({
|
const rip = await rippen({
|
||||||
programm,
|
programm,
|
||||||
geraetepfad: geraet.pfad,
|
geraetepfad: geraet.pfad,
|
||||||
zielOrdner: rohOrdner,
|
zielOrdner: rohOrdner,
|
||||||
titel: 'all',
|
titel: welcher,
|
||||||
abbruch: abbruch.signal,
|
abbruch: titelAbbruch.signal,
|
||||||
aufFortschritt: (prozent) => {
|
aufFortschritt: (prozent) => {
|
||||||
const alt = this.laufend.get(id)?.status
|
const alt = this.laufend.get(id)?.status
|
||||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||||
@@ -169,22 +238,79 @@ export class Pipeline {
|
|||||||
const alt = this.laufend.get(id)?.status
|
const alt = this.laufend.get(id)?.status
|
||||||
if (alt !== undefined) this.melden({ ...alt, text })
|
if (alt !== undefined) this.melden({ ...alt, text })
|
||||||
},
|
},
|
||||||
|
aufHaengt: (klemmt) => {
|
||||||
|
const alt = this.laufend.get(id)?.status
|
||||||
|
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||||
|
|
||||||
if (rip.status === 'cancelled') {
|
if (rip.status === 'cancelled') {
|
||||||
|
// Nur dieser Titel — oder der ganze Auftrag?
|
||||||
|
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||||
this.laufend.delete(id)
|
this.laufend.delete(id)
|
||||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
this.melden({
|
||||||
|
id,
|
||||||
|
phase: 'abgebrochen',
|
||||||
|
prozent: -1,
|
||||||
|
text: 'Abgebrochen.',
|
||||||
|
dateien: [...alleDateien],
|
||||||
|
lesefehler: lesefehlerGesamt,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
uebersprungen.push(beschriftung)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
|
||||||
if (rip.status === 'error') {
|
if (rip.status === 'error') {
|
||||||
|
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
|
||||||
|
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||||
|
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// rippen() liefert den ganzen Zielordner zurück, also auch die
|
||||||
|
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
||||||
|
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
|
||||||
|
for (const d of rip.dateien) alleDateien.add(d)
|
||||||
|
// Ohne Auswahl ('all') gilt für alles die Film-Regel.
|
||||||
|
const rolle: TitelRolle = auftrag[i]?.rolle ?? 'hauptfilm'
|
||||||
|
for (const d of neu) {
|
||||||
|
rolleJeDatei.set(d, rolle)
|
||||||
|
dauerJeDatei.set(d, auftrag[i]?.dauerS ?? 0)
|
||||||
|
}
|
||||||
|
if (neu.length === 0) {
|
||||||
|
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
||||||
|
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
|
||||||
|
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nachLauf = this.laufend.get(id)
|
||||||
|
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
|
||||||
|
|
||||||
|
if (alleDateien.size === 0) {
|
||||||
this.laufend.delete(id)
|
this.laufend.delete(id)
|
||||||
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
|
this.melden({
|
||||||
|
id,
|
||||||
|
phase: 'fehler',
|
||||||
|
prozent: -1,
|
||||||
|
text: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.',
|
||||||
|
dateien: [],
|
||||||
|
lesefehler: lesefehlerGesamt,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const lesefehlerHinweis = rip.lesefehler
|
const rip = { dateien: [...alleDateien].sort(), lesefehler: lesefehlerGesamt }
|
||||||
|
|
||||||
|
const lesefehlerHinweis =
|
||||||
|
(rip.lesefehler
|
||||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||||
: ''
|
: '') +
|
||||||
|
(uebersprungen.length > 0 ? ` ⚠ Übersprungen: ${uebersprungen.join(', ')}.` : '') +
|
||||||
|
(gescheitert.length > 0 ? ` ⚠ Nicht gesichert: ${gescheitert.join(' · ')}` : '')
|
||||||
|
|
||||||
// ── Kompression (W-3) ────────────────────────────────────────────────
|
// ── Kompression (W-3) ────────────────────────────────────────────────
|
||||||
const einstellungen = this.umgebung.einstellungen()
|
const einstellungen = this.umgebung.einstellungen()
|
||||||
@@ -211,16 +337,40 @@ export class Pipeline {
|
|||||||
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
||||||
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
||||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||||
const fertigOrdner =
|
|
||||||
zuordnung !== null && zuordnung.titel.length > 0
|
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
|
||||||
|
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
|
||||||
|
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
|
||||||
|
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
|
||||||
|
const alsSerie = auftrag.some((a) => a.rolle === 'folge')
|
||||||
|
const staffel = this.umgebung.discAngaben?.(id)?.staffel ?? 1
|
||||||
|
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
|
||||||
|
const fertigOrdner = alsSerie
|
||||||
|
? serienOrdner(join(this.umgebung.ablage(), 'Serien'), serienTitel, staffel)
|
||||||
|
: zuordnung !== null && zuordnung.titel.length > 0
|
||||||
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
|
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
|
||||||
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||||
mkdirSync(fertigOrdner, { recursive: true })
|
mkdirSync(fertigOrdner, { recursive: true })
|
||||||
|
|
||||||
|
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so
|
||||||
|
// lesen Jellyfin, Emby und Kodi sie als Zugaben und nicht als
|
||||||
|
// zweiten Film. Der Ordner entsteht nur, wenn es wirklich Extras gibt.
|
||||||
|
const extrasOrdner = join(fertigOrdner, 'extras')
|
||||||
|
const zielFuer = (roh: string): string => {
|
||||||
|
const rolle = rolleJeDatei.get(roh) ?? 'hauptfilm'
|
||||||
|
return rolle === 'extra' ? extrasOrdner : fertigOrdner
|
||||||
|
}
|
||||||
|
if (rip.dateien.some((d) => zielFuer(d) === extrasOrdner)) {
|
||||||
|
mkdirSync(extrasOrdner, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
const fertigeDateien: string[] = []
|
const fertigeDateien: string[] = []
|
||||||
|
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||||
|
// Episoden-Zuordnung weiter unten.
|
||||||
|
const folgenSekunden: number[] = []
|
||||||
for (let i = 0; i < rip.dateien.length; i++) {
|
for (let i = 0; i < rip.dateien.length; i++) {
|
||||||
const eingabe = rip.dateien[i]
|
const eingabe = rip.dateien[i]
|
||||||
const ausgabe = join(fertigOrdner, basename(eingabe))
|
const ausgabe = join(zielFuer(eingabe), basename(eingabe))
|
||||||
const alt = this.laufend.get(id)?.status
|
const alt = this.laufend.get(id)?.status
|
||||||
if (alt !== undefined) {
|
if (alt !== undefined) {
|
||||||
this.melden({
|
this.melden({
|
||||||
@@ -270,6 +420,31 @@ export class Pipeline {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fertigeDateien.push(encode.ausgabe)
|
fertigeDateien.push(encode.ausgabe)
|
||||||
|
if ((rolleJeDatei.get(eingabe) ?? 'hauptfilm') === 'folge') {
|
||||||
|
folgenSekunden.push(dauerJeDatei.get(eingabe) ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
|
||||||
|
// Umbenannt wird NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als
|
||||||
|
// falsch (die offene Wunde #395 von ARM war genau dieses Ratespiel).
|
||||||
|
// Die Laufzeiten der Staffel kommen von TMDb; ohne sie bleibt es beim
|
||||||
|
// MakeMKV-Namen, und Rippy SAGT das.
|
||||||
|
const ablageMeldungen: string[] = []
|
||||||
|
if (alsSerie && folgenSekunden.length > 0) {
|
||||||
|
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
|
||||||
|
const laufzeiten =
|
||||||
|
tmdbId !== null ? ((await this.umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
|
||||||
|
const episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
||||||
|
if (episoden !== null) {
|
||||||
|
ablageMeldungen.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
|
||||||
|
} else {
|
||||||
|
ablageMeldungen.push(
|
||||||
|
laufzeiten.length === 0
|
||||||
|
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen.'
|
||||||
|
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
|
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
|
||||||
|
|||||||
@@ -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 => ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
||||||
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
||||||
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
function worte(t: string): Set<string> {
|
||||||
export function wortUeberlappung(a: string, b: string): number {
|
return new Set(
|
||||||
const worte = (t: string): Set<string> =>
|
|
||||||
new Set(
|
|
||||||
t
|
t
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[^\p{L}\p{N}]+/u)
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
.filter((w) => w.length > 0),
|
.filter((w) => w.length > 0),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
||||||
|
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
||||||
|
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
||||||
|
export function wortUeberlappung(a: string, b: string): number {
|
||||||
const wa = worte(a)
|
const 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
KRITISCHE_CODES,
|
KRITISCHE_CODES,
|
||||||
KRITISCHE_TEXTE,
|
KRITISCHE_TEXTE,
|
||||||
LESEFEHLER_MARKE,
|
LESEFEHLER_MARKE,
|
||||||
|
offsetAusMeldung,
|
||||||
parseMsg,
|
parseMsg,
|
||||||
parseStreamInfo,
|
parseStreamInfo,
|
||||||
parseTitelInfo,
|
parseTitelInfo,
|
||||||
@@ -38,18 +39,41 @@ export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: str
|
|||||||
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
|
||||||
|
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
|
||||||
|
* zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer
|
||||||
|
* von einem echten Defekt zu unterscheiden. */
|
||||||
|
export const HAENGT_AB = 12
|
||||||
|
|
||||||
|
/** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der
|
||||||
|
* Aufrufer schweigt dazu — abgelöst ist kein Fehler, sondern Absicht. */
|
||||||
|
export const ABGELOEST = 'titel-lauf-abgeloest'
|
||||||
|
|
||||||
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
||||||
export class RipAuswertung {
|
export class RipAuswertung {
|
||||||
prozent = -1
|
prozent = -1
|
||||||
letzteMeldung = ''
|
letzteMeldung = ''
|
||||||
lesefehler = false
|
lesefehler = false
|
||||||
kritischeMeldungen: string[] = []
|
kritischeMeldungen: string[] = []
|
||||||
|
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
|
||||||
|
haengtBei = ''
|
||||||
|
/** Wie oft dieselbe Stelle schon gemeldet wurde. */
|
||||||
|
haengtWiederholungen = 0
|
||||||
|
|
||||||
|
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
|
||||||
|
* der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */
|
||||||
|
haengt(): boolean {
|
||||||
|
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
|
||||||
|
}
|
||||||
|
|
||||||
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
||||||
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
||||||
const p = progressAusPrgv(zeile)
|
const p = progressAusPrgv(zeile)
|
||||||
if (p >= 0) {
|
if (p >= 0) {
|
||||||
if (p === this.prozent) return {}
|
if (p === this.prozent) return {}
|
||||||
|
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
|
||||||
|
this.haengtBei = ''
|
||||||
|
this.haengtWiederholungen = 0
|
||||||
this.prozent = p
|
this.prozent = p
|
||||||
return { prozent: p }
|
return { prozent: p }
|
||||||
}
|
}
|
||||||
@@ -57,6 +81,16 @@ export class RipAuswertung {
|
|||||||
if (meldung === null) return {}
|
if (meldung === null) return {}
|
||||||
this.letzteMeldung = meldung.text
|
this.letzteMeldung = meldung.text
|
||||||
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
||||||
|
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
|
||||||
|
const stelle = offsetAusMeldung(meldung.text)
|
||||||
|
if (stelle.length > 0) {
|
||||||
|
if (stelle === this.haengtBei) {
|
||||||
|
this.haengtWiederholungen += 1
|
||||||
|
} else {
|
||||||
|
this.haengtBei = stelle
|
||||||
|
this.haengtWiederholungen = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
if (meldung.code in KRITISCHE_CODES) {
|
if (meldung.code in KRITISCHE_CODES) {
|
||||||
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
|
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
|
||||||
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
|
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
|
||||||
@@ -103,6 +137,15 @@ export class ZeilenLeser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscInfo {
|
export interface DiscInfo {
|
||||||
|
/** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam — die
|
||||||
|
* kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief.
|
||||||
|
*
|
||||||
|
* Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 („Das
|
||||||
|
* Programm konnte keine verwendbaren optischen Laufwerke finden")
|
||||||
|
* antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
|
||||||
|
* stünde im Fenster nur „keine Titel gefunden" — und niemand wüsste,
|
||||||
|
* dass das Laufwerk gemeint ist und nicht die Disc. */
|
||||||
|
ursachen: string[]
|
||||||
titel: TitelInfo[]
|
titel: TitelInfo[]
|
||||||
streams: Map<number, TitelStreams>
|
streams: Map<number, TitelStreams>
|
||||||
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
|
||||||
@@ -114,6 +157,7 @@ export function titelInfoLesen(
|
|||||||
programm: string,
|
programm: string,
|
||||||
geraetepfad: string,
|
geraetepfad: string,
|
||||||
timeoutMs = 300_000,
|
timeoutMs = 300_000,
|
||||||
|
abbruch?: AbortSignal,
|
||||||
): Promise<DiscInfo> {
|
): Promise<DiscInfo> {
|
||||||
return new Promise((fertig, scheitert) => {
|
return new Promise((fertig, scheitert) => {
|
||||||
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
|
||||||
@@ -127,16 +171,37 @@ export function titelInfoLesen(
|
|||||||
kind.kill()
|
kind.kill()
|
||||||
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
|
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
|
||||||
|
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
|
||||||
|
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
|
||||||
|
// die längst gelieferte Titelliste).
|
||||||
|
const aufgeben = (): void => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
kind.kill()
|
||||||
|
scheitert(new Error(ABGELOEST))
|
||||||
|
}
|
||||||
|
abbruch?.addEventListener('abort', aufgeben, { once: true })
|
||||||
kind.on('error', (fehler) => {
|
kind.on('error', (fehler) => {
|
||||||
clearTimeout(zeiger)
|
clearTimeout(zeiger)
|
||||||
|
abbruch?.removeEventListener('abort', aufgeben)
|
||||||
scheitert(fehler)
|
scheitert(fehler)
|
||||||
})
|
})
|
||||||
kind.on('close', () => {
|
kind.on('close', () => {
|
||||||
clearTimeout(zeiger)
|
clearTimeout(zeiger)
|
||||||
|
abbruch?.removeEventListener('abort', aufgeben)
|
||||||
const ausgabe = textVon(Buffer.concat(stuecke))
|
const ausgabe = textVon(Buffer.concat(stuecke))
|
||||||
const titel = parseTitelInfo(ausgabe)
|
const titel = parseTitelInfo(ausgabe)
|
||||||
const streams = parseStreamInfo(ausgabe)
|
const streams = parseStreamInfo(ausgabe)
|
||||||
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
// Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
|
||||||
|
const ursachen: string[] = []
|
||||||
|
for (const zeile of ausgabe.split('\n')) {
|
||||||
|
const meldung = parseMsg(zeile)
|
||||||
|
if (meldung === null) continue
|
||||||
|
if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
|
||||||
|
ursachen.push(KRITISCHE_CODES[meldung.code])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -154,6 +219,9 @@ export interface RipLaufOptionen {
|
|||||||
titel: string
|
titel: string
|
||||||
aufFortschritt?: (prozent: number) => void
|
aufFortschritt?: (prozent: number) => void
|
||||||
aufMeldung?: (code: number, text: string) => void
|
aufMeldung?: (code: number, text: string) => void
|
||||||
|
/** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) —
|
||||||
|
* das ist ein Disc-Defekt, kein Fortschrittsproblem. */
|
||||||
|
aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void
|
||||||
abbruch?: AbortSignal
|
abbruch?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,12 +247,22 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
|||||||
}
|
}
|
||||||
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
|
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 zeile = (z: string): void => {
|
||||||
const ergebnis = auswertung.verarbeite(z)
|
const ergebnis = auswertung.verarbeite(z)
|
||||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||||
if (ergebnis.meldung !== undefined) {
|
if (ergebnis.meldung !== undefined) {
|
||||||
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
|
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.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||||
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||||
|
|||||||
@@ -249,6 +249,26 @@ export function episodenTitel(dauern: Map<number, number>, episodenMinuten: numb
|
|||||||
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
|
// (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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -34,7 +34,15 @@ describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
|
|||||||
// § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die
|
// § 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
|
||||||
|
// Regel bleibt, was sie war — „Win32 nur an benannten Orten"; sie zählt
|
||||||
|
// jetzt drei statt zwei, und der Test hält alles andere weiterhin dicht.
|
||||||
|
const erlaubt = new Set([
|
||||||
|
'src/haupt/leine.ts',
|
||||||
|
'src/haupt/fensterrand.ts',
|
||||||
|
'src/kern/laufwerk/win32.ts',
|
||||||
|
])
|
||||||
|
|
||||||
it('koffi wird nirgendwo sonst angefasst', () => {
|
it('koffi wird nirgendwo sonst angefasst', () => {
|
||||||
const verstoesse = dateien
|
const verstoesse = dateien
|
||||||
@@ -97,6 +105,46 @@ describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Nachrichten-Schema — der Prüfer kennt JEDE Nachrichtenart', () => {
|
||||||
|
// Der teure Fund vom 01.09.2026: 'titel-lesen' und 'rip-ueberspringen'
|
||||||
|
// standen in der FensterNachricht-Union, aber NICHT in
|
||||||
|
// istFensterNachricht(). Der Kern verwarf sie — still. Im Fenster stand
|
||||||
|
// ewig „Rippy liest die Titel …", und nirgends stand ein Grund.
|
||||||
|
//
|
||||||
|
// Dieser Test liest den Quelltext: Jede `art: '…'` der Union muss im
|
||||||
|
// Prüfer wörtlich vorkommen. Eine neue Nachricht ohne Prüfung fällt
|
||||||
|
// damit sofort auf, statt erst am lebenden Objekt.
|
||||||
|
const schema = readFileSync(join(WURZEL, 'src', 'gemeinsam', 'nachrichten.ts'), 'utf8')
|
||||||
|
|
||||||
|
function artenAus(unionName: string): string[] {
|
||||||
|
const start = schema.indexOf(`export type ${unionName} =`)
|
||||||
|
expect(start, `${unionName} nicht gefunden`).toBeGreaterThan(-1)
|
||||||
|
// Der Union-Block reicht bis zur nächsten Deklaration auf oberster Ebene.
|
||||||
|
const rest = schema.slice(start + `export type ${unionName} =`.length)
|
||||||
|
const ende = rest.indexOf('\nexport ')
|
||||||
|
const block = ende > 0 ? rest.slice(0, ende) : rest
|
||||||
|
return [...new Set([...block.matchAll(/art:\s*'([a-z0-9-]+)'/g)].map((m) => m[1]))]
|
||||||
|
}
|
||||||
|
|
||||||
|
it('istFensterNachricht kennt jede FensterNachricht', () => {
|
||||||
|
const pruefer = schema.slice(schema.indexOf('export function istFensterNachricht'))
|
||||||
|
const fehlend = artenAus('FensterNachricht').filter((art) => !pruefer.includes(`'${art}'`))
|
||||||
|
expect(fehlend).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('istKernNachricht kennt jede KernNachricht', () => {
|
||||||
|
const pruefer = schema.slice(schema.indexOf('export function istKernNachricht'))
|
||||||
|
const fehlend = artenAus('KernNachricht').filter((art) => !pruefer.includes(`'${art}'`))
|
||||||
|
expect(fehlend).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('der Kern verwirft nichts still', () => {
|
||||||
|
// Wer eine Nachricht wegwirft, sagt WARUM (R4).
|
||||||
|
const kern = readFileSync(join(WURZEL, 'src', 'kern', 'index.ts'), 'utf8')
|
||||||
|
expect(kern).not.toMatch(/if\s*\(!istFensterNachricht\([^)]*\)\)\s*return/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
|
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
|
||||||
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
|
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
|
||||||
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
|
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
|
||||||
|
|||||||
Reference in New Issue
Block a user